diff --git a/common/djangoapps/student/tests/test_views.py b/common/djangoapps/student/tests/test_views.py index aa69ec3bec..5fbc296c23 100644 --- a/common/djangoapps/student/tests/test_views.py +++ b/common/djangoapps/student/tests/test_views.py @@ -5,7 +5,7 @@ import itertools import json import re import unittest -from datetime import timedelta, date +from datetime import timedelta, datetime import ddt from completion.test_utils import submit_completions_for_testing, CompletionWaffleTestMixin @@ -730,7 +730,7 @@ class StudentDashboardTests(SharedModuleStoreTestCase, MilestonesTestCaseMixin, Links will be removed from the course title, course image and button (View Course/Resume Course). The course card should have an access expired message. """ - CourseDurationLimitConfig.objects.create(enabled=True, enabled_as_of=date(2018, 1, 1)) + CourseDurationLimitConfig.objects.create(enabled=True, enabled_as_of=datetime(2018, 1, 1)) self.override_waffle_switch(True) course = CourseFactory.create(start=self.THREE_YEARS_AGO) diff --git a/common/lib/xmodule/xmodule/partitions/tests/test_partitions.py b/common/lib/xmodule/xmodule/partitions/tests/test_partitions.py index a1c136aee0..c1def1cbed 100644 --- a/common/lib/xmodule/xmodule/partitions/tests/test_partitions.py +++ b/common/lib/xmodule/xmodule/partitions/tests/test_partitions.py @@ -3,7 +3,7 @@ Test the partitions and partitions service """ -from datetime import date +from datetime import datetime from django.test import TestCase from mock import Mock, patch @@ -439,7 +439,7 @@ class PartitionServiceBaseClass(PartitionTestCase): ContentTypeGatingConfig.objects.create( enabled=True, - enabled_as_of=date(2018, 1, 1), + enabled_as_of=datetime(2018, 1, 1), studio_override_enabled=True ) self.course = Mock(id=CourseLocator('org_0', 'course_0', 'run_0')) diff --git a/lms/djangoapps/ccx/tests/test_field_override_performance.py b/lms/djangoapps/ccx/tests/test_field_override_performance.py index 853d1ab28c..2496d7cb84 100644 --- a/lms/djangoapps/ccx/tests/test_field_override_performance.py +++ b/lms/djangoapps/ccx/tests/test_field_override_performance.py @@ -3,7 +3,7 @@ Performance tests for field overrides. """ import itertools -from datetime import datetime, date +from datetime import datetime import ddt import mock @@ -204,7 +204,7 @@ class FieldOverridePerformanceTestCase(FieldOverrideTestMixin, ProceduralCourseT """ ContentTypeGatingConfig.objects.create( enabled=True, - enabled_as_of=date(2018, 1, 1), + enabled_as_of=datetime(2018, 1, 1), ) providers = { diff --git a/lms/djangoapps/courseware/tests/test_access.py b/lms/djangoapps/courseware/tests/test_access.py index 880c16ce02..a2ea60d051 100644 --- a/lms/djangoapps/courseware/tests/test_access.py +++ b/lms/djangoapps/courseware/tests/test_access.py @@ -828,7 +828,7 @@ class CourseOverviewAccessTestCase(ModuleStoreTestCase): @ddt.unpack @patch.dict('django.conf.settings.FEATURES', {'DISABLE_START_DATES': False}) def test_course_catalog_access_num_queries(self, user_attr_name, action, course_attr_name): - ContentTypeGatingConfig.objects.create(enabled=True, enabled_as_of=datetime.date(2018, 1, 1)) + ContentTypeGatingConfig.objects.create(enabled=True, enabled_as_of=datetime.datetime(2018, 1, 1)) course = getattr(self, course_attr_name) diff --git a/lms/djangoapps/courseware/tests/test_course_info.py b/lms/djangoapps/courseware/tests/test_course_info.py index 18bbd16a81..accedcfd24 100644 --- a/lms/djangoapps/courseware/tests/test_course_info.py +++ b/lms/djangoapps/courseware/tests/test_course_info.py @@ -2,7 +2,7 @@ """ Test the course_info xblock """ -from datetime import date +from datetime import datetime import ddt import mock from django.conf import settings @@ -418,7 +418,7 @@ class SelfPacedCourseInfoTestCase(LoginEnrollmentTestCase, SharedModuleStoreTest def setUp(self): super(SelfPacedCourseInfoTestCase, self).setUp() - ContentTypeGatingConfig.objects.create(enabled=True, enabled_as_of=date(2018, 1, 1)) + ContentTypeGatingConfig.objects.create(enabled=True, enabled_as_of=datetime(2018, 1, 1)) self.setup_user() diff --git a/lms/djangoapps/courseware/tests/test_views.py b/lms/djangoapps/courseware/tests/test_views.py index 81f7da6d9b..dde8df74e4 100644 --- a/lms/djangoapps/courseware/tests/test_views.py +++ b/lms/djangoapps/courseware/tests/test_views.py @@ -5,7 +5,7 @@ Tests courseware views.py import itertools import json import unittest -from datetime import datetime, timedelta, date +from datetime import datetime, timedelta from HTMLParser import HTMLParser from urllib import quote, urlencode from uuid import uuid4 @@ -218,7 +218,7 @@ class IndexQueryTestCase(ModuleStoreTestCase): ) @ddt.unpack def test_index_query_counts(self, store_type, expected_mongo_query_count, expected_mysql_query_count): - ContentTypeGatingConfig.objects.create(enabled=True, enabled_as_of=date(2018, 1, 1)) + ContentTypeGatingConfig.objects.create(enabled=True, enabled_as_of=datetime(2018, 1, 1)) with self.store.default_store(store_type): course = CourseFactory.create() with self.store.bulk_operations(course.id): @@ -1445,7 +1445,7 @@ class ProgressPageTests(ProgressPageBaseTests): @ddt.unpack def test_progress_queries_paced_courses(self, self_paced, query_count): """Test that query counts remain the same for self-paced and instructor-paced courses.""" - ContentTypeGatingConfig.objects.create(enabled=True, enabled_as_of=date(2018, 1, 1)) + ContentTypeGatingConfig.objects.create(enabled=True, enabled_as_of=datetime(2018, 1, 1)) self.setup_course(self_paced=self_paced) with self.assertNumQueries(query_count, table_blacklist=QUERY_COUNT_TABLE_BLACKLIST), check_mongo_calls(1): self._get_progress_page() @@ -1457,7 +1457,7 @@ class ProgressPageTests(ProgressPageBaseTests): ) @ddt.unpack def test_progress_queries(self, enable_waffle, initial, subsequent): - ContentTypeGatingConfig.objects.create(enabled=True, enabled_as_of=date(2018, 1, 1)) + ContentTypeGatingConfig.objects.create(enabled=True, enabled_as_of=datetime(2018, 1, 1)) self.setup_course() with grades_waffle().override(ASSUME_ZERO_GRADE_IF_ABSENT, active=enable_waffle): with self.assertNumQueries( @@ -1668,7 +1668,7 @@ class ProgressPageTests(ProgressPageBaseTests): Verify that expired banner message appears on progress page, if learner is enrolled in audit mode. """ - CourseDurationLimitConfig.objects.create(enabled=True, enabled_as_of=date(2018, 1, 1)) + CourseDurationLimitConfig.objects.create(enabled=True, enabled_as_of=datetime(2018, 1, 1)) user = UserFactory.create() self.assertTrue(self.client.login(username=user.username, password='test')) add_course_mode(self.course, upgrade_deadline_expired=False) @@ -2715,7 +2715,7 @@ class TestIndexViewWithCourseDurationLimits(ModuleStoreTestCase): Test that the courseware contains the course expiration banner when course_duration_limits are enabled. """ - CourseDurationLimitConfig.objects.create(enabled=True, enabled_as_of=date(2018, 1, 1)) + CourseDurationLimitConfig.objects.create(enabled=True, enabled_as_of=datetime(2018, 1, 1)) self.assertTrue(self.client.login(username=self.user.username, password='test')) add_course_mode(self.course, upgrade_deadline_expired=False) response = self.client.get( diff --git a/lms/djangoapps/discussion/tests/test_views.py b/lms/djangoapps/discussion/tests/test_views.py index 13ba910806..405054df33 100644 --- a/lms/djangoapps/discussion/tests/test_views.py +++ b/lms/djangoapps/discussion/tests/test_views.py @@ -1,6 +1,6 @@ import json import logging -from datetime import datetime, date +from datetime import datetime import ddt from django.urls import reverse @@ -456,7 +456,7 @@ class SingleThreadQueryCountTestCase(ForumsEnableMixin, ModuleStoreTestCase): num_cached_sql_queries, mock_request ): - ContentTypeGatingConfig.objects.create(enabled=True, enabled_as_of=date(2018, 1, 1)) + ContentTypeGatingConfig.objects.create(enabled=True, enabled_as_of=datetime(2018, 1, 1)) with modulestore().default_store(default_store): course = CourseFactory.create(discussion_topics={'dummy discussion': {'id': 'dummy_discussion_id'}}) diff --git a/lms/djangoapps/mobile_api/users/tests.py b/lms/djangoapps/mobile_api/users/tests.py index 588cda7ffd..69882fa809 100644 --- a/lms/djangoapps/mobile_api/users/tests.py +++ b/lms/djangoapps/mobile_api/users/tests.py @@ -299,7 +299,7 @@ class TestUserEnrollmentApi(UrlResetMixin, MobileAPITestCase, MobileAuthUserTest Test that expired courses are only returned in v1 of API when waffle flag enabled, and un-expired courses always returned ''' - CourseDurationLimitConfig.objects.create(enabled=True, enabled_as_of=datetime.date(2018, 1, 1)) + CourseDurationLimitConfig.objects.create(enabled=True, enabled_as_of=datetime.datetime(2018, 1, 1)) courses = self._get_enrollment_data(api_version, expired) self._assert_enrollment_results(api_version, courses, num_courses_returned) diff --git a/openedx/features/content_type_gating/migrations/0003_auto_20181128_1407.py b/openedx/features/content_type_gating/migrations/0003_auto_20181128_1407.py new file mode 100644 index 0000000000..9a37b67076 --- /dev/null +++ b/openedx/features/content_type_gating/migrations/0003_auto_20181128_1407.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.16 on 2018-11-28 19:07 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('content_type_gating', '0002_auto_20181119_0959'), + ] + + operations = [ + migrations.AlterField( + model_name='contenttypegatingconfig', + name='enabled_as_of', + field=models.DateTimeField(blank=True, default=None, help_text='If the configuration is Enabled, then all enrollments created after this date (UTC) will be affected.', null=True, verbose_name='Enabled As Of'), + ), + ] diff --git a/openedx/features/content_type_gating/migrations/0004_auto_20181128_1521.py b/openedx/features/content_type_gating/migrations/0004_auto_20181128_1521.py new file mode 100644 index 0000000000..d963f0a1a7 --- /dev/null +++ b/openedx/features/content_type_gating/migrations/0004_auto_20181128_1521.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.16 on 2018-11-28 20:21 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('content_type_gating', '0003_auto_20181128_1407'), + ] + + operations = [ + migrations.AlterField( + model_name='contenttypegatingconfig', + name='enabled_as_of', + field=models.DateTimeField(blank=True, default=None, help_text='If the configuration is Enabled, then all enrollments created after this date and time (UTC) will be affected.', null=True, verbose_name='Enabled As Of'), + ), + ] diff --git a/openedx/features/content_type_gating/models.py b/openedx/features/content_type_gating/models.py index 0ec29341a8..b3e4482865 100644 --- a/openedx/features/content_type_gating/models.py +++ b/openedx/features/content_type_gating/models.py @@ -5,11 +5,11 @@ Content Type Gating Configuration Models # -*- coding: utf-8 -*- from __future__ import unicode_literals -from datetime import datetime from django.core.exceptions import ValidationError from django.db import models from django.utils.encoding import python_2_unicode_compatible from django.utils.translation import ugettext_lazy as _ +from django.utils import timezone from experiments.models import ExperimentData from student.models import CourseEnrollment @@ -29,14 +29,14 @@ class ContentTypeGatingConfig(StackedConfigurationModel): STACKABLE_FIELDS = ('enabled', 'enabled_as_of', 'studio_override_enabled') - enabled_as_of = models.DateField( + enabled_as_of = models.DateTimeField( default=None, null=True, verbose_name=_('Enabled As Of'), blank=True, help_text=_( 'If the configuration is Enabled, then all enrollments ' - 'created after this date (UTC) will be affected.' + 'created after this date and time (UTC) will be affected.' ) ) studio_override_enabled = models.NullBooleanField( @@ -87,7 +87,7 @@ class ContentTypeGatingConfig(StackedConfigurationModel): # enrollment might be None if the user isn't enrolled. In that case, # return enablement as if the user enrolled today if enrollment is None: - return cls.enabled_for_course(course_key=course_key, target_date=datetime.utcnow().date()) + return cls.enabled_for_course(course_key=course_key, target_datetime=timezone.now()) else: # TODO: clean up as part of REV-100 experiment_data_holdback_key = EXPERIMENT_DATA_HOLDBACK_KEY.format(user) @@ -104,48 +104,48 @@ class ContentTypeGatingConfig(StackedConfigurationModel): if is_in_holdback: return False current_config = cls.current(course_key=enrollment.course_id) - return current_config.enabled_as_of_date(target_date=enrollment.created.date()) + return current_config.enabled_as_of_datetime(target_datetime=enrollment.created) @classmethod - def enabled_for_course(cls, course_key, target_date=None): + def enabled_for_course(cls, course_key, target_datetime=None): """ Return whether Content Type Gating is enabled for this course as of a particular date. Content Type Gating is enabled for a course on a date if it is enabled either specifically, or via a containing context, such as the org, site, or globally, and if the configuration - is specified to be ``enabled_as_of`` before ``target_date``. + is specified to be ``enabled_as_of`` before ``target_datetime``. Only one of enrollment and (user, course_key) may be specified at a time. Arguments: course_key: The CourseKey of the course being queried. - target_date: The date to checked enablement as of. Defaults to the current date. + target_datetime: The datetime to checked enablement as of. Defaults to the current date and time. """ if CONTENT_TYPE_GATING_FLAG.is_enabled(): return True - if target_date is None: - target_date = datetime.utcnow().date() + if target_datetime is None: + target_datetime = timezone.now() current_config = cls.current(course_key=course_key) - return current_config.enabled_as_of_date(target_date=target_date) + return current_config.enabled_as_of_datetime(target_datetime=target_datetime) def clean(self): if self.enabled and self.enabled_as_of is None: raise ValidationError({'enabled_as_of': _('enabled_as_of must be set when enabled is True')}) - def enabled_as_of_date(self, target_date): + def enabled_as_of_datetime(self, target_datetime): """ - Return whether this Content Type Gating configuration context is enabled as of a date. + Return whether this Content Type Gating configuration context is enabled as of a date and time. Arguments: - target_date (:class:`datetime.date`): The date that ``enabled_as_of`` must be equal to or before + target_datetime (:class:`datetime.datetime`): The datetime that ``enabled_as_of`` must be equal to or before """ if CONTENT_TYPE_GATING_FLAG.is_enabled(): return True # Explicitly cast this to bool, so that when self.enabled is None the method doesn't return None - return bool(self.enabled and self.enabled_as_of <= target_date) + return bool(self.enabled and self.enabled_as_of <= target_datetime) def __str__(self): return "ContentTypeGatingConfig(enabled={!r}, enabled_as_of={!r}, studio_override_enabled={!r})".format( diff --git a/openedx/features/content_type_gating/tests/test_access.py b/openedx/features/content_type_gating/tests/test_access.py index 1f52eabac8..e0a499237b 100644 --- a/openedx/features/content_type_gating/tests/test_access.py +++ b/openedx/features/content_type_gating/tests/test_access.py @@ -2,7 +2,7 @@ Test audit user's access to various content based on content-gating features. """ -from datetime import date +from datetime import datetime import ddt from django.conf import settings from django.test.client import RequestFactory @@ -225,7 +225,7 @@ class TestProblemTypeAccess(SharedModuleStoreTestCase): course_id=self.courses['audit_only']['course'].id, mode='audit' ) - ContentTypeGatingConfig.objects.create(enabled=True, enabled_as_of=date(2018, 1, 1)) + ContentTypeGatingConfig.objects.create(enabled=True, enabled_as_of=datetime(2018, 1, 1)) @classmethod def _create_course(cls, run, display_name, modes, component_types): @@ -478,7 +478,7 @@ class TestConditionalContentAccess(TestConditionalContent): def setUpClass(cls): super(TestConditionalContentAccess, cls).setUpClass() cls.factory = RequestFactory() - ContentTypeGatingConfig.objects.create(enabled=True, enabled_as_of=date(2018, 1, 1)) + ContentTypeGatingConfig.objects.create(enabled=True, enabled_as_of=datetime(2018, 1, 1)) def setUp(self): super(TestConditionalContentAccess, self).setUp() diff --git a/openedx/features/content_type_gating/tests/test_models.py b/openedx/features/content_type_gating/tests/test_models.py index 5d569ab0b5..dc2c2e3e98 100644 --- a/openedx/features/content_type_gating/tests/test_models.py +++ b/openedx/features/content_type_gating/tests/test_models.py @@ -1,6 +1,8 @@ -import ddt -from datetime import timedelta, date, datetime, time +from datetime import timedelta, datetime import itertools + +import ddt +from django.utils import timezone from mock import Mock from openedx.core.djangoapps.site_configuration.tests.factories import SiteConfigurationFactory @@ -38,12 +40,12 @@ class TestContentTypeGatingConfig(CacheIsolationTestCase): enrolled_before_enabled, ): - # Tweak the day to enable the config so that it is either before - # or after today (which is when the enrollment will be created) + # Tweak the datetime to enable the config so that it is either before + # or after now (which is when the enrollment will be created) if enrolled_before_enabled: - enabled_as_of = date.today() + timedelta(days=1) + enabled_as_of = datetime.now() + timedelta(days=1) else: - enabled_as_of = date.today() - timedelta(days=1) + enabled_as_of = datetime.now() - timedelta(days=1) config = ContentTypeGatingConfig.objects.create( enabled=True, @@ -102,15 +104,15 @@ class TestContentTypeGatingConfig(CacheIsolationTestCase): config = ContentTypeGatingConfig.objects.create( enabled=True, course=self.course_overview, - enabled_as_of=date(2018, 1, 1), + enabled_as_of=timezone.now(), ) - # Tweak the day to check for course enablement so it is either + # Tweak the datetime to check for course enablement so it is either # before or after when the configuration was enabled if before_enabled: - target_date = config.enabled_as_of - timedelta(days=1) + target_datetime = config.enabled_as_of - timedelta(days=1) else: - target_date = config.enabled_as_of + timedelta(days=1) + target_datetime = config.enabled_as_of + timedelta(days=1) course_key = self.course_overview.id @@ -118,7 +120,7 @@ class TestContentTypeGatingConfig(CacheIsolationTestCase): not before_enabled, ContentTypeGatingConfig.enabled_for_course( course_key=course_key, - target_date=target_date, + target_datetime=target_datetime, ) ) @@ -141,21 +143,21 @@ class TestContentTypeGatingConfig(CacheIsolationTestCase): non_test_site_cfg_enabled = SiteConfigurationFactory.create(values={'course_org_filter': non_test_course_enabled.org}) non_test_site_cfg_disabled = SiteConfigurationFactory.create(values={'course_org_filter': non_test_course_disabled.org}) - ContentTypeGatingConfig.objects.create(course=non_test_course_enabled, enabled=True, enabled_as_of=date(2018, 1, 1)) + ContentTypeGatingConfig.objects.create(course=non_test_course_enabled, enabled=True, enabled_as_of=datetime(2018, 1, 1)) ContentTypeGatingConfig.objects.create(course=non_test_course_disabled, enabled=False) - ContentTypeGatingConfig.objects.create(org=non_test_course_enabled.org, enabled=True, enabled_as_of=date(2018, 1, 1)) + ContentTypeGatingConfig.objects.create(org=non_test_course_enabled.org, enabled=True, enabled_as_of=datetime(2018, 1, 1)) ContentTypeGatingConfig.objects.create(org=non_test_course_disabled.org, enabled=False) - ContentTypeGatingConfig.objects.create(site=non_test_site_cfg_enabled.site, enabled=True, enabled_as_of=date(2018, 1, 1)) + ContentTypeGatingConfig.objects.create(site=non_test_site_cfg_enabled.site, enabled=True, enabled_as_of=datetime(2018, 1, 1)) ContentTypeGatingConfig.objects.create(site=non_test_site_cfg_disabled.site, enabled=False) # Set up test objects test_course = CourseOverviewFactory.create(org='test-org') test_site_cfg = SiteConfigurationFactory.create(values={'course_org_filter': test_course.org}) - ContentTypeGatingConfig.objects.create(enabled=global_setting, enabled_as_of=date(2018, 1, 1)) - ContentTypeGatingConfig.objects.create(course=test_course, enabled=course_setting, enabled_as_of=date(2018, 1, 1)) - ContentTypeGatingConfig.objects.create(org=test_course.org, enabled=org_setting, enabled_as_of=date(2018, 1, 1)) - ContentTypeGatingConfig.objects.create(site=test_site_cfg.site, enabled=site_setting, enabled_as_of=date(2018, 1, 1)) + ContentTypeGatingConfig.objects.create(enabled=global_setting, enabled_as_of=datetime(2018, 1, 1)) + ContentTypeGatingConfig.objects.create(course=test_course, enabled=course_setting, enabled_as_of=datetime(2018, 1, 1)) + ContentTypeGatingConfig.objects.create(org=test_course.org, enabled=org_setting, enabled_as_of=datetime(2018, 1, 1)) + ContentTypeGatingConfig.objects.create(site=test_site_cfg.site, enabled=site_setting, enabled_as_of=datetime(2018, 1, 1)) all_settings = [global_setting, site_setting, org_setting, course_setting] expected_global_setting = self._resolve_settings([global_setting]) @@ -169,7 +171,7 @@ class TestContentTypeGatingConfig(CacheIsolationTestCase): self.assertEqual(expected_course_setting, ContentTypeGatingConfig.current(course_key=test_course.id).enabled) def test_caching_global(self): - global_config = ContentTypeGatingConfig(enabled=True, enabled_as_of=date(2018, 1, 1)) + global_config = ContentTypeGatingConfig(enabled=True, enabled_as_of=datetime(2018, 1, 1)) global_config.save() # Check that the global value is not retrieved from cache after save @@ -189,7 +191,7 @@ class TestContentTypeGatingConfig(CacheIsolationTestCase): def test_caching_site(self): site_cfg = SiteConfigurationFactory() - site_config = ContentTypeGatingConfig(site=site_cfg.site, enabled=True, enabled_as_of=date(2018, 1, 1)) + site_config = ContentTypeGatingConfig(site=site_cfg.site, enabled=True, enabled_as_of=datetime(2018, 1, 1)) site_config.save() # Check that the site value is not retrieved from cache after save @@ -207,7 +209,7 @@ class TestContentTypeGatingConfig(CacheIsolationTestCase): with self.assertNumQueries(1): self.assertFalse(ContentTypeGatingConfig.current(site=site_cfg.site).enabled) - global_config = ContentTypeGatingConfig(enabled=True, enabled_as_of=date(2018, 1, 1)) + global_config = ContentTypeGatingConfig(enabled=True, enabled_as_of=datetime(2018, 1, 1)) global_config.save() # Check that the site value is not updated in cache by changing the global value @@ -217,7 +219,7 @@ class TestContentTypeGatingConfig(CacheIsolationTestCase): def test_caching_org(self): course = CourseOverviewFactory.create(org='test-org') site_cfg = SiteConfigurationFactory.create(values={'course_org_filter': course.org}) - org_config = ContentTypeGatingConfig(org=course.org, enabled=True, enabled_as_of=date(2018, 1, 1)) + org_config = ContentTypeGatingConfig(org=course.org, enabled=True, enabled_as_of=datetime(2018, 1, 1)) org_config.save() # Check that the org value is not retrieved from cache after save @@ -235,14 +237,14 @@ class TestContentTypeGatingConfig(CacheIsolationTestCase): with self.assertNumQueries(2): self.assertFalse(ContentTypeGatingConfig.current(org=course.org).enabled) - global_config = ContentTypeGatingConfig(enabled=True, enabled_as_of=date(2018, 1, 1)) + global_config = ContentTypeGatingConfig(enabled=True, enabled_as_of=datetime(2018, 1, 1)) global_config.save() # Check that the org value is not updated in cache by changing the global value with self.assertNumQueries(0): self.assertFalse(ContentTypeGatingConfig.current(org=course.org).enabled) - site_config = ContentTypeGatingConfig(site=site_cfg.site, enabled=True, enabled_as_of=date(2018, 1, 1)) + site_config = ContentTypeGatingConfig(site=site_cfg.site, enabled=True, enabled_as_of=datetime(2018, 1, 1)) site_config.save() # Check that the org value is not updated in cache by changing the site value @@ -252,7 +254,7 @@ class TestContentTypeGatingConfig(CacheIsolationTestCase): def test_caching_course(self): course = CourseOverviewFactory.create(org='test-org') site_cfg = SiteConfigurationFactory.create(values={'course_org_filter': course.org}) - course_config = ContentTypeGatingConfig(course=course, enabled=True, enabled_as_of=date(2018, 1, 1)) + course_config = ContentTypeGatingConfig(course=course, enabled=True, enabled_as_of=datetime(2018, 1, 1)) course_config.save() # Check that the org value is not retrieved from cache after save @@ -270,21 +272,21 @@ class TestContentTypeGatingConfig(CacheIsolationTestCase): with self.assertNumQueries(2): self.assertFalse(ContentTypeGatingConfig.current(course_key=course.id).enabled) - global_config = ContentTypeGatingConfig(enabled=True, enabled_as_of=date(2018, 1, 1)) + global_config = ContentTypeGatingConfig(enabled=True, enabled_as_of=datetime(2018, 1, 1)) global_config.save() # Check that the org value is not updated in cache by changing the global value with self.assertNumQueries(0): self.assertFalse(ContentTypeGatingConfig.current(course_key=course.id).enabled) - site_config = ContentTypeGatingConfig(site=site_cfg.site, enabled=True, enabled_as_of=date(2018, 1, 1)) + site_config = ContentTypeGatingConfig(site=site_cfg.site, enabled=True, enabled_as_of=datetime(2018, 1, 1)) site_config.save() # Check that the org value is not updated in cache by changing the site value with self.assertNumQueries(0): self.assertFalse(ContentTypeGatingConfig.current(course_key=course.id).enabled) - org_config = ContentTypeGatingConfig(org=course.org, enabled=True, enabled_as_of=date(2018, 1, 1)) + org_config = ContentTypeGatingConfig(org=course.org, enabled=True, enabled_as_of=datetime(2018, 1, 1)) org_config.save() # Check that the org value is not updated in cache by changing the site value diff --git a/openedx/features/content_type_gating/tests/test_partitions.py b/openedx/features/content_type_gating/tests/test_partitions.py index 8f2cf2a626..a4cbaf526b 100644 --- a/openedx/features/content_type_gating/tests/test_partitions.py +++ b/openedx/features/content_type_gating/tests/test_partitions.py @@ -1,4 +1,4 @@ -from datetime import date +from datetime import datetime from mock import Mock, patch from opaque_keys.edx.keys import CourseKey @@ -15,7 +15,7 @@ class TestContentTypeGatingPartition(CacheIsolationTestCase): def test_create_content_gating_partition_happy_path(self): mock_course = Mock(id=self.course_key, user_partitions={}) - ContentTypeGatingConfig.objects.create(enabled=True, enabled_as_of=date(2018, 1, 1)) + ContentTypeGatingConfig.objects.create(enabled=True, enabled_as_of=datetime(2018, 1, 1)) with patch('openedx.features.content_type_gating.partitions.ContentTypeGatingPartitionScheme.create_user_partition') as mock_create: partition = create_content_gating_partition(mock_course) @@ -37,7 +37,7 @@ class TestContentTypeGatingPartition(CacheIsolationTestCase): def test_create_content_gating_partition_no_scheme_installed(self): mock_course = Mock(id=self.course_key, user_partitions={}) - ContentTypeGatingConfig.objects.create(enabled=True, enabled_as_of=date(2018, 1, 1)) + ContentTypeGatingConfig.objects.create(enabled=True, enabled_as_of=datetime(2018, 1, 1)) with patch('openedx.features.content_type_gating.partitions.UserPartition.get_scheme', side_effect=UserPartitionError): partition = create_content_gating_partition(mock_course) @@ -46,7 +46,7 @@ class TestContentTypeGatingPartition(CacheIsolationTestCase): def test_create_content_gating_partition_partition_id_used(self): mock_course = Mock(id=self.course_key, user_partitions={Mock(name='partition', id=CONTENT_GATING_PARTITION_ID): object()}) - ContentTypeGatingConfig.objects.create(enabled=True, enabled_as_of=date(2018, 1, 1)) + ContentTypeGatingConfig.objects.create(enabled=True, enabled_as_of=datetime(2018, 1, 1)) with patch('openedx.features.content_type_gating.partitions.LOG') as mock_log: partition = create_content_gating_partition(mock_course) diff --git a/openedx/features/course_duration_limits/migrations/0003_auto_20181128_1407.py b/openedx/features/course_duration_limits/migrations/0003_auto_20181128_1407.py new file mode 100644 index 0000000000..644cb2ec27 --- /dev/null +++ b/openedx/features/course_duration_limits/migrations/0003_auto_20181128_1407.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.16 on 2018-11-28 19:07 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('course_duration_limits', '0002_auto_20181119_0959'), + ] + + operations = [ + migrations.AlterField( + model_name='coursedurationlimitconfig', + name='enabled_as_of', + field=models.DateTimeField(blank=True, default=None, help_text='If the configuration is Enabled, then all enrollments created after this date (UTC) will be affected.', null=True, verbose_name='Enabled As Of'), + ), + ] diff --git a/openedx/features/course_duration_limits/migrations/0004_auto_20181128_1521.py b/openedx/features/course_duration_limits/migrations/0004_auto_20181128_1521.py new file mode 100644 index 0000000000..cb75c6a189 --- /dev/null +++ b/openedx/features/course_duration_limits/migrations/0004_auto_20181128_1521.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.16 on 2018-11-28 20:21 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('course_duration_limits', '0003_auto_20181128_1407'), + ] + + operations = [ + migrations.AlterField( + model_name='coursedurationlimitconfig', + name='enabled_as_of', + field=models.DateTimeField(blank=True, default=None, help_text='If the configuration is Enabled, then all enrollments created after this date and time (UTC) will be affected.', null=True, verbose_name='Enabled As Of'), + ), + ] diff --git a/openedx/features/course_duration_limits/models.py b/openedx/features/course_duration_limits/models.py index 9936f2f652..427c9f4f48 100644 --- a/openedx/features/course_duration_limits/models.py +++ b/openedx/features/course_duration_limits/models.py @@ -5,11 +5,11 @@ Course Duration Limit Configuration Models # -*- coding: utf-8 -*- from __future__ import unicode_literals -from datetime import datetime from django.core.exceptions import ValidationError from django.db import models from django.utils.encoding import python_2_unicode_compatible from django.utils.translation import ugettext_lazy as _ +from django.utils import timezone from experiments.models import ExperimentData from student.models import CourseEnrollment @@ -29,14 +29,14 @@ class CourseDurationLimitConfig(StackedConfigurationModel): STACKABLE_FIELDS = ('enabled', 'enabled_as_of') - enabled_as_of = models.DateField( + enabled_as_of = models.DateTimeField( default=None, null=True, verbose_name=_('Enabled As Of'), blank=True, help_text=_( 'If the configuration is Enabled, then all enrollments ' - 'created after this date (UTC) will be affected.' + 'created after this date and time (UTC) will be affected.' ) ) @@ -78,7 +78,7 @@ class CourseDurationLimitConfig(StackedConfigurationModel): # enrollment might be None if the user isn't enrolled. In that case, # return enablement as if the user enrolled today if enrollment is None: - return cls.enabled_for_course(course_key=course_key, target_date=datetime.utcnow().date()) + return cls.enabled_for_course(course_key=course_key, target_datetime=timezone.now()) else: # TODO: clean up as part of REV-100 experiment_data_holdback_key = EXPERIMENT_DATA_HOLDBACK_KEY.format(user) @@ -95,48 +95,48 @@ class CourseDurationLimitConfig(StackedConfigurationModel): if is_in_holdback: return False current_config = cls.current(course_key=enrollment.course_id) - return current_config.enabled_as_of_date(target_date=enrollment.created.date()) + return current_config.enabled_as_of_datetime(target_datetime=enrollment.created) @classmethod - def enabled_for_course(cls, course_key, target_date=None): + def enabled_for_course(cls, course_key, target_datetime=None): """ Return whether Course Duration Limits are enabled for this course as of a particular date. Course Duration Limits are enabled for a course on a date if they are enabled either specifically, or via a containing context, such as the org, site, or globally, and if the configuration - is specified to be ``enabled_as_of`` before ``target_date``. + is specified to be ``enabled_as_of`` before ``target_datetime``. Only one of enrollment and (user, course_key) may be specified at a time. Arguments: course_key: The CourseKey of the course being queried. - target_date: The date to checked enablement as of. Defaults to the current date. + target_datetime: The datetime to checked enablement as of. Defaults to the current date and time. """ if CONTENT_TYPE_GATING_FLAG.is_enabled(): return True - if target_date is None: - target_date = datetime.utcnow().date() + if target_datetime is None: + target_datetime = timezone.now() current_config = cls.current(course_key=course_key) - return current_config.enabled_as_of_date(target_date=target_date) + return current_config.enabled_as_of_datetime(target_datetime=target_datetime) def clean(self): if self.enabled and self.enabled_as_of is None: raise ValidationError({'enabled_as_of': _('enabled_as_of must be set when enabled is True')}) - def enabled_as_of_date(self, target_date): + def enabled_as_of_datetime(self, target_datetime): """ - Return whether this Course Duration Limit configuration context is enabled as of a date. + Return whether this Course Duration Limit configuration context is enabled as of a date and time. Arguments: - target_date (:class:`datetime.date`): The date that ``enabled_as_of`` must be equal to or before + target_datetime (:class:`datetime.datetime`): The datetime that ``enabled_as_of`` must be equal to or before """ if CONTENT_TYPE_GATING_FLAG.is_enabled(): return True # Explicitly cast this to bool, so that when self.enabled is None the method doesn't return None - return bool(self.enabled and self.enabled_as_of <= target_date) + return bool(self.enabled and self.enabled_as_of <= target_datetime) def __str__(self): return "CourseDurationLimits(enabled={!r}, enabled_as_of={!r})".format( diff --git a/openedx/features/course_duration_limits/tests/test_models.py b/openedx/features/course_duration_limits/tests/test_models.py index 1b596b6f6f..eddd042f8f 100644 --- a/openedx/features/course_duration_limits/tests/test_models.py +++ b/openedx/features/course_duration_limits/tests/test_models.py @@ -2,12 +2,12 @@ Tests of CourseDurationLimitConfig. """ -from datetime import timedelta, date +from datetime import timedelta, datetime import itertools import ddt +from django.utils import timezone from mock import Mock - from openedx.core.djangoapps.site_configuration.tests.factories import SiteConfigurationFactory from openedx.core.djangoapps.content.course_overviews.tests.factories import CourseOverviewFactory from openedx.core.djangoapps.waffle_utils.testutils import override_waffle_flag @@ -46,12 +46,12 @@ class TestCourseDurationLimitConfig(CacheIsolationTestCase): enrolled_before_enabled, ): - # Tweak the day to enable the config so that it is either before - # or after today (which is when the enrollment will be created) + # Tweak the datetime to enable the config so that it is either before + # or after now (which is when the enrollment will be created) if enrolled_before_enabled: - enabled_as_of = date.today() + timedelta(days=1) + enabled_as_of = timezone.now() + timedelta(days=1) else: - enabled_as_of = date.today() - timedelta(days=1) + enabled_as_of = timezone.now() - timedelta(days=1) CourseDurationLimitConfig.objects.create( enabled=True, @@ -130,15 +130,15 @@ class TestCourseDurationLimitConfig(CacheIsolationTestCase): config = CourseDurationLimitConfig.objects.create( enabled=True, course=self.course_overview, - enabled_as_of=date.today(), + enabled_as_of=timezone.now(), ) - # Tweak the day to check for course enablement so it is either + # Tweak the datetime to check for course enablement so it is either # before or after when the configuration was enabled if before_enabled: - target_date = config.enabled_as_of - timedelta(days=1) + target_datetime = config.enabled_as_of - timedelta(days=1) else: - target_date = config.enabled_as_of + timedelta(days=1) + target_datetime = config.enabled_as_of + timedelta(days=1) course_key = self.course_overview.id @@ -146,7 +146,7 @@ class TestCourseDurationLimitConfig(CacheIsolationTestCase): not before_enabled, CourseDurationLimitConfig.enabled_for_course( course_key=course_key, - target_date=target_date, + target_datetime=target_datetime, ) ) @@ -200,7 +200,7 @@ class TestCourseDurationLimitConfig(CacheIsolationTestCase): self.assertEqual(expected_course_setting, CourseDurationLimitConfig.current(course_key=test_course.id).enabled) def test_caching_global(self): - global_config = CourseDurationLimitConfig(enabled=True, enabled_as_of=date(2018, 1, 1)) + global_config = CourseDurationLimitConfig(enabled=True, enabled_as_of=datetime(2018, 1, 1)) global_config.save() # Check that the global value is not retrieved from cache after save @@ -220,7 +220,7 @@ class TestCourseDurationLimitConfig(CacheIsolationTestCase): def test_caching_site(self): site_cfg = SiteConfigurationFactory() - site_config = CourseDurationLimitConfig(site=site_cfg.site, enabled=True, enabled_as_of=date(2018, 1, 1)) + site_config = CourseDurationLimitConfig(site=site_cfg.site, enabled=True, enabled_as_of=datetime(2018, 1, 1)) site_config.save() # Check that the site value is not retrieved from cache after save @@ -238,7 +238,7 @@ class TestCourseDurationLimitConfig(CacheIsolationTestCase): with self.assertNumQueries(1): self.assertFalse(CourseDurationLimitConfig.current(site=site_cfg.site).enabled) - global_config = CourseDurationLimitConfig(enabled=True, enabled_as_of=date(2018, 1, 1)) + global_config = CourseDurationLimitConfig(enabled=True, enabled_as_of=datetime(2018, 1, 1)) global_config.save() # Check that the site value is not updated in cache by changing the global value @@ -248,7 +248,7 @@ class TestCourseDurationLimitConfig(CacheIsolationTestCase): def test_caching_org(self): course = CourseOverviewFactory.create(org='test-org') site_cfg = SiteConfigurationFactory.create(values={'course_org_filter': course.org}) - org_config = CourseDurationLimitConfig(org=course.org, enabled=True, enabled_as_of=date(2018, 1, 1)) + org_config = CourseDurationLimitConfig(org=course.org, enabled=True, enabled_as_of=datetime(2018, 1, 1)) org_config.save() # Check that the org value is not retrieved from cache after save @@ -266,14 +266,14 @@ class TestCourseDurationLimitConfig(CacheIsolationTestCase): with self.assertNumQueries(2): self.assertFalse(CourseDurationLimitConfig.current(org=course.org).enabled) - global_config = CourseDurationLimitConfig(enabled=True, enabled_as_of=date(2018, 1, 1)) + global_config = CourseDurationLimitConfig(enabled=True, enabled_as_of=datetime(2018, 1, 1)) global_config.save() # Check that the org value is not updated in cache by changing the global value with self.assertNumQueries(0): self.assertFalse(CourseDurationLimitConfig.current(org=course.org).enabled) - site_config = CourseDurationLimitConfig(site=site_cfg.site, enabled=True, enabled_as_of=date(2018, 1, 1)) + site_config = CourseDurationLimitConfig(site=site_cfg.site, enabled=True, enabled_as_of=datetime(2018, 1, 1)) site_config.save() # Check that the org value is not updated in cache by changing the site value @@ -283,7 +283,7 @@ class TestCourseDurationLimitConfig(CacheIsolationTestCase): def test_caching_course(self): course = CourseOverviewFactory.create(org='test-org') site_cfg = SiteConfigurationFactory.create(values={'course_org_filter': course.org}) - course_config = CourseDurationLimitConfig(course=course, enabled=True, enabled_as_of=date(2018, 1, 1)) + course_config = CourseDurationLimitConfig(course=course, enabled=True, enabled_as_of=datetime(2018, 1, 1)) course_config.save() # Check that the org value is not retrieved from cache after save @@ -301,21 +301,21 @@ class TestCourseDurationLimitConfig(CacheIsolationTestCase): with self.assertNumQueries(2): self.assertFalse(CourseDurationLimitConfig.current(course_key=course.id).enabled) - global_config = CourseDurationLimitConfig(enabled=True, enabled_as_of=date(2018, 1, 1)) + global_config = CourseDurationLimitConfig(enabled=True, enabled_as_of=datetime(2018, 1, 1)) global_config.save() # Check that the org value is not updated in cache by changing the global value with self.assertNumQueries(0): self.assertFalse(CourseDurationLimitConfig.current(course_key=course.id).enabled) - site_config = CourseDurationLimitConfig(site=site_cfg.site, enabled=True, enabled_as_of=date(2018, 1, 1)) + site_config = CourseDurationLimitConfig(site=site_cfg.site, enabled=True, enabled_as_of=datetime(2018, 1, 1)) site_config.save() # Check that the org value is not updated in cache by changing the site value with self.assertNumQueries(0): self.assertFalse(CourseDurationLimitConfig.current(course_key=course.id).enabled) - org_config = CourseDurationLimitConfig(org=course.org, enabled=True, enabled_as_of=date(2018, 1, 1)) + org_config = CourseDurationLimitConfig(org=course.org, enabled=True, enabled_as_of=datetime(2018, 1, 1)) org_config.save() # Check that the org value is not updated in cache by changing the site value diff --git a/openedx/features/course_experience/tests/views/test_course_home.py b/openedx/features/course_experience/tests/views/test_course_home.py index 15a158ccd5..3e8a0c4198 100644 --- a/openedx/features/course_experience/tests/views/test_course_home.py +++ b/openedx/features/course_experience/tests/views/test_course_home.py @@ -2,7 +2,7 @@ """ Tests for the course home page. """ -from datetime import datetime, timedelta, date +from datetime import datetime, timedelta import ddt import mock @@ -189,7 +189,7 @@ class TestCourseHomePage(CourseHomePageTestCase): """ Verify that the view's query count doesn't regress. """ - CourseDurationLimitConfig.objects.create(enabled=True, enabled_as_of=date(2018, 1, 1)) + CourseDurationLimitConfig.objects.create(enabled=True, enabled_as_of=datetime(2018, 1, 1)) # Pre-fetch the view to populate any caches course_home_url(self.course) @@ -435,7 +435,7 @@ class TestCourseHomePageAccess(CourseHomePageTestCase): Ensure that a user accessing an expired course sees a redirect to the student dashboard, not a 404. """ - CourseDurationLimitConfig.objects.create(enabled=True, enabled_as_of=date(2010, 1, 1)) + CourseDurationLimitConfig.objects.create(enabled=True, enabled_as_of=datetime(2010, 1, 1)) course = CourseFactory.create(start=THREE_YEARS_AGO) url = course_home_url(course) @@ -468,7 +468,7 @@ class TestCourseHomePageAccess(CourseHomePageTestCase): Verify that enrolled users are NOT shown the course expiration banner and can access the course home page if course audit only """ - CourseDurationLimitConfig.objects.create(enabled=True, enabled_as_of=date(2010, 1, 1)) + CourseDurationLimitConfig.objects.create(enabled=True, enabled_as_of=datetime(2010, 1, 1)) audit_only_course = CourseFactory.create() self.create_user_for_course(audit_only_course, CourseUserType.ENROLLED) response = self.client.get(course_home_url(audit_only_course)) @@ -483,7 +483,7 @@ class TestCourseHomePageAccess(CourseHomePageTestCase): Ensure that a user accessing an expired course that is in the holdback does not get redirected to the student dashboard, not a 404. """ - CourseDurationLimitConfig.objects.create(enabled=True, enabled_as_of=date(2010, 1, 1)) + CourseDurationLimitConfig.objects.create(enabled=True, enabled_as_of=datetime(2010, 1, 1)) course = CourseFactory.create(start=THREE_YEARS_AGO) url = course_home_url(course) @@ -584,7 +584,7 @@ class TestCourseHomePageAccess(CourseHomePageTestCase): config = CourseDurationLimitConfig( course=CourseOverview.get_from_id(self.course.id), enabled=True, - enabled_as_of=date(2018, 1, 1) + enabled_as_of=datetime(2018, 1, 1) ) config.save() diff --git a/openedx/features/course_experience/tests/views/test_course_updates.py b/openedx/features/course_experience/tests/views/test_course_updates.py index 789a64fe82..90230ff743 100644 --- a/openedx/features/course_experience/tests/views/test_course_updates.py +++ b/openedx/features/course_experience/tests/views/test_course_updates.py @@ -1,7 +1,7 @@ """ Tests for the course updates page. """ -from datetime import date +from datetime import datetime from courseware.courses import get_course_info_usage_key from django.urls import reverse @@ -122,7 +122,7 @@ class TestCourseUpdatesPage(SharedModuleStoreTestCase): self.assertContains(response, 'Second Message') def test_queries(self): - ContentTypeGatingConfig.objects.create(enabled=True, enabled_as_of=date(2018, 1, 1)) + ContentTypeGatingConfig.objects.create(enabled=True, enabled_as_of=datetime(2018, 1, 1)) create_course_update(self.course, self.user, 'First Message') # Pre-fetch the view to populate any caches