-
Notifications
You must be signed in to change notification settings - Fork 1
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
fix: use course enrollment instead of course mode to calculate audit trial is expired #146
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -3,7 +3,7 @@ | |
""" | ||
import datetime | ||
import logging | ||
from datetime import datetime, timedelta | ||
from datetime import datetime, timedelta, timezone | ||
|
||
from django.conf import settings | ||
from django.contrib.auth import get_user_model | ||
|
@@ -12,11 +12,6 @@ | |
from jinja2 import BaseLoader, Environment | ||
from opaque_keys import InvalidKeyError | ||
|
||
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 ( | ||
|
@@ -307,19 +302,25 @@ def get_or_create_audit_trial(user): | |
) | ||
|
||
|
||
def audit_trial_is_expired(audit_trial_data, courserun_key): | ||
""" | ||
Given a user (User), get or create the corresponding LearningAssistantAuditTrial trial object. | ||
def audit_trial_is_expired(enrollment, audit_trial_data): | ||
""" | ||
course_mode = CourseMode.objects.get(course=courserun_key) | ||
Given an enrollment and audit_trial_data, return whether the audit trial is expired as a boolean. | ||
|
||
Arguments: | ||
* enrollment (CourseEnrollment): the user course enrollment | ||
* audit_trial_data (LearningAssistantAuditTrialData): the data related to the audit trial | ||
|
||
upgrade_deadline = course_mode.expiration_datetime() | ||
Returns: | ||
* audit_trial_is_expired (boolean): whether the audit trial is expired | ||
""" | ||
upgrade_deadline = enrollment.upgrade_deadline | ||
today = datetime.now(tz=timezone.utc) | ||
|
||
# If the upgrade deadline has passed, return True for expired. Upgrade deadline is an optional attribute of a | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Note: I've changed the way that this is calculated to avoid issues with timezone naive/aware timezones. This now converts all of the datetimes into dates so that we're just calculating the difference in whole days (I believe this is more consistent with the calculations of days remaining in the frontend as well). There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I don't see where in the JavaScript we're treating an expiration date as a whole day. I'm looking at the useCourseUpgrade hook. The Can we resolve the timezone issues by constructing the datetime the same way (i.e. timezone-aware or timezone-naive datetimes) between the two places that have issues - sounds like the code and the tests? Can you help me understand why we're calculating this by date? My concern is that we're using time data in the calculation on the frontend but not on the backend. Wouldn't a learner's audit trials be considered expired at midnight on the last day of the trial? The impact of this is that the frontend determines that the trial is not-expired, but the new Python code considers it expired. This means that the chat is going to show for the learner on the frontend, but the backend is going to return a 403 when the learner sends a message. Here's an example I worked through. Python
JavaScript
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think forcing the timezone as In the case of the frontend, the date received is in UTC but when a date is created like in the hook as I'm no expert on Python, but I think it should be similar, so parsing a UTC time should (IMHO) be converted to the server timezone, so it should be able to be compared with I don't think truncating the dates would be a viable solution. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Good call! I've updated to have the timezone set when creating datetime.now() to have it be timezone aware so it can be compared with the other times. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Do we know anything about the I'm concerned about the use of There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I can do some more research on this but the reason I did this was because my understanding is that when comparing two aware datetimes, the timezone is considered. So you can compare two aware datetimes and the timezone is taken into account. See here: https://stackabuse.com/comparing-datetimes-in-python-with-and-without-timezones/. If it's better I could convert all the timezones to UTC but I didn't think this was necessary. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Didn't know that. Good call. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think you're right that Python compares them correctly under-the-hood. I just couldn't find that state explicitly in the documentation. Looks good to me! There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. So yes, I confirmed by testing this:
And these are the outputs of each:
So it looks like the comparison of datetimes takes the timezones into account! |
||
# CourseMode, so if it's None, then do not return True. | ||
days_until_upgrade_deadline = datetime.now() - upgrade_deadline if upgrade_deadline else None | ||
# CourseEnrollment, so if it's None, then do not return True. | ||
days_until_upgrade_deadline = today - 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() | ||
return audit_trial_data is None or audit_trial_data.expiration_date <= today |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -2,7 +2,7 @@ | |
Test cases for the learning-assistant api module. | ||
""" | ||
import itertools | ||
from datetime import datetime, timedelta | ||
from datetime import datetime, timedelta, timezone | ||
from unittest.mock import MagicMock, patch | ||
|
||
import ddt | ||
|
@@ -491,6 +491,7 @@ class GetAuditTrialExpirationDateTests(TestCase): | |
""" | ||
Test suite for get_audit_trial_expiration_date. | ||
""" | ||
|
||
@ddt.data( | ||
(datetime(2024, 1, 1, 0, 0, 0), datetime(2024, 1, 2, 0, 0, 0), 1), | ||
(datetime(2024, 1, 18, 0, 0, 0), datetime(2024, 1, 19, 0, 0, 0), 1), | ||
|
@@ -516,6 +517,7 @@ class GetAuditTrialTests(TestCase): | |
""" | ||
Test suite for get_audit_trial. | ||
""" | ||
|
||
@freeze_time('2024-01-01') | ||
def setUp(self): | ||
super().setUp() | ||
|
@@ -548,6 +550,7 @@ class GetOrCreateAuditTrialTests(TestCase): | |
""" | ||
Test suite for get_or_create_audit_trial. | ||
""" | ||
|
||
def setUp(self): | ||
super().setUp() | ||
self.user = User(username='tester', email='[email protected]') | ||
|
@@ -596,86 +599,80 @@ def setUp(self): | |
self.user = User(username='tester', email='[email protected]') | ||
self.user.save() | ||
|
||
self.upgrade_deadline = datetime.now() + timedelta(days=1) # 1 day from now | ||
|
||
@freeze_time('2024-01-01') | ||
@patch('learning_assistant.api.CourseMode') | ||
def test_upgrade_deadline_expired(self, mock_course_mode): | ||
|
||
mock_mode = MagicMock() | ||
mock_mode.expiration_datetime.return_value = datetime.now() - timedelta(days=1) # yesterday | ||
mock_course_mode.objects.get.return_value = mock_mode | ||
@freeze_time('2024-01-01 00:00:01 UTC') | ||
def test_upgrade_deadline_expired(self): | ||
today = datetime.now(tz=timezone.utc) | ||
mock_enrollment = MagicMock() | ||
mock_enrollment.upgrade_deadline = today - timedelta(days=1) # yesterday | ||
|
||
start_date = datetime.now() | ||
start_date = today | ||
audit_trial_data = LearningAssistantAuditTrialData( | ||
user_id=self.user.id, | ||
start_date=start_date, | ||
expiration_date=start_date + timedelta(days=settings.LEARNING_ASSISTANT_AUDIT_TRIAL_LENGTH_DAYS), | ||
) | ||
|
||
self.assertEqual(audit_trial_is_expired(audit_trial_data, self.course_key), True) | ||
|
||
@freeze_time('2024-01-01') | ||
@patch('learning_assistant.api.CourseMode') | ||
def test_upgrade_deadline_none(self, mock_course_mode): | ||
self.assertEqual(audit_trial_is_expired(mock_enrollment, audit_trial_data), True) | ||
|
||
mock_mode = MagicMock() | ||
mock_mode.expiration_datetime.return_value = None | ||
mock_course_mode.objects.get.return_value = mock_mode | ||
@freeze_time('2024-01-01 00:00:01 UTC') | ||
def test_upgrade_deadline_none(self): | ||
today = datetime.now(tz=timezone.utc) | ||
mock_enrollment = MagicMock() | ||
mock_enrollment.upgrade_deadline = None | ||
|
||
# Verify that the audit trial data is considered when determing whether an audit trial is expired and not the | ||
# Verify that the audit trial data is considered when determining whether an audit trial is expired and not the | ||
# upgrade deadline. | ||
start_date = datetime.now() | ||
start_date = today | ||
audit_trial_data = LearningAssistantAuditTrialData( | ||
user_id=self.user.id, | ||
start_date=start_date, | ||
expiration_date=start_date + timedelta(days=settings.LEARNING_ASSISTANT_AUDIT_TRIAL_LENGTH_DAYS), | ||
) | ||
|
||
self.assertEqual(audit_trial_is_expired(audit_trial_data, self.course_key), False) | ||
self.assertEqual(audit_trial_is_expired(mock_enrollment, audit_trial_data), False) | ||
|
||
start_date = datetime.now() - timedelta(days=settings.LEARNING_ASSISTANT_AUDIT_TRIAL_LENGTH_DAYS + 1) | ||
start_date = today - timedelta(days=settings.LEARNING_ASSISTANT_AUDIT_TRIAL_LENGTH_DAYS + 1) | ||
audit_trial_data = LearningAssistantAuditTrialData( | ||
user_id=self.user.id, | ||
start_date=start_date, | ||
expiration_date=start_date + timedelta(days=settings.LEARNING_ASSISTANT_AUDIT_TRIAL_LENGTH_DAYS), | ||
) | ||
|
||
self.assertEqual(audit_trial_is_expired(audit_trial_data, self.course_key), True) | ||
self.assertEqual(audit_trial_is_expired(mock_enrollment, audit_trial_data), True) | ||
|
||
@ddt.data( | ||
# exactly the trial deadline | ||
datetime(year=2024, month=1, day=1) - timedelta(days=settings.LEARNING_ASSISTANT_AUDIT_TRIAL_LENGTH_DAYS), | ||
datetime(year=2024, month=1, day=1, tzinfo=timezone.utc) - | ||
timedelta(days=settings.LEARNING_ASSISTANT_AUDIT_TRIAL_LENGTH_DAYS), | ||
# 1 day more than trial deadline | ||
datetime(year=2024, month=1, day=1) - timedelta(days=settings.LEARNING_ASSISTANT_AUDIT_TRIAL_LENGTH_DAYS + 1), | ||
datetime(year=2024, month=1, day=1, tzinfo=timezone.utc) - | ||
timedelta(days=settings.LEARNING_ASSISTANT_AUDIT_TRIAL_LENGTH_DAYS + 1), | ||
) | ||
@freeze_time('2024-01-01') | ||
@patch('learning_assistant.api.CourseMode') | ||
def test_audit_trial_expired(self, start_date, mock_course_mode): | ||
mock_mode = MagicMock() | ||
mock_mode.expiration_datetime.return_value = datetime.now() + timedelta(days=1) # tomorrow | ||
mock_course_mode.objects.get.return_value = mock_mode | ||
@freeze_time('2024-01-01 00:00:01 UTC') | ||
def test_audit_trial_expired(self, start_date): | ||
today = datetime.now(tz=timezone.utc) | ||
mock_enrollment = MagicMock() | ||
mock_enrollment.upgrade_deadline = today + timedelta(days=1) # tomorrow | ||
|
||
audit_trial_data = LearningAssistantAuditTrialData( | ||
user_id=self.user.id, | ||
start_date=start_date, | ||
expiration_date=get_audit_trial_expiration_date(start_date), | ||
) | ||
|
||
self.assertEqual(audit_trial_is_expired(audit_trial_data, self.upgrade_deadline), True) | ||
self.assertEqual(audit_trial_is_expired(mock_enrollment, audit_trial_data), True) | ||
|
||
@freeze_time('2024-01-01') | ||
@patch('learning_assistant.api.CourseMode') | ||
def test_audit_trial_unexpired(self, mock_course_mode): | ||
mock_mode = MagicMock() | ||
mock_mode.expiration_datetime.return_value = datetime.now() + timedelta(days=1) # tomorrow | ||
mock_course_mode.objects.get.return_value = mock_mode | ||
@freeze_time('2024-01-01 00:00:01 UTC') | ||
def test_audit_trial_unexpired(self): | ||
today = datetime.now(tz=timezone.utc) | ||
mock_enrollment = MagicMock() | ||
mock_enrollment.upgrade_deadline = today + timedelta(days=1) # tomorrow | ||
|
||
start_date = datetime.now() - timedelta(days=settings.LEARNING_ASSISTANT_AUDIT_TRIAL_LENGTH_DAYS - 1) | ||
start_date = today - timedelta(days=settings.LEARNING_ASSISTANT_AUDIT_TRIAL_LENGTH_DAYS - 1) | ||
audit_trial_data = LearningAssistantAuditTrialData( | ||
user_id=self.user.id, | ||
start_date=start_date, | ||
expiration_date=get_audit_trial_expiration_date(start_date), | ||
) | ||
|
||
self.assertEqual(audit_trial_is_expired(audit_trial_data, self.upgrade_deadline), False) | ||
self.assertEqual(audit_trial_is_expired(mock_enrollment, audit_trial_data), False) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is great! 🎉