Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add a BFFE endpoint for Learning Assistant (LA), including information about LA is enabled, LA message history, and LA audit trial. #140

Merged
merged 1 commit into from
Dec 4, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,12 @@ Change Log

Unreleased
**********

4.5.0 - 2024-12-04
******************
* Add local setup to readme
* Add a BFFE chat summary endpoint for Learning Assistant, including information about whether the Learning Assistant is
enabled, Learning Assistant message history, and Learning Assistant audit trial data.

4.4.7 - 2024-11-25
******************
Expand Down
2 changes: 1 addition & 1 deletion learning_assistant/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,6 @@
Plugin for a learning assistant backend, intended for use within edx-platform.
"""

__version__ = '4.4.7'
__version__ = '4.5.0'

default_app_config = 'learning_assistant.apps.LearningAssistantConfig' # pylint: disable=invalid-name
104 changes: 93 additions & 11 deletions learning_assistant/api.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""
Library for the learning_assistant app.
"""
import datetime
import logging
from datetime import datetime, timedelta

Expand All @@ -11,8 +12,13 @@
from jinja2 import BaseLoader, Environment
from opaque_keys import InvalidKeyError

from learning_assistant.constants import ACCEPTED_CATEGORY_TYPES, AUDIT_TRIAL_MAX_DAYS, CATEGORY_TYPE_MAP
from learning_assistant.data import LearningAssistantCourseEnabledData
try:
from common.djangoapps.course_modes.models import CourseMode
except ImportError:
CourseMode = None

from learning_assistant.constants import ACCEPTED_CATEGORY_TYPES, CATEGORY_TYPE_MAP
from learning_assistant.data import LearningAssistantAuditTrialData, LearningAssistantCourseEnabledData
from learning_assistant.models import (
LearningAssistantAuditTrial,
LearningAssistantCourseEnabled,
Expand Down Expand Up @@ -231,22 +237,98 @@ def get_message_history(courserun_key, user, message_count):
return message_history


def audit_trial_is_expired(user, upgrade_deadline):
def get_audit_trial_expiration_date(start_date):
"""
Given a user (User), get or create the corresponding LearningAssistantAuditTrial trial object.
Given a start date of an audit trial, calculate the expiration date of the audit trial.

Arguments:
* start_date (datetime): the start date of the audit trial

Returns:
* expiration_date (datetime): the expiration date of the audit trial
"""
# If the upgrade deadline has passed, return "True" for expired
DAYS_SINCE_UPGRADE_DEADLINE = datetime.now() - upgrade_deadline
if DAYS_SINCE_UPGRADE_DEADLINE >= timedelta(days=0):
return True
default_trial_length_days = 14
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good idea to have a default!


trial_length_days = getattr(settings, 'LEARNING_ASSISTANT_AUDIT_TRIAL_LENGTH_DAYS', default_trial_length_days)

if trial_length_days is None:
trial_length_days = default_trial_length_days

# If LEARNING_ASSISTANT_AUDIT_TRIAL_LENGTH_DAYS is set to a negative number, assume it should be 0.
# pylint: disable=consider-using-max-builtin
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

pylint told me to "consider" doing trial_length_days = max(trial_length_days, 0) instead, which I thought was less readable, so I didn't. Interesting that it forces me to address something it tells me to consider 😄.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"I'm asking telling you to consider, and I won't take a NO for an answer.." 🤌

if trial_length_days < 0:
trial_length_days = 0

expiration_datetime = start_date + timedelta(days=trial_length_days)
return expiration_datetime


def get_audit_trial(user):
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I had to create a get and a get_or_create method because when we make a POST call to the /chat endpoint, we want to create a trial if one doesn't exist, but when we make a GET call to the /summary endpoint, we do not.

"""
Given a user, return the associated audit trial data.

Arguments:
* user (User): the user

Returns:
* audit_trial_data (LearningAssistantAuditTrialData): the audit trial data
* user_id (int): the user's id
* start_date (datetime): the start date of the audit trial
* expiration_date (datetime): the expiration date of the audit trial
* None: if no audit trial exists for the user
"""
try:
audit_trial = LearningAssistantAuditTrial.objects.get(user=user)
except LearningAssistantAuditTrial.DoesNotExist:
return None

return LearningAssistantAuditTrialData(
user_id=user.id,
start_date=audit_trial.start_date,
expiration_date=get_audit_trial_expiration_date(audit_trial.start_date),
)


def get_or_create_audit_trial(user):
"""
Given a user, return the associated audit trial data, creating a new audit trial for the user if one does not exist.

Arguments:
* user (User): the user

Returns:
* audit_trial_data (LearningAssistantAuditTrialData): the audit trial data
* user_id (int): the user's id
* start_date (datetime): the start date of the audit trial
* expiration_date (datetime): the expiration date of the audit trial
"""
audit_trial, _ = LearningAssistantAuditTrial.objects.get_or_create(
user=user,
defaults={
"start_date": datetime.now(),
},
)

# If the user's trial is past its expiry date, return "True" for expired. Else, return False
DAYS_SINCE_TRIAL_START_DATE = datetime.now() - audit_trial.start_date
return DAYS_SINCE_TRIAL_START_DATE >= timedelta(days=AUDIT_TRIAL_MAX_DAYS)
return LearningAssistantAuditTrialData(
user_id=user.id,
start_date=audit_trial.start_date,
expiration_date=get_audit_trial_expiration_date(audit_trial.start_date),
)


def audit_trial_is_expired(audit_trial_data, courserun_key):
"""
Given a user (User), get or create the corresponding LearningAssistantAuditTrial trial object.
"""
course_mode = CourseMode.objects.get(course=courserun_key)

upgrade_deadline = course_mode.expiration_datetime()

# If the upgrade deadline has passed, return True for expired. Upgrade deadline is an optional attribute of a
# CourseMode, so if it's None, then do not return True.
days_until_upgrade_deadline = datetime.now() - upgrade_deadline if upgrade_deadline else None
if days_until_upgrade_deadline is not None and days_until_upgrade_deadline >= timedelta(days=0):
return True

# If the user's trial is past its expiry date, return True for expired. Else, return False.
return audit_trial_data is None or audit_trial_data.expiration_date <= datetime.now()
13 changes: 13 additions & 0 deletions learning_assistant/data.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
"""
Data classes for the Learning Assistant application.
"""
from datetime import datetime

from attrs import field, frozen, validators
from opaque_keys.edx.keys import CourseKey

Expand All @@ -13,3 +15,14 @@ class LearningAssistantCourseEnabledData:

course_key: CourseKey = field(validator=validators.instance_of(CourseKey))
enabled: bool = field(validator=validators.instance_of(bool))


@frozen
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question: does this just mean that this cannot be changed?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes. The frozen decorator makes LearningAssistantAuditTrialData class immutable, so once an instance of this class is created, its attributes cannot be changed.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

... just let it go.

class LearningAssistantAuditTrialData:
"""
Data class representing an audit learner's trial of the Learning Assistant.
"""

user_id: int = field(validator=validators.instance_of(int))
start_date: datetime = field(validator=validators.optional(validators.instance_of(datetime)))
expiration_date: datetime = field(validator=validators.optional(validators.instance_of(datetime)))
12 changes: 11 additions & 1 deletion learning_assistant/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,12 @@
from django.urls import re_path

from learning_assistant.constants import COURSE_ID_PATTERN
from learning_assistant.views import CourseChatView, LearningAssistantEnabledView, LearningAssistantMessageHistoryView
from learning_assistant.views import (
CourseChatView,
LearningAssistantChatSummaryView,
LearningAssistantEnabledView,
LearningAssistantMessageHistoryView,
)

app_name = 'learning_assistant'

Expand All @@ -24,4 +29,9 @@
LearningAssistantMessageHistoryView.as_view(),
name='message-history',
),
re_path(
fr'learning_assistant/v1/course_id/{COURSE_ID_PATTERN}/chat-summary',
LearningAssistantChatSummaryView.as_view(),
name='chat-summary',
),
]
Loading
Loading