Skip to content
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
29 changes: 19 additions & 10 deletions common/djangoapps/course_modes/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@

from util.date_utils import get_time_display
from xmodule.modulestore.django import modulestore
from course_modes.models import CourseMode
from course_modes.models import CourseMode, CourseModeExpirationConfig

# Technically, we shouldn't be doing this, since verify_student is defined
# in LMS, and course_modes is defined in common.
Expand Down Expand Up @@ -66,12 +66,13 @@ def __init__(self, *args, **kwargs):

default_tz = timezone(settings.TIME_ZONE)

if self.instance.expiration_datetime:
if self.instance._expiration_datetime: # pylint: disable=protected-access
# django admin is using default timezone. To avoid time conversion from db to form
# convert the UTC object to naive and then localize with default timezone.
expiration_datetime = self.instance.expiration_datetime.replace(tzinfo=None)
self.initial["expiration_datetime"] = default_tz.localize(expiration_datetime)

_expiration_datetime = self.instance._expiration_datetime.replace( # pylint: disable=protected-access
tzinfo=None
)
self.initial["_expiration_datetime"] = default_tz.localize(_expiration_datetime)
# Load the verification deadline
# Since this is stored on a model in verify student, we need to load it from there.
# We need to munge the timezone a bit to get Django admin to display it without converting
Expand Down Expand Up @@ -99,14 +100,14 @@ def clean_course_id(self):

return course_key

def clean_expiration_datetime(self):
def clean__expiration_datetime(self):
"""
Ensure that the expiration datetime we save uses the UTC timezone.
"""
# django admin saving the date with default timezone to avoid time conversion from form to db
# changes its tzinfo to UTC
if self.cleaned_data.get("expiration_datetime"):
return self.cleaned_data.get("expiration_datetime").replace(tzinfo=UTC)
if self.cleaned_data.get("_expiration_datetime"):
return self.cleaned_data.get("_expiration_datetime").replace(tzinfo=UTC)

def clean_verification_deadline(self):
"""
Expand All @@ -122,7 +123,7 @@ def clean(self):
"""
cleaned_data = super(CourseModeForm, self).clean()
mode_slug = cleaned_data.get("mode_slug")
upgrade_deadline = cleaned_data.get("expiration_datetime")
upgrade_deadline = cleaned_data.get("_expiration_datetime")
verification_deadline = cleaned_data.get("verification_deadline")

# Allow upgrade deadlines ONLY for the "verified" mode
Expand Down Expand Up @@ -181,7 +182,7 @@ class CourseModeAdmin(admin.ModelAdmin):
'mode_display_name',
'min_price',
'currency',
'expiration_datetime',
'_expiration_datetime',
'verification_deadline',
'sku'
)
Expand All @@ -206,4 +207,12 @@ def expiration_datetime_custom(self, obj):
# in the Django admin list view.
expiration_datetime_custom.short_description = "Upgrade Deadline"


class CourseModeExpirationConfigAdmin(admin.ModelAdmin):
"""Admin interface for the course mode auto expiration configuration. """

class Meta(object):
model = CourseModeExpirationConfig

admin.site.register(CourseMode, CourseModeAdmin)
admin.site.register(CourseModeExpirationConfig, CourseModeExpirationConfigAdmin)
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('course_modes', '0002_coursemode_expiration_datetime_is_explicit'),
]

operations = [
migrations.AlterField(
model_name='coursemode',
name='expiration_datetime_is_explicit',
field=models.BooleanField(default=False),
),
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from datetime import timedelta

from django.db import migrations, models
import django.db.models.deletion
from django.conf import settings


class Migration(migrations.Migration):

dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('course_modes', '0003_auto_20151113_1443'),
]

operations = [
migrations.CreateModel(
name='CourseModeExpirationConfig',
fields=[
('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
('change_date', models.DateTimeField(auto_now_add=True, verbose_name='Change date')),
('enabled', models.BooleanField(default=False, verbose_name='Enabled')),
('verification_window', models.DurationField(default=timedelta(10), help_text='The time period before a course ends in which a course mode will expire')),
('changed_by', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, editable=False, to=settings.AUTH_USER_MODEL, null=True, verbose_name='Changed by')),
],
options={
'ordering': ('-change_date',),
'abstract': False,
},
),
]
46 changes: 37 additions & 9 deletions common/djangoapps/course_modes/models.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
"""
Add and create new modes for running courses on this particular LMS
"""
from datetime import datetime, timedelta
import pytz
from datetime import datetime

from collections import namedtuple, defaultdict
from config_models.models import ConfigurationModel
from django.core.exceptions import ValidationError
from django.db import models
from collections import namedtuple, defaultdict
from django.utils.translation import ugettext_lazy as _
from django.db.models import Q

from django.utils.translation import ugettext_lazy as _
from xmodule_django.models import CourseKeyField

Mode = namedtuple('Mode',
Expand Down Expand Up @@ -54,19 +54,20 @@ class CourseMode(models.Model):
# For example, if there is a verified mode that expires on 1/1/2015,
# then users will be able to upgrade into the verified mode before that date.
# Once the date passes, users will no longer be able to enroll as verified.
expiration_datetime = models.DateTimeField(
_expiration_datetime = models.DateTimeField(
default=None, null=True, blank=True,
verbose_name=_(u"Upgrade Deadline"),
help_text=_(
u"OPTIONAL: After this date/time, users will no longer be able to enroll in this mode. "
u"Leave this blank if users can enroll in this mode until enrollment closes for the course."
),
db_column='expiration_datetime',
)

# The system prefers to set this automatically based on default settings. But
# if the field is set manually we want a way to indicate that so we don't
# overwrite the manual setting of the field.
expiration_datetime_is_explicit = models.BooleanField(default=True)
expiration_datetime_is_explicit = models.BooleanField(default=False)

# DEPRECATED: the `expiration_date` field has been replaced by `expiration_datetime`
expiration_date = models.DateField(default=None, null=True, blank=True)
Expand Down Expand Up @@ -150,6 +151,17 @@ def slug(self):
"""
return self.mode_slug

@property
def expiration_datetime(self):
""" Return _expiration_datetime. """
return self._expiration_datetime

@expiration_datetime.setter
def expiration_datetime(self, new_datetime):
""" Saves datetime to _expiration_datetime and sets the explicit flag. """
self.expiration_datetime_is_explicit = True
self._expiration_datetime = new_datetime

@classmethod
def all_modes_for_courses(cls, course_id_list):
"""Find all modes for a list of course IDs, including expired modes.
Expand Down Expand Up @@ -223,8 +235,8 @@ def paid_modes_for_course(cls, course_id):
Q(course_id=course_id) &
Q(min_price__gt=0) &
(
Q(expiration_datetime__isnull=True) |
Q(expiration_datetime__gte=now)
Q(_expiration_datetime__isnull=True) |
Q(_expiration_datetime__gte=now)
)
)
return [mode.to_tuple() for mode in found_course_modes]
Expand Down Expand Up @@ -259,7 +271,7 @@ def modes_for_course(cls, course_id, include_expired=False, only_selectable=True
# Filter out expired course modes if include_expired is not set
if not include_expired:
found_course_modes = found_course_modes.filter(
Q(expiration_datetime__isnull=True) | Q(expiration_datetime__gte=now)
Q(_expiration_datetime__isnull=True) | Q(_expiration_datetime__gte=now)
)

# Credit course modes are currently not shown on the track selection page;
Expand Down Expand Up @@ -633,3 +645,19 @@ class CourseModesArchive(models.Model):
expiration_date = models.DateField(default=None, null=True, blank=True)

expiration_datetime = models.DateTimeField(default=None, null=True, blank=True)


class CourseModeExpirationConfig(ConfigurationModel):
"""
Configuration for time period from end of course to auto-expire a course mode.
"""
verification_window = models.DurationField(
default=timedelta(days=10),
help_text=_(
"The time period before a course ends in which a course mode will expire"
)
)

def __unicode__(self):
""" Returns the unicode date of the verification window. """
return unicode(self.verification_window)
36 changes: 36 additions & 0 deletions common/djangoapps/course_modes/signals.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
"""
Signal handler for setting default course mode expiration dates
"""
from django.core.exceptions import ObjectDoesNotExist
from django.dispatch.dispatcher import receiver
from xmodule.modulestore.django import SignalHandler, modulestore

from .models import CourseMode, CourseModeExpirationConfig


@receiver(SignalHandler.course_published)
def _listen_for_course_publish(sender, course_key, **kwargs): # pylint: disable=unused-argument
"""
Catches the signal that a course has been published in Studio and
sets the verified mode dates to defaults.
"""
try:
verified_mode = CourseMode.objects.get(course_id=course_key, mode_slug=CourseMode.VERIFIED)
if _should_update_date(verified_mode):
course = modulestore().get_course(course_key)
if not course:
return None
verification_window = CourseModeExpirationConfig.current().verification_window
new_expiration_datetime = course.end - verification_window

if verified_mode.expiration_datetime != new_expiration_datetime:
# Set the expiration_datetime without triggering the explicit flag
verified_mode._expiration_datetime = new_expiration_datetime # pylint: disable=protected-access
verified_mode.save()
except ObjectDoesNotExist:
pass


def _should_update_date(verified_mode):
""" Returns whether or not the verified mode should be updated. """
return not(verified_mode is None or verified_mode.expiration_datetime_is_explicit)
4 changes: 4 additions & 0 deletions common/djangoapps/course_modes/startup.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
"""
Setup the signals on startup.
"""
import course_modes.signals # pylint: disable=unused-import
6 changes: 3 additions & 3 deletions common/djangoapps/course_modes/tests/test_admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,8 @@ def test_expiration_timezone(self):
'mode_display_name': 'verified',
'min_price': 10,
'currency': 'usd',
'expiration_datetime_0': expiration.date(), # due to django admin datetime widget passing as seperate vals
'expiration_datetime_1': expiration.time(),
'_expiration_datetime_0': expiration.date(), # due to django admin datetime widget passing as separate vals
'_expiration_datetime_1': expiration.time(),

}

Expand Down Expand Up @@ -201,7 +201,7 @@ def _admin_form(self, mode, upgrade_deadline=None):
"course_id": unicode(self.course.id),
"mode_slug": mode,
"mode_display_name": mode,
"expiration_datetime": upgrade_deadline,
"_expiration_datetime": upgrade_deadline,
"currency": "usd",
"min_price": 10,
}, instance=course_mode)
Expand Down
20 changes: 19 additions & 1 deletion common/djangoapps/course_modes/tests/test_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ def create_mode(
min_price=min_price,
suggested_prices=suggested_prices,
currency=currency,
expiration_datetime=expiration_datetime,
_expiration_datetime=expiration_datetime,
)

def test_save(self):
Expand Down Expand Up @@ -403,3 +403,21 @@ def _enrollment_display_modes_dicts(self, dict_type):
return dict(zip(dict_keys, display_values.get('verify_none')))
else:
return dict(zip(dict_keys, display_values.get(dict_type)))

def test_expiration_datetime_explicitly_set(self):
""" Verify that setting the expiration_date property sets the explicit flag. """
verified_mode, __ = self.create_mode('verified', 'Verified Certificate')
now = datetime.now()
verified_mode.expiration_datetime = now

self.assertTrue(verified_mode.expiration_datetime_is_explicit)
self.assertEqual(verified_mode.expiration_datetime, now)

def test_expiration_datetime_not_explicitly_set(self):
""" Verify that setting the _expiration_date property does not set the explicit flag. """
verified_mode, __ = self.create_mode('verified', 'Verified Certificate')
now = datetime.now()
verified_mode._expiration_datetime = now # pylint: disable=protected-access

self.assertFalse(verified_mode.expiration_datetime_is_explicit)
self.assertEqual(verified_mode.expiration_datetime, now)
Loading