From 87b38c40b1424998e6d3e86e81cd589e7567a02c Mon Sep 17 00:00:00 2001 From: Boris Zimka Date: Tue, 5 Sep 2017 17:09:59 +0000 Subject: [PATCH 01/35] Course Shift Models --- models.py | 265 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 265 insertions(+) create mode 100644 models.py diff --git a/models.py b/models.py new file mode 100644 index 0000000..c76a656 --- /dev/null +++ b/models.py @@ -0,0 +1,265 @@ +from datetime import timedelta +from logging import getLogger + +from django.contrib.auth.models import User +from django.db import models, IntegrityError +from django.utils import timezone +from opaque_keys.edx.keys import CourseKey +from xmodule.modulestore.django import modulestore + +from openedx.core.djangoapps.course_groups.models import CourseUserGroup, CourseKeyField + +log = getLogger(__name__) + + +class CourseShiftGroup(models.Model): + """ + Represents group of users with shifted due dates. + It is based on CourseUserGroup. To ensure that + every user is enrolled to the one shift only + CourseShiftMembership is used (just like for Cohorts). + + Don't use this model's methods directly, they should be used by + other models only. + """ + course_user_group = models.OneToOneField(CourseUserGroup) + start_date = models.DateField( + default=timezone.now, + db_index=True, + help_text="Date when this shift started" + ) + + days_shift = models.IntegerField( + default=0, + help_text="Days to add to the block's due" + ) + + @property + def course_key(self): + return self.course_user_group.course_id + + @property + def users(self): + return self.course_user_group.users + + @property + def name(self): + return self.course_user_group.name + + @classmethod + def get_course_shifts(cls, course_key): + if not isinstance(course_key, CourseKey): + raise TypeError("course_key must be CourseKey, not {}".format(type(course_key))) + return cls.objects.filter(course_user_group__course_id=course_key).order_by('-start_date') + + @classmethod + def create(cls, name, course_key, start_date=None, days_shift=None): + """Creates new CourseShiftGroup""" + course_user_group, created = CourseUserGroup.create(name=name, course_id=course_key) + kwargs = {"course_user_group": course_user_group} + if start_date: + kwargs["start_date"] = start_date + if days_shift: + kwargs["days_shift"] = days_shift + course_shift_group, created_shift = CourseShiftGroup.objects.get_or_create(**kwargs) + return course_shift_group, created and created_shift + + def __unicode__(self): + return u"'{}' in '{}'".format(self.name, str(self.course_key)) + + +class CourseShiftGroupMembership(models.Model): + """ + Represents membership in CourseShiftGroup. At any changes it + updates CourseUserGroup. + """ + user = models.OneToOneField(User) + course_shift_group = models.ForeignKey(CourseShiftGroup) + + class ChangeForbidden(Exception): + """We want to forbid update but allow deletion on rows""" + pass + + def save(self, *args, **kwargs): + if self.pk: + raise self.ChangeForbidden("CourseShiftGroupMembership can't be changed, only deleted") + save = super(CourseShiftGroupMembership, self).save(*args, **kwargs) + if self.user not in self.course_shift_group.users.all(): + self._add_user_with_membership(self.course_shift_group, self.user) + return save + + def delete(self, *args, **kwargs): + delete = super(CourseShiftGroupMembership, self).delete(*args, **kwargs) + self._delete_user_without_membership(self.user, self.course_shift_group) + return delete + + @classmethod + def transfer_user(cls, user, course_shift_group_from, course_shift_group_to): + """ + Transfers user from one shift to another one. If the first one is None, + user is enrolled to the 'course_shift_group_to'. If the last one + is None, user is unenrolled from shift 'course_shift_group_from' + """ + + if course_shift_group_from and user not in course_shift_group_from.users.all(): + raise ValueError("User {} is not in {}".format(user.username, course_shift_group_from.name)) + if course_shift_group_from: + membership = cls.objects.get(user=user) + if course_shift_group_from == course_shift_group_to: + return membership + membership.delete() + if course_shift_group_to: + return cls.objects.create(user=user, course_shift_group=course_shift_group_to) + + @classmethod + def _add_user_with_membership(cls, course_shift_group, user): + """Adds users only when they are already have shift membership""" + try: + membership = CourseShiftGroupMembership.objects.get(user=user) + except CourseShiftGroupMembership.DoesNotExist: + raise IntegrityError("Membership for user {} not found".format(user.username)) + if membership.course_shift_group != course_shift_group: + raise IntegrityError("Found membership for user {}, supposed to be {}".format( + membership.course_shift_group.name, + course_shift_group.name + )) + if not user in course_shift_group.users.all(): + course_shift_group.course_user_group.users.add(user) + + @classmethod + def _delete_user_without_membership(cls, user, course_shift_group): + """Deletes user from course_shift_group if he doesn't have membership.""" + try: + membership = CourseShiftGroupMembership.objects.get(user=user) + except CourseShiftGroupMembership.DoesNotExist: + membership = None + if membership: + raise IntegrityError("Found membership for user {}, supposed to be None".format( + user.username, + membership.name + )) + course_shift_group.course_user_group.users.remove(user) + + def __unicode__(self): + return u"'{}' in '{}'".format( + self.user.username, + self.course_shift_group.name + ) + + +class CourseShiftSettings(models.Model): + """ + Describes how should course shifts be run for + course session. + """ + course_key = CourseKeyField( + max_length=255, + db_index=True, + ) + + is_shift_enabled = models.BooleanField( + default=False, + help_text="Is feature enabled for course" + ) + + is_autostart = models.BooleanField( + default=True, + help_text="Are groups generated automatically with period " + "or according to the manually set plan") + + autostart_period_days = models.IntegerField( + default=28, + db_column='autostart_period_days', + help_text="Period of generated groups", + null=True + ) + + enroll_before_days = models.IntegerField( + default=14, + help_text="Days before start when student can enroll already" + ) + + enroll_after_days = models.IntegerField( + default=7, + help_text="Days after start when student still can enroll" + ) + + def __init__(self, *args, **kwargs): + super(CourseShiftSettings, self).__init__(*args, **kwargs) + # This attribute is used to clear all course shift plans when + # feature is turned off in course + self._original_is_shift_enabled = self.is_shift_enabled + + @property + def last_start_date(self): + shifts = CourseShiftGroup.get_course_shifts(self.course_key) + if not shifts: + return self.course_start_date + return shifts[0].start_date + + @property + def course_start_date(self): + course = modulestore().get_course(self.course_key) + return course.start + + @classmethod + def update_course_shift_groups(cls, course_key): + "Generate course shift group if necessary according to the settings" + current_settings, created = cls.objects.get_or_create(course_key=course_key) + if not current_settings.is_shift_enabled: + return + + plan = None + if current_settings.is_autostart: + last_date = current_settings.last_start_date + next_start_date = last_date + timedelta(days=current_settings._autostart_period_days) + else: + course_shifts_plans = current_settings.plans.all().order_by('start_date') + if not course_shifts_plans: + return + plan = course_shifts_plans[0] + next_start_date = plan.start_date + + if next_start_date < timezone.now(): + days_add = int((next_start_date - current_settings.course_start_date).days) + name = cls.naming(course_key, next_start_date) + group, create = CourseShiftGroup.create( + course_key=course_key, + name=name, + days_shift=days_add, + start_date=next_start_date + ) + if plan: + plan.delete() + return group + + @classmethod + def naming(cls, course_key, date): + return "shift_{}_{}".format(str(course_key), str(date)) + + def save(self, *args, **kwargs): + if (not self.is_shift_enabled) and self._original_is_shift_enabled: + CourseShiftPlannedRun.clear_course_shift_plans(self.course_key) + return super(CourseShiftSettings, self).save(*args, **kwargs) + + @classmethod + def get_course_settings(cls, course_key): + current_settings, created = cls.objects.get_or_create(course_key=course_key) + return current_settings + + +class CourseShiftPlannedRun(models.Model): + """ + Represents planned shift for course. Used + only when course shift dates are set up manually(not 'is_autostart') + """ + course_shift_settings = models.ForeignKey( + CourseShiftSettings, + related_name="plans") + start_date = models.DateField(default=timezone.now) + + @classmethod + def clear_course_shift_plans(cls, course_key): + plans = cls.objects.filter(course_shift_settings__course_key=course_key) + for x in plans: + x.delete() From c21a02c908cc6a968aec61527554bb06c24885e2 Mon Sep 17 00:00:00 2001 From: Boris Zimka Date: Thu, 7 Sep 2017 17:53:21 +0000 Subject: [PATCH 02/35] Models improvements --- models.py | 91 +++++++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 75 insertions(+), 16 deletions(-) diff --git a/models.py b/models.py index c76a656..4c98a52 100644 --- a/models.py +++ b/models.py @@ -1,6 +1,10 @@ +""" +This file contains the logic for course shifts. +""" from datetime import timedelta from logging import getLogger +from django.core.exceptions import ValidationError from django.contrib.auth.models import User from django.db import models, IntegrityError from django.utils import timezone @@ -12,6 +16,10 @@ log = getLogger(__name__) +def datenow(): + return timezone.now().date() + + class CourseShiftGroup(models.Model): """ Represents group of users with shifted due dates. @@ -20,11 +28,12 @@ class CourseShiftGroup(models.Model): CourseShiftMembership is used (just like for Cohorts). Don't use this model's methods directly, they should be used by - other models only. + other models only. Direct usage can lead to the inconsistent + state of shifts. """ course_user_group = models.OneToOneField(CourseUserGroup) start_date = models.DateField( - default=timezone.now, + default=datenow, db_index=True, help_text="Date when this shift started" ) @@ -34,6 +43,19 @@ class CourseShiftGroup(models.Model): help_text="Days to add to the block's due" ) + def validate_unique(self, *args, **kwargs): + val = super(CourseShiftGroup, self).validate_unique(*args, **kwargs) + if not self.pk: + current_shifts = CourseShiftGroup.get_course_shifts(self.course_key) + already_have_such_date = any([x.start_date == self.start_date for x in current_shifts]) + if already_have_such_date: + raise ValidationError( + "Shift for course {} with date {} already exists".format( + str(self.course_key), str(self.start_date) + ) + ) + return val + @property def course_key(self): return self.course_user_group.course_id @@ -67,22 +89,32 @@ def create(cls, name, course_key, start_date=None, days_shift=None): def __unicode__(self): return u"'{}' in '{}'".format(self.name, str(self.course_key)) + def delete(self, *args, **kwargs): + course_group = self.course_user_group + delete = super(CourseShiftGroup, self).delete(*args, **kwargs) + course_group.delete() + return delete + + def save(self, *args, **kwargs): + self.validate_unique() + return super(CourseShiftGroup, self).save(*args, **kwargs) + class CourseShiftGroupMembership(models.Model): """ Represents membership in CourseShiftGroup. At any changes it updates CourseUserGroup. """ - user = models.OneToOneField(User) + user = models.ForeignKey(User, related_name="shift_membership") course_shift_group = models.ForeignKey(CourseShiftGroup) - class ChangeForbidden(Exception): - """We want to forbid update but allow deletion on rows""" - pass + @property + def course_key(self): + return self.course_shift_group.course_key def save(self, *args, **kwargs): if self.pk: - raise self.ChangeForbidden("CourseShiftGroupMembership can't be changed, only deleted") + raise ValueError("CourseShiftGroupMembership can't be changed, only deleted") save = super(CourseShiftGroupMembership, self).save(*args, **kwargs) if self.user not in self.course_shift_group.users.all(): self._add_user_with_membership(self.course_shift_group, self.user) @@ -93,6 +125,12 @@ def delete(self, *args, **kwargs): self._delete_user_without_membership(self.user, self.course_shift_group) return delete + @classmethod + def get_user_membership(cls, user, course_key): + all_memberships = cls.objects.filter(user=user) + course_membership = all_memberships.filter(course_shift_group__course_user_group__course_id=course_key) + return course_membership.first() + @classmethod def transfer_user(cls, user, course_shift_group_from, course_shift_group_to): """ @@ -101,13 +139,33 @@ def transfer_user(cls, user, course_shift_group_from, course_shift_group_to): is None, user is unenrolled from shift 'course_shift_group_from' """ - if course_shift_group_from and user not in course_shift_group_from.users.all(): - raise ValueError("User {} is not in {}".format(user.username, course_shift_group_from.name)) - if course_shift_group_from: - membership = cls.objects.get(user=user) - if course_shift_group_from == course_shift_group_to: - return membership + if not course_shift_group_to and not course_shift_group_from: + return + + if course_shift_group_from == course_shift_group_to: + return + + key = lambda x: str(x.course_key) if hasattr(x, "course_key") else None + + if course_shift_group_from and course_shift_group_to: + if key(course_shift_group_from) != key(course_shift_group_to): + raise ValueError("Course groups have different course_key's: {} and {}".format( + key(course_shift_group_from), key(course_shift_group_to) + ) + ) + + current_course_key = key(course_shift_group_from) or key(course_shift_group_to) + membership = cls.get_user_membership(user, current_course_key) + if membership != course_shift_group_from: + raise ValueError("User's membership is '{}', not '{}'".format( + str(membership), + str(course_shift_group_from) + ) + ) + + if membership: membership.delete() + if course_shift_group_to: return cls.objects.create(user=user, course_shift_group=course_shift_group_to) @@ -123,7 +181,7 @@ def _add_user_with_membership(cls, course_shift_group, user): membership.course_shift_group.name, course_shift_group.name )) - if not user in course_shift_group.users.all(): + if user not in course_shift_group.users.all(): course_shift_group.course_user_group.users.add(user) @classmethod @@ -155,6 +213,7 @@ class CourseShiftSettings(models.Model): course_key = CourseKeyField( max_length=255, db_index=True, + unique=True, ) is_shift_enabled = models.BooleanField( @@ -222,7 +281,7 @@ def update_course_shift_groups(cls, course_key): if next_start_date < timezone.now(): days_add = int((next_start_date - current_settings.course_start_date).days) - name = cls.naming(course_key, next_start_date) + name = cls._naming(course_key, next_start_date) group, create = CourseShiftGroup.create( course_key=course_key, name=name, @@ -234,7 +293,7 @@ def update_course_shift_groups(cls, course_key): return group @classmethod - def naming(cls, course_key, date): + def _naming(cls, course_key, date): return "shift_{}_{}".format(str(course_key), str(date)) def save(self, *args, **kwargs): From 6852a6f6543503bbaa5d9e6d04bba216735dca4b Mon Sep 17 00:00:00 2001 From: Boris Zimka Date: Mon, 11 Sep 2017 11:13:20 +0000 Subject: [PATCH 03/35] Basic tests passed --- models.py | 169 ++++++++++++++++++++++---------------- tests/__init__.py | 0 tests/test_shifts.py | 189 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 288 insertions(+), 70 deletions(-) create mode 100644 tests/__init__.py create mode 100644 tests/test_shifts.py diff --git a/models.py b/models.py index 4c98a52..fe8b3fe 100644 --- a/models.py +++ b/models.py @@ -16,7 +16,7 @@ log = getLogger(__name__) -def datenow(): +def date_now(): return timezone.now().date() @@ -25,7 +25,7 @@ class CourseShiftGroup(models.Model): Represents group of users with shifted due dates. It is based on CourseUserGroup. To ensure that every user is enrolled to the one shift only - CourseShiftMembership is used (just like for Cohorts). + CourseShiftMembership model is used (just like for CourseCohorts). Don't use this model's methods directly, they should be used by other models only. Direct usage can lead to the inconsistent @@ -33,7 +33,7 @@ class CourseShiftGroup(models.Model): """ course_user_group = models.OneToOneField(CourseUserGroup) start_date = models.DateField( - default=datenow, + default=date_now, db_index=True, help_text="Date when this shift started" ) @@ -43,19 +43,6 @@ class CourseShiftGroup(models.Model): help_text="Days to add to the block's due" ) - def validate_unique(self, *args, **kwargs): - val = super(CourseShiftGroup, self).validate_unique(*args, **kwargs) - if not self.pk: - current_shifts = CourseShiftGroup.get_course_shifts(self.course_key) - already_have_such_date = any([x.start_date == self.start_date for x in current_shifts]) - if already_have_such_date: - raise ValidationError( - "Shift for course {} with date {} already exists".format( - str(self.course_key), str(self.start_date) - ) - ) - return val - @property def course_key(self): return self.course_user_group.course_id @@ -70,13 +57,19 @@ def name(self): @classmethod def get_course_shifts(cls, course_key): + """ + Returns all shifts groups for given course + """ if not isinstance(course_key, CourseKey): raise TypeError("course_key must be CourseKey, not {}".format(type(course_key))) return cls.objects.filter(course_user_group__course_id=course_key).order_by('-start_date') @classmethod def create(cls, name, course_key, start_date=None, days_shift=None): - """Creates new CourseShiftGroup""" + """ + Creates new CourseShiftGroup. + If shift with (name, course_key) combination already exists returns this shift + """ course_user_group, created = CourseUserGroup.create(name=name, course_id=course_key) kwargs = {"course_user_group": course_user_group} if start_date: @@ -86,13 +79,31 @@ def create(cls, name, course_key, start_date=None, days_shift=None): course_shift_group, created_shift = CourseShiftGroup.objects.get_or_create(**kwargs) return course_shift_group, created and created_shift + def validate_unique(self, *args, **kwargs): + """ + Checks that course_key and date combination is unique. + Can't be set is constraint because course_key is taken + from ForeignKey + """ + val = super(CourseShiftGroup, self).validate_unique(*args, **kwargs) + if not self.pk: + current_shifts = CourseShiftGroup.get_course_shifts(self.course_key) + already_have_such_date = any([x.start_date == self.start_date for x in current_shifts]) + if already_have_such_date: + raise ValidationError( + "Shift for course {} with date {} already exists".format( + str(self.course_key), str(self.start_date) + ) + ) + return val + def __unicode__(self): return u"'{}' in '{}'".format(self.name, str(self.course_key)) def delete(self, *args, **kwargs): - course_group = self.course_user_group + group = self.course_user_group delete = super(CourseShiftGroup, self).delete(*args, **kwargs) - course_group.delete() + group.delete() return delete def save(self, *args, **kwargs): @@ -112,24 +123,16 @@ class CourseShiftGroupMembership(models.Model): def course_key(self): return self.course_shift_group.course_key - def save(self, *args, **kwargs): - if self.pk: - raise ValueError("CourseShiftGroupMembership can't be changed, only deleted") - save = super(CourseShiftGroupMembership, self).save(*args, **kwargs) - if self.user not in self.course_shift_group.users.all(): - self._add_user_with_membership(self.course_shift_group, self.user) - return save - - def delete(self, *args, **kwargs): - delete = super(CourseShiftGroupMembership, self).delete(*args, **kwargs) - self._delete_user_without_membership(self.user, self.course_shift_group) - return delete - @classmethod def get_user_membership(cls, user, course_key): + """ + Returns CourseUserGroup for user and course if membership exists, else None + """ all_memberships = cls.objects.filter(user=user) course_membership = all_memberships.filter(course_shift_group__course_user_group__course_id=course_key) - return course_membership.first() + membership = course_membership.first() + if membership: + return membership @classmethod def transfer_user(cls, user, course_shift_group_from, course_shift_group_to): @@ -145,24 +148,27 @@ def transfer_user(cls, user, course_shift_group_from, course_shift_group_to): if course_shift_group_from == course_shift_group_to: return - key = lambda x: str(x.course_key) if hasattr(x, "course_key") else None + key = lambda x: x.course_key if hasattr(x, "course_key") else None + key_from = key(course_shift_group_from) + key_to = key(course_shift_group_to) if course_shift_group_from and course_shift_group_to: - if key(course_shift_group_from) != key(course_shift_group_to): + if str(key_from) != str(key_to): raise ValueError("Course groups have different course_key's: {} and {}".format( - key(course_shift_group_from), key(course_shift_group_to) + str(key_from), str(key_to) ) ) - current_course_key = key(course_shift_group_from) or key(course_shift_group_to) + current_course_key = key_from or key_to membership = cls.get_user_membership(user, current_course_key) - if membership != course_shift_group_from: + membership_group = membership and membership.course_shift_group + + if membership_group != course_shift_group_from: raise ValueError("User's membership is '{}', not '{}'".format( - str(membership), + str(membership_group), str(course_shift_group_from) ) ) - if membership: membership.delete() @@ -170,34 +176,52 @@ def transfer_user(cls, user, course_shift_group_from, course_shift_group_to): return cls.objects.create(user=user, course_shift_group=course_shift_group_to) @classmethod - def _add_user_with_membership(cls, course_shift_group, user): - """Adds users only when they are already have shift membership""" - try: - membership = CourseShiftGroupMembership.objects.get(user=user) - except CourseShiftGroupMembership.DoesNotExist: - raise IntegrityError("Membership for user {} not found".format(user.username)) - if membership.course_shift_group != course_shift_group: - raise IntegrityError("Found membership for user {}, supposed to be {}".format( - membership.course_shift_group.name, + def _push_add_to_group(cls, course_shift_group, user): + """ + Adds user to CourseShiftGroup if he has membership for this group or None. + """ + membership = CourseShiftGroupMembership.get_user_membership(user=user, course_key=course_shift_group.course_key) + membership_group = membership and membership.course_shift_group + + if membership_group and membership_group != course_shift_group: + raise IntegrityError("Found membership for user {}, supposed to be {} or None".format( + membership_group, course_shift_group.name )) + if user not in course_shift_group.users.all(): course_shift_group.course_user_group.users.add(user) @classmethod - def _delete_user_without_membership(cls, user, course_shift_group): - """Deletes user from course_shift_group if he doesn't have membership.""" - try: - membership = CourseShiftGroupMembership.objects.get(user=user) - except CourseShiftGroupMembership.DoesNotExist: - membership = None - if membership: + def _push_delete_from_group(cls, user, course_shift_group): + """ + Deletes user from course_shift_group if he doesn't have membership. + """ + membership = CourseShiftGroupMembership.get_user_membership(user=user, course_key=course_shift_group.course_key) + membership_group = membership and membership.course_shift_group + + if membership_group: raise IntegrityError("Found membership for user {}, supposed to be None".format( user.username, - membership.name + membership_group.name )) + if user not in course_shift_group.course_user_group.users.all(): + raise IntegrityError("User {} is not in {}".format(user.username, course_shift_group.name)) course_shift_group.course_user_group.users.remove(user) + def save(self, *args, **kwargs): + if self.pk: + raise ValueError("CourseShiftGroupMembership can't be changed, only deleted") + save = super(CourseShiftGroupMembership, self).save(*args, **kwargs) + if self.user not in self.course_shift_group.users.all(): + self._push_add_to_group(self.course_shift_group, self.user) + return save + + def delete(self, *args, **kwargs): + delete = super(CourseShiftGroupMembership, self).delete(*args, **kwargs) + self._push_delete_from_group(self.user, self.course_shift_group) + return delete + def __unicode__(self): return u"'{}' in '{}'".format( self.user.username, @@ -243,11 +267,6 @@ class CourseShiftSettings(models.Model): help_text="Days after start when student still can enroll" ) - def __init__(self, *args, **kwargs): - super(CourseShiftSettings, self).__init__(*args, **kwargs) - # This attribute is used to clear all course shift plans when - # feature is turned off in course - self._original_is_shift_enabled = self.is_shift_enabled @property def last_start_date(self): @@ -261,18 +280,28 @@ def course_start_date(self): course = modulestore().get_course(self.course_key) return course.start + @classmethod + def get_course_settings(cls, course_key): + current_settings, created = cls.objects.get_or_create(course_key=course_key) + return current_settings + @classmethod def update_course_shift_groups(cls, course_key): - "Generate course shift group if necessary according to the settings" + """ + Generates course shift group if necessary according to the settings. + """ current_settings, created = cls.objects.get_or_create(course_key=course_key) if not current_settings.is_shift_enabled: return plan = None + # calculates when should be next shift started if 'is_autostart', + # else get next plan from CourseShiftPlannedRun if current_settings.is_autostart: last_date = current_settings.last_start_date next_start_date = last_date + timedelta(days=current_settings._autostart_period_days) else: + course_shifts_plans = current_settings.plans.all().order_by('start_date') if not course_shifts_plans: return @@ -282,7 +311,7 @@ def update_course_shift_groups(cls, course_key): if next_start_date < timezone.now(): days_add = int((next_start_date - current_settings.course_start_date).days) name = cls._naming(course_key, next_start_date) - group, create = CourseShiftGroup.create( + CourseShiftGroup.create( course_key=course_key, name=name, days_shift=days_add, @@ -290,7 +319,12 @@ def update_course_shift_groups(cls, course_key): ) if plan: plan.delete() - return group + + def __init__(self, *args, **kwargs): + super(CourseShiftSettings, self).__init__(*args, **kwargs) + # This attribute is used to clear all course shift plans when + # feature is turned off in course + self._original_is_shift_enabled = self.is_shift_enabled @classmethod def _naming(cls, course_key, date): @@ -301,11 +335,6 @@ def save(self, *args, **kwargs): CourseShiftPlannedRun.clear_course_shift_plans(self.course_key) return super(CourseShiftSettings, self).save(*args, **kwargs) - @classmethod - def get_course_settings(cls, course_key): - current_settings, created = cls.objects.get_or_create(course_key=course_key) - return current_settings - class CourseShiftPlannedRun(models.Model): """ diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_shifts.py b/tests/test_shifts.py new file mode 100644 index 0000000..cdc5601 --- /dev/null +++ b/tests/test_shifts.py @@ -0,0 +1,189 @@ +""" +Tests for course shifts +""" +# pylint: disable=no-member +from nose.plugins.attrib import attr +import datetime + +from opaque_keys.edx.locations import SlashSeparatedCourseKey +from student.tests.factories import UserFactory, RegistrationFactory, UserProfileFactory +from xmodule.modulestore.tests.django_utils import TEST_DATA_MIXED_MODULESTORE, ModuleStoreTestCase +from xmodule.modulestore.tests.factories import ToyCourseFactory +from django.core.exceptions import ValidationError +from ..models import CourseShiftGroup, CourseShiftGroupMembership, CourseUserGroup + + +def date_shifted(shift): + return (datetime.datetime.now() + datetime.timedelta(days=shift)).date() + + +@attr(shard=2) +class TestCourseShifts(ModuleStoreTestCase): + """ + Test the course shifts feature + """ + MODULESTORE = TEST_DATA_MIXED_MODULESTORE + + def setUp(self): + """ + Make sure that course is reloaded every time--clear out the modulestore. + """ + super(TestCourseShifts, self).setUp() + date = datetime.datetime.now() + self.course = ToyCourseFactory.create(start=date) + self.course_key = self.course.id + + def date_shifted(self, shift): + return (datetime.datetime.now() + datetime.timedelta(days=shift)).date() + + def test_shift_group_creation(self): + """ + Tests shifts groups creation and .get_course_shifts method. + Valid scenarios. + """ + groups = CourseShiftGroup.get_course_shifts(self.course_key) + self.assertTrue( + len(groups) == 0, + "Course has shift groups at creation" + ) + + test_shift_group, created = CourseShiftGroup.create("test_shift_group", self.course_key) + groups = CourseShiftGroup.get_course_shifts(self.course_key) + self.assertTrue( + len(groups) == 1, + "Course has {} shifts, must have 1".format(len(groups)) + ) + self.assertTrue( + test_shift_group in groups, + "Created group is not in course shifts:'{}' not in '{}'".format( + str(test_shift_group),(str(groups)) + ) + ) + + test_shift_group.delete() + groups = CourseShiftGroup.get_course_shifts(self.course_key) + self.assertTrue( + len(groups) == 0, + "Course has shift groups after group deletion" + ) + + def test_shift_group_deletion(self): + """ + Tests shifts groups deletion and .get_course_shifts method. + Valid scenarios. + """ + + # create shift, check user + test_shift_group, created = CourseShiftGroup.create("test_shift_group", self.course_key) + course_user_groups = CourseUserGroup.objects.all() + self.assertTrue( + len(course_user_groups) == 1, + "Group was not created: {}".format(str(course_user_groups)) + ) + + # delete user, check shift + test_shift_group.course_user_group.delete() + course_shift_groups = CourseShiftGroup.get_course_shifts(self.course_key) + self.assertTrue( + len(course_shift_groups) == 0, + "More than zero course shift groups after deletion: {}".format(str(course_shift_groups)) + ) + + # create shift, delete shift, check user + test_shift_group, created = CourseShiftGroup.create("test_shift_group", self.course_key) + test_shift_group.delete() + course_user_groups = CourseUserGroup.objects.all() + self.assertTrue( + len(course_user_groups) == 0, + "Group was not deleted: {}".format(str(course_user_groups)) + ) + + def test_shift_creation_errors(self): + """ + Tests behavior of CourseShiftGroup.create in case of + incorrect course_key, name conflict + """ + test_shift_group, created = CourseShiftGroup.create("test_shift_group", self.course_key) + + with self.assertRaises(ValidationError) as context_manager: + test_shift_group2, created = CourseShiftGroup.create("test_shift_group2", self.course_key) + exception_msg_parts = ("Shift for course", "with date", "already exists") + self.assertTrue(all(x in str(context_manager.exception) for x in exception_msg_parts)) + + # when try to create group shift with same (name, key, date) already exists we get that old shift + test_shift_group_same, created = CourseShiftGroup.create("test_shift_group", self.course_key) + self.assertFalse(created) + self.assertTrue(test_shift_group.pk == test_shift_group_same.pk) + test_shift_group.delete() + + def test_membership_creation(self): + """ + Tests shifts membership creation and deletion. + Valid scenarios only. + """ + test_shift_group, created = CourseShiftGroup.create("test_shift_group", self.course_key) + user = UserFactory(username="test", email="a@b.com") + + CourseShiftGroupMembership.transfer_user(user, None, test_shift_group) + self.assertTrue(user in test_shift_group.users.all()) + + CourseShiftGroupMembership.transfer_user(user, test_shift_group, None) + self.assertTrue(len(test_shift_group.users.all()) == 0) + + date = datetime.datetime.now() + datetime.timedelta(days=7) + test_shift_group2, created = CourseShiftGroup.create("test_shift_group2", self.course_key, start_date=date) + CourseShiftGroupMembership.transfer_user(user, None, test_shift_group2) + CourseShiftGroupMembership.transfer_user(user, test_shift_group2, test_shift_group) + + self.assertTrue( + (user in test_shift_group.users.all()), + "User wasn't transfered:{}".format(str(CourseShiftGroupMembership.objects.all())) + ) + self.assertTrue( + (len(test_shift_group2.users.all())==0), + "test_shift_group2 is not empty:{}".format(str(test_shift_group2.users.all())) + ) + test_shift_group.delete() + test_shift_group2.delete() + + def test_membership_errors(self): + """ + Tests transfer_user method versus wrong shift groups + """ + test_shift_group, created = CourseShiftGroup.create("test_shift_group", self.course_key) + test_shift_group2, created = CourseShiftGroup.create("test_shift_group2", self.course_key, + start_date=date_shifted(shift=10)) + + user = UserFactory(username="test", email="a@b.com") + # user doesn't have shift, but transfer from test_shift_group + with self.assertRaises(ValueError) as context_manager: + CourseShiftGroupMembership.transfer_user(user, test_shift_group, test_shift_group2) + message_right = list(x in str(context_manager.exception) for x in ["User's membership is", "test_shift_group", "not"]) + self.assertTrue(all(message_right), "Message:{}".format(str(context_manager.exception), message_right)) + + # user doesn't have shift, but transfer from None + with self.assertRaises(ValueError) as context_manager: + CourseShiftGroupMembership.transfer_user(user, test_shift_group, None) + message_right = list(x in str(context_manager.exception) for x in ["User's membership is", "None", "not"]) + self.assertTrue(all(message_right), "Message:{}".format(str(context_manager.exception), message_right)) + + CourseShiftGroupMembership.transfer_user(user, None, test_shift_group) + + # user has shift test_shift_group, but transfer from test_shift_group2 + with self.assertRaises(ValueError) as context_manager: + CourseShiftGroupMembership.transfer_user(user, test_shift_group2, test_shift_group) + message_right = list(x in str(context_manager.exception) for x in ["User's membership is", "test_shift_group", "not"]) + self.assertTrue(all(message_right), "Message:{}".format(str(context_manager.exception), message_right)) + + fake_key = SlashSeparatedCourseKey('a', 'b', 'c') + fake_shift_group, created = CourseShiftGroup.create("fake_shift_group", fake_key) + + # transfer from one course to other + with self.assertRaises(ValueError) as context_manager: + CourseShiftGroupMembership.transfer_user(user, test_shift_group, fake_shift_group) + message_right = list(x in str(context_manager.exception) for x in ["Course groups have different course_key"]) + self.assertTrue(all(message_right), "Message:{}".format(str(context_manager.exception), message_right)) + + test_shift_group.delete() + test_shift_group2.delete() + fake_shift_group.delete() From 9a6b1105be63715e933121955a107536009d04f6 Mon Sep 17 00:00:00 2001 From: Boris Zimka Date: Mon, 11 Sep 2017 11:21:53 +0000 Subject: [PATCH 04/35] README added --- README.rst | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 README.rst diff --git a/README.rst b/README.rst new file mode 100644 index 0000000..b6b6045 --- /dev/null +++ b/README.rst @@ -0,0 +1,18 @@ +Description +----------- +This is django app for OpenEdx that should provide the way for student to move forward all due dates for given course according to the rules defined by the course staff. +Similar feature at the Coursera is called "Session Switch": when activated, course has several sessions with the same content but different deadlines and student can switch them at will. This feature can be useful when student have missed some deadlines but still wants to +finish the course and get credit. + +There are several differences between this app and course rerun/CCX: + +1. The content of the course is the same in all course shifts. Therefore it should be easier for staff to upgrade such course if necessary. It also doesn't spend additional resources. + +2. Forum is shared between all course shifts. + +3. Students can use this function when they want, and therefore course schedule becomes more flexible. + +Details +------- +Feature is implemented via additional FieldOverrideProvider and CourseUserGroups, similar to the way it's done for 'INDIVIDUAL_DUE_DATES' feature. +Every course student is associated with some CourseUserGroup, and provider checks for membership and shift due dates accordingly. From 520ff0834d9a9c528d3a54fe506aaea16b227854 Mon Sep 17 00:00:00 2001 From: Boris Zimka Date: Tue, 12 Sep 2017 13:54:57 +0000 Subject: [PATCH 05/35] Model update, tests for settings --- models.py | 126 +++++++++++++++--------- tests/test_shifts.py | 224 ++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 299 insertions(+), 51 deletions(-) diff --git a/models.py b/models.py index fe8b3fe..394447f 100644 --- a/models.py +++ b/models.py @@ -267,7 +267,6 @@ class CourseShiftSettings(models.Model): help_text="Days after start when student still can enroll" ) - @property def last_start_date(self): shifts = CourseShiftGroup.get_course_shifts(self.course_key) @@ -278,76 +277,109 @@ def last_start_date(self): @property def course_start_date(self): course = modulestore().get_course(self.course_key) - return course.start + return course.start.date() @classmethod def get_course_settings(cls, course_key): current_settings, created = cls.objects.get_or_create(course_key=course_key) return current_settings - @classmethod - def update_course_shift_groups(cls, course_key): - """ - Generates course shift group if necessary according to the settings. - """ - current_settings, created = cls.objects.get_or_create(course_key=course_key) - if not current_settings.is_shift_enabled: - return - - plan = None - # calculates when should be next shift started if 'is_autostart', - # else get next plan from CourseShiftPlannedRun - if current_settings.is_autostart: - last_date = current_settings.last_start_date - next_start_date = last_date + timedelta(days=current_settings._autostart_period_days) + def update_shifts(self): + plan = self.get_next_plan() + is_updated = False + while plan: + is_updated = True + name = self._naming(self.course_key, plan.start_date) + days_add = int((plan.start_date - self.course_start_date).days) + plan.launch_shift(name=name, days_add=days_add) + plan = self.get_next_plan() + return is_updated + + def get_next_plan(self): + if not self.is_shift_enabled: + return None + if self.is_autostart: + plan = self._get_next_autostart_plan() else: - - course_shifts_plans = current_settings.plans.all().order_by('start_date') - if not course_shifts_plans: - return - plan = course_shifts_plans[0] - next_start_date = plan.start_date - - if next_start_date < timezone.now(): - days_add = int((next_start_date - current_settings.course_start_date).days) - name = cls._naming(course_key, next_start_date) - CourseShiftGroup.create( - course_key=course_key, - name=name, - days_shift=days_add, - start_date=next_start_date - ) - if plan: - plan.delete() - - def __init__(self, *args, **kwargs): - super(CourseShiftSettings, self).__init__(*args, **kwargs) - # This attribute is used to clear all course shift plans when - # feature is turned off in course - self._original_is_shift_enabled = self.is_shift_enabled + plan = self._get_next_manual_plan() + return plan + + def _get_next_autostart_plan(self): + last_date = self.last_start_date + next_start_date = last_date + timedelta(days=self.autostart_period_days) + now_time = date_now() + if next_start_date > now_time: + return None + return CourseShiftPlannedRun.get_mocked_plan(self, next_start_date) + + def _get_next_manual_plan(self): + course_shifts_plans = self.plans.all().order_by('start_date') + if not course_shifts_plans: + return False + return course_shifts_plans.first() @classmethod def _naming(cls, course_key, date): return "shift_{}_{}".format(str(course_key), str(date)) - def save(self, *args, **kwargs): - if (not self.is_shift_enabled) and self._original_is_shift_enabled: - CourseShiftPlannedRun.clear_course_shift_plans(self.course_key) - return super(CourseShiftSettings, self).save(*args, **kwargs) + def create_plan(self, start_date): + created, plan = CourseShiftPlannedRun.objects.get_or_create( + course_shift_settings=self, + start_date=start_date, + ) + return plan class CourseShiftPlannedRun(models.Model): """ - Represents planned shift for course. Used - only when course shift dates are set up manually(not 'is_autostart') + Represents planned shift for course. Real plans are stored + in db and user only when new shifts are generated manually('is_autostart'=False) + Also used as a mock for autostart to keep same syntax """ course_shift_settings = models.ForeignKey( CourseShiftSettings, related_name="plans") start_date = models.DateField(default=timezone.now) + class Meta: + unique_together = ('course_shift_settings', 'start_date',) + + MOCKING_FLAG = "mocking_flag" + + @classmethod + def get_mocked_plan(cls, settings, start_date): + """ + Returns mocked plan for autostart mode. It can be launched, + but doesn't hit database in any way + """ + mock = cls(course_shift_settings=settings, start_date=start_date) + setattr(mock, cls.MOCKING_FLAG, True) + mock.delete = lambda: None + mock.save = lambda: None + return mock + @classmethod def clear_course_shift_plans(cls, course_key): plans = cls.objects.filter(course_shift_settings__course_key=course_key) for x in plans: x.delete() + + @classmethod + def get_course_plans(cls, course_key): + return cls.objects.filter(course_shift_settings__course_key=course_key) + + def launch_shift(self, name, days_add): + """ + Launches shift according to plan and self-destructs + """ + shift, created = CourseShiftGroup.create( + course_key=self.course_shift_settings.course_key, + name=name, + days_shift=days_add, + start_date=self.start_date + ) + self.delete() + return shift + + def __unicode__(self): + return u"{} for {}".format(str(self.start_date), str(self.course_shift_settings.course_key)) diff --git a/tests/test_shifts.py b/tests/test_shifts.py index cdc5601..8a5ff56 100644 --- a/tests/test_shifts.py +++ b/tests/test_shifts.py @@ -10,11 +10,11 @@ from xmodule.modulestore.tests.django_utils import TEST_DATA_MIXED_MODULESTORE, ModuleStoreTestCase from xmodule.modulestore.tests.factories import ToyCourseFactory from django.core.exceptions import ValidationError -from ..models import CourseShiftGroup, CourseShiftGroupMembership, CourseUserGroup +from ..models import CourseShiftGroup, CourseShiftGroupMembership, CourseUserGroup, CourseShiftSettings, CourseShiftPlannedRun -def date_shifted(shift): - return (datetime.datetime.now() + datetime.timedelta(days=shift)).date() +def date_shifted(days): + return (datetime.datetime.now() + datetime.timedelta(days=days)).date() @attr(shard=2) @@ -152,7 +152,7 @@ def test_membership_errors(self): """ test_shift_group, created = CourseShiftGroup.create("test_shift_group", self.course_key) test_shift_group2, created = CourseShiftGroup.create("test_shift_group2", self.course_key, - start_date=date_shifted(shift=10)) + start_date=date_shifted(days=10)) user = UserFactory(username="test", email="a@b.com") # user doesn't have shift, but transfer from test_shift_group @@ -187,3 +187,219 @@ def test_membership_errors(self): test_shift_group.delete() test_shift_group2.delete() fake_shift_group.delete() + + def test_several_courses_conflicts(self): + """Tests several memberships in different courses""" + second_course = ToyCourseFactory.create(org="neworg") + second_course_key = second_course.id + + test_a_shift_group, created = CourseShiftGroup.create("test_A_shift_group", self.course_key) + test_a_shift_group2, created = CourseShiftGroup.create("test_A_shift_group2", self.course_key, + start_date=date_shifted(days=10)) + + test_b_shift_group, created = CourseShiftGroup.create("test_B_shift_group", second_course_key) + test_b_shift_group2, created = CourseShiftGroup.create("test_B_shift_group2", second_course_key, + start_date=date_shifted(days=10)) + + user = UserFactory(username="test", email="a@b.com") + + membership_a = CourseShiftGroupMembership.get_user_membership(user, course_key=self.course_key) + membership_b = CourseShiftGroupMembership.get_user_membership(user, course_key=second_course_key) + self.assertTrue(membership_a is None, "User's membership:{}, should be None".format(str(membership_a))) + self.assertTrue(membership_b is None, "User's membership:{}, should be None".format(str(membership_b))) + + CourseShiftGroupMembership.transfer_user(user, None, test_a_shift_group) + CourseShiftGroupMembership.transfer_user(user, None, test_b_shift_group) + membership_a = CourseShiftGroupMembership.get_user_membership(user, course_key=self.course_key) + membership_b = CourseShiftGroupMembership.get_user_membership(user, course_key=second_course_key) + group_1 = membership_a and membership_a.course_shift_group + group_2 = membership_b and membership_b.course_shift_group + self.assertTrue(group_1 == test_a_shift_group, "User's membership {}, should be {}".format( + str(group_1), + str(test_a_shift_group) + )) + self.assertTrue(group_2 == test_b_shift_group, "User's membership {}, should be {}".format( + str(group_1), + str(test_b_shift_group) + )) + + CourseShiftGroupMembership.transfer_user(user, test_a_shift_group, test_a_shift_group2) + CourseShiftGroupMembership.transfer_user(user, test_b_shift_group, test_b_shift_group2) + membership_a = CourseShiftGroupMembership.get_user_membership(user, course_key=self.course_key) + membership_b = CourseShiftGroupMembership.get_user_membership(user, course_key=second_course_key) + group_1 = membership_a and membership_a.course_shift_group + group_2 = membership_b and membership_b.course_shift_group + self.assertTrue(group_1 == test_a_shift_group2, "User's membership {}, should be {}".format( + str(group_1), + str(test_a_shift_group2) + )) + self.assertTrue(group_2 == test_b_shift_group2, "User's membership {}, should be {}".format( + str(group_1), + str(test_b_shift_group2) + )) + CourseShiftGroupMembership.transfer_user(user, test_a_shift_group2, None) + CourseShiftGroupMembership.transfer_user(user, test_b_shift_group2, None) + test_a_shift_group.delete() + test_a_shift_group2.delete() + test_b_shift_group.delete() + test_b_shift_group2.delete() + + +@attr(shard=2) +class TestCourseShiftSettings(ModuleStoreTestCase): + """ + Test the course shifts settings + """ + MODULESTORE = TEST_DATA_MIXED_MODULESTORE + + def setUp(self): + """ + Make sure that course is reloaded every time--clear out the modulestore. + """ + super(TestCourseShiftSettings, self).setUp() + date = datetime.datetime.now() - datetime.timedelta(days=14) + self.course = ToyCourseFactory.create(start=date) + self.course_key = self.course.id + + def _settings_setup(self, period=10, autostart=True): + """ + Not included into setUp because should be tests + """ + settings = CourseShiftSettings.get_course_settings(self.course_key) + settings.is_shift_enabled = True + settings.is_autostart = autostart + settings.autostart_period_days = period + settings.save() + return settings + + def test_settings_generation_and_saving(self): + """ + Tests that settings got by get_course_settings saved correctly + """ + settings = self._settings_setup() + + self.assertTrue(settings.is_shift_enabled == True) + self.assertTrue(settings.is_autostart == True) + self.assertTrue(settings.autostart_period_days == 10) + settings.delete() + + def test_autostart_generation_single(self): + """ + Single shift must be generated automatically + """ + settings = self._settings_setup(period=9) + course_shifts = CourseShiftGroup.get_course_shifts(self.course_key) + self.assertTrue(len(course_shifts) == 0, "There are course shifts at start:{}".format(str(course_shifts))) + + settings.update_shifts() + course_shifts = CourseShiftGroup.get_course_shifts(self.course_key) + self.assertTrue(len(course_shifts) == 1, "Must be single shift, found:{}".format(str(course_shifts))) + for x in course_shifts: + x.delete() + + def test_autostart_generation_three(self): + """ + Three shifts must be generated automatically + """ + settings = self._settings_setup(period=4) + course_shifts = CourseShiftGroup.get_course_shifts(self.course_key) + self.assertTrue(len(course_shifts) == 0, "There are course shifts at start:{}".format(str(course_shifts))) + + settings.update_shifts() + course_shifts = CourseShiftGroup.get_course_shifts(self.course_key) + self.assertTrue(len(course_shifts) == 3, "Must be 3 shifts, found:{}".format(str(course_shifts))) + for x in course_shifts: + x.delete() + + def test_autostart_generation_zero(self): + """ + Autostart but no shift should be generated. + """ + settings = self._settings_setup(period=30) + course_shifts = CourseShiftGroup.get_course_shifts(self.course_key) + self.assertTrue(len(course_shifts) == 0, "There are course shifts at start:{}".format(str(course_shifts))) + + settings.update_shifts() + course_shifts = CourseShiftGroup.get_course_shifts(self.course_key) + self.assertTrue(len(course_shifts) == 0, "Must be 0 shifts, found: {}".format(str(course_shifts))) + + def test_plan_generation(self): + """ + Tests that plans are generated correctly + """ + settings = self._settings_setup(autostart=False) + plans = CourseShiftPlannedRun.get_course_plans(self.course_key) + self.assertTrue(len(plans) == 0, "There are shift plans at start:{}".format(str(plans))) + + settings.create_plan(date_shifted(-3)) + plans = CourseShiftPlannedRun.get_course_plans(self.course_key) + self.assertTrue(len(plans) == 1, "Must be single plan, found:{}".format(str(plans))) + plans[0].delete() + + def test_plan_launch(self): + settings = self._settings_setup(autostart=False) + + plans = CourseShiftPlannedRun.get_course_plans(self.course_key) + self.assertTrue(len(plans) == 0, "There are shift plans at start:{}".format(str(plans))) + + course_shifts = CourseShiftGroup.get_course_shifts(self.course_key) + self.assertTrue(len(course_shifts) == 0, "There are course shifts at start:{}".format(str(course_shifts))) + + settings.create_plan(date_shifted(-3)) + next_plan = settings.get_next_plan() + self.assertTrue(next_plan, "Plan is :{}".format(str(next_plan))) + next_plan.launch_shift(name="doesnt_matter", days_add=7) + + plans = CourseShiftPlannedRun.get_course_plans(self.course_key) + self.assertTrue(len(plans) == 0, "Shouldn't be any plans, found:{}".format(str(plans))) + + course_shifts = CourseShiftGroup.get_course_shifts(self.course_key) + self.assertTrue(len(course_shifts) == 1, "Must be single shift, found:{}".format(str(course_shifts))) + + def test_manual_generation_zero(self): + """ + Tests manually preset plans. + Test with zero planned runs + """ + settings = self._settings_setup(autostart=False) + course_shifts = CourseShiftGroup.get_course_shifts(self.course_key) + self.assertTrue(len(course_shifts) == 0, "There are course shifts at start:{}".format(str(course_shifts))) + + settings.update_shifts() + + course_shifts = CourseShiftGroup.get_course_shifts(self.course_key) + self.assertTrue(len(course_shifts) == 0, "Course shifts shouldn't be generated, found:{}".format(str(course_shifts))) + + def test_manual_generation_one(self): + """ + Tests manually preset plans. + Test with single planned run + """ + settings = self._settings_setup(autostart=False) + settings.create_plan(start_date=date_shifted(-2)) + + course_shifts = CourseShiftGroup.get_course_shifts(self.course_key) + self.assertTrue(len(course_shifts) == 0, "There are course shifts at start:{}".format(str(course_shifts))) + + settings.update_shifts() + + course_shifts = CourseShiftGroup.get_course_shifts(self.course_key) + self.assertTrue(len(course_shifts) == 1, "Must be single shift, found:{}".format(str(course_shifts))) + + def test_manual_generation_three(self): + """ + Tests manually preset plans. + Test with three planned runs + """ + settings = self._settings_setup(autostart=False) + settings.create_plan(start_date=date_shifted(-6)) + settings.create_plan(start_date=date_shifted(-4)) + settings.create_plan(start_date=date_shifted(-2)) + + course_shifts = CourseShiftGroup.get_course_shifts(self.course_key) + self.assertTrue(len(course_shifts) == 0, "There are course shifts at start:{}".format(str(course_shifts))) + + settings.update_shifts() + + course_shifts = CourseShiftGroup.get_course_shifts(self.course_key) + self.assertTrue(len(course_shifts) == 3, "Must be single shift, found:{}".format(str(course_shifts))) From f8cf126ba0be575fa4072db57ecb6f1c62f1e89a Mon Sep 17 00:00:00 2001 From: Boris Zimka Date: Tue, 12 Sep 2017 15:53:13 +0000 Subject: [PATCH 06/35] Added manager for operations on user --- manager.py | 91 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ models.py | 11 +++++++ 2 files changed, 102 insertions(+) create mode 100644 manager.py diff --git a/manager.py b/manager.py new file mode 100644 index 0000000..8fe3911 --- /dev/null +++ b/manager.py @@ -0,0 +1,91 @@ +from datetime import timedelta +from logging import getLogger + +from django.utils import timezone +from models import CourseShiftGroup, CourseShiftGroupMembership, CourseShiftSettings + + +date_now = lambda: timezone.now().date() +log = getLogger(__name__) + + +class CourseShiftUserManager(object): + """ + Provides the interface to perform operations on users and + course shifts: user transfer between shifts, due date calculation, + active shifts etc. + """ + + @classmethod + def get_user_course_shift(cls, user, course_key): + """ + Returns user's shift group for given course + """ + membership = CourseShiftGroupMembership.get_user_membership(user, course_key) + if membership: + return membership.course_user_group + + def __init__(self, course_key): + self.course_key = course_key + self.settings = CourseShiftSettings.get_course_settings(self.course_key) + + def get_active_shifts(self, date_threshold=None): + """ + Returns shifts that are are active at this moment according to the settings. + date_threshold add additional filter for shifts start date against threshold + (e.g. for user switching shift to the newer shift) + """ + if not self.settings.is_shift_enabled: + return [] + current_date = date_now() + all_shifts = CourseShiftGroup.get_course_shifts(self.course_key) + if not all_shifts: + return [] + + active_shifts = [] + for shift in all_shifts: + enroll_finish = shift.start_date + timedelta(days=self.settings.enroll_after_days) + enroll_start = shift.start_date - timedelta(days=self.settings.enroll_before_days) + if not(enroll_start < current_date < enroll_finish): + continue + if date_threshold and shift.start_date < date_threshold: + continue + active_shifts.append(shift) + return active_shifts + + def sign_user_on_shift(self, user, shift_to, course_key, shift_from=None, forced=False): + """ + Transfers user to given shift group. User's enrollment is not checked + because at course enrollment user should be firstly transfered to shift and + only then enrolled on course. + If forced, then user unenrolled from current course shift automatically, + otherwise user mustn't have any current shift membership + """ + if shift_to.course_key != course_key: + raise ValueError("Shift's course_key: '{}', given course_key:'{}'".format( + str(shift_to.course_key), + str(course_key) + )) + if shift_from and shift_from.course_key != course_key: + raise ValueError("Shift_from's course_key: '{}', given course_key:'{}'".format( + str(shift_from.course_key), + str(course_key) + )) + + membership = CourseShiftGroupMembership.get_user_membership(user=user, course_key=course_key) + if membership and membership.course_shift_group == shift_to: + return membership + + if not forced and membership and membership.course_shift_group != shift_from: + raise ValueError("User's membership for given course is not None:{}".format(str(membership))) + + date_threshold = shift_from and shift_from.start_date + active_shifts = self.get_active_shifts(date_threshold=date_threshold) + if shift_to not in active_shifts: + raise ValueError("Shift {} is not in active shifts: {}".format( + str(shift_to), + str(active_shifts) + )) + + return CourseShiftGroupMembership.transfer_user(user, membership.course_shift_group, shift_to) + diff --git a/models.py b/models.py index 394447f..a795496 100644 --- a/models.py +++ b/models.py @@ -55,6 +55,17 @@ def users(self): def name(self): return self.course_user_group.name + def get_due(self, user, block): + if not block.due: + return + + if user not in self.users.all(): + raise ValueError("User {} is not in shift {}".format( + user.username, + str(self) + )) + return block.due + timedelta(days=self.days_shift) + @classmethod def get_course_shifts(cls, course_key): """ From 7c9e565a2b9e02492c616f45588f98669c25fbc9 Mon Sep 17 00:00:00 2001 From: Boris Zimka Date: Wed, 13 Sep 2017 12:54:30 +0000 Subject: [PATCH 07/35] Manager update and manager tests --- manager.py | 39 +++++++---- models.py | 2 +- tests/test_shifts.py | 149 ++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 176 insertions(+), 14 deletions(-) diff --git a/manager.py b/manager.py index 8fe3911..5aece41 100644 --- a/manager.py +++ b/manager.py @@ -16,18 +16,24 @@ class CourseShiftUserManager(object): active shifts etc. """ - @classmethod - def get_user_course_shift(cls, user, course_key): + def __init__(self, course_key): + self.course_key = course_key + self.settings = CourseShiftSettings.get_course_settings(self.course_key) + + @property + def is_shift_enabled(self): + return self.settings.is_shift_enabled + + def get_user_course_shift(self, user, course_key): """ - Returns user's shift group for given course + Returns user's shift group for given course. """ + if not self.settings.is_shift_enabled: + return + membership = CourseShiftGroupMembership.get_user_membership(user, course_key) if membership: - return membership.course_user_group - - def __init__(self, course_key): - self.course_key = course_key - self.settings = CourseShiftSettings.get_course_settings(self.course_key) + return membership.course_shift_group def get_active_shifts(self, date_threshold=None): """ @@ -53,13 +59,18 @@ def get_active_shifts(self, date_threshold=None): active_shifts.append(shift) return active_shifts - def sign_user_on_shift(self, user, shift_to, course_key, shift_from=None, forced=False): + def sign_user_on_shift(self, user, shift_to, course_key, shift_from=None, forced=False, shift_up_only=True): """ Transfers user to given shift group. User's enrollment is not checked because at course enrollment user should be firstly transfered to shift and only then enrolled on course. If forced, then user unenrolled from current course shift automatically, otherwise user mustn't have any current shift membership + :param user: user to enroll on shift + :param shift_to: CourseShiftGroup to enroll + :param course_key: to which course shift_to (and shif_from if not None) belongs + :param forced: unenroll from current shift if shift_from is not given + :param shift_up_only: allow to change only on later shifts """ if shift_to.course_key != course_key: raise ValueError("Shift's course_key: '{}', given course_key:'{}'".format( @@ -73,13 +84,17 @@ def sign_user_on_shift(self, user, shift_to, course_key, shift_from=None, forced )) membership = CourseShiftGroupMembership.get_user_membership(user=user, course_key=course_key) - if membership and membership.course_shift_group == shift_to: + group_from = membership and membership.course_shift_group + if group_from == shift_to: return membership - if not forced and membership and membership.course_shift_group != shift_from: + if not forced and group_from != shift_from: raise ValueError("User's membership for given course is not None:{}".format(str(membership))) date_threshold = shift_from and shift_from.start_date + if not shift_up_only: + date_threshold = None + active_shifts = self.get_active_shifts(date_threshold=date_threshold) if shift_to not in active_shifts: raise ValueError("Shift {} is not in active shifts: {}".format( @@ -87,5 +102,5 @@ def sign_user_on_shift(self, user, shift_to, course_key, shift_from=None, forced str(active_shifts) )) - return CourseShiftGroupMembership.transfer_user(user, membership.course_shift_group, shift_to) + return CourseShiftGroupMembership.transfer_user(user, group_from, shift_to) diff --git a/models.py b/models.py index a795496..d567539 100644 --- a/models.py +++ b/models.py @@ -361,7 +361,7 @@ class Meta: def get_mocked_plan(cls, settings, start_date): """ Returns mocked plan for autostart mode. It can be launched, - but doesn't hit database in any way + but doesn't hit database at deletion """ mock = cls(course_shift_settings=settings, start_date=start_date) setattr(mock, cls.MOCKING_FLAG, True) diff --git a/tests/test_shifts.py b/tests/test_shifts.py index 8a5ff56..61cc274 100644 --- a/tests/test_shifts.py +++ b/tests/test_shifts.py @@ -11,7 +11,7 @@ from xmodule.modulestore.tests.factories import ToyCourseFactory from django.core.exceptions import ValidationError from ..models import CourseShiftGroup, CourseShiftGroupMembership, CourseUserGroup, CourseShiftSettings, CourseShiftPlannedRun - +from ..manager import CourseShiftUserManager def date_shifted(days): return (datetime.datetime.now() + datetime.timedelta(days=days)).date() @@ -403,3 +403,150 @@ def test_manual_generation_three(self): course_shifts = CourseShiftGroup.get_course_shifts(self.course_key) self.assertTrue(len(course_shifts) == 3, "Must be single shift, found:{}".format(str(course_shifts))) + + +@attr(shard=2) +class TestCourseShiftManager(ModuleStoreTestCase): + def setUp(self): + super(TestCourseShiftManager, self).setUp() + date = datetime.datetime.now() - datetime.timedelta(days=14) + self.course = ToyCourseFactory.create(start=date) + self.course_key = self.course.id + self.shift_settings = CourseShiftSettings.get_course_settings(self.course_key) + self.shift_settings.is_shift_enabled = True + self.shift_settings.save() + + def test_get_user_course_shift(self): + """ + Tests method get_user_course_shift + """ + user = UserFactory(username="test", email="a@b.com") + shift_manager = CourseShiftUserManager(course_key=self.course_key) + shift_group = shift_manager.get_user_course_shift(user, self.course_key) + self.assertTrue(shift_group is None, "User shift group is {}, should be None".format(str(shift_group))) + + test_a_shift_group, created = CourseShiftGroup.create("test_shift_group", self.course_key) + CourseShiftGroupMembership.transfer_user(user, None, test_a_shift_group) + shift_group = shift_manager.get_user_course_shift(user, self.course_key) + self.assertTrue(shift_group==test_a_shift_group, "User shift group is {}, should be {}".format( + str(shift_group), + str(test_a_shift_group) + )) + + self.shift_settings.is_shift_enabled = False + self.shift_settings.save() + shift_manager = CourseShiftUserManager(self.course_key) + shift_group = shift_manager.get_user_course_shift(user, self.course_key) + self.assertTrue(shift_group is None, "User shift group is {}, should be None".format(str(shift_group))) + + self.shift_settings.is_shift_enabled = True + self.shift_settings.save() + CourseShiftGroupMembership.transfer_user(user, test_a_shift_group, None) + test_a_shift_group.delete() + + def test_get_active_shifts(self): + """ + Tests method get_active_shifts + """ + shift_manager = CourseShiftUserManager(self.course_key) + course_shifts = shift_manager.get_active_shifts() + self.assertTrue(len(course_shifts) == 0, "Must be zero shift groups, found:{}".format(str(course_shifts))) + + group1, created = CourseShiftGroup.create("test_group", self.course_key) + group2, created = CourseShiftGroup.create("test_group2", self.course_key, start_date=date_shifted(days=-5)) + + course_shifts = shift_manager.get_active_shifts() + correct = (group1 in course_shifts) and (group2 in course_shifts) and (len(course_shifts) == 2) + self.assertTrue(correct, "Shifts should be {} and {}, found {}".format( + str(group1), + str(group2), + str(course_shifts) + )) + + course_shifts = shift_manager.get_active_shifts(date_threshold=date_shifted(-2)) + correct = (group1 in course_shifts) and (len(course_shifts) == 1) + self.assertTrue(correct, "Shifts should be {}, found {}".format( + str(group1), + str(course_shifts) + )) + group1.delete() + group2.delete() + + def test_sign_user_on_shift_valid(self): + """ + Tests method sign_user_on_shift. + Valid scenarios + """ + user = UserFactory(username="test", email="a@b.com") + shift_manager = CourseShiftUserManager(course_key=self.course_key) + shift_group = shift_manager.get_user_course_shift(user, self.course_key) + self.assertTrue(shift_group is None, "User shift group is {}, should be None".format(str(shift_group))) + + group1, created = CourseShiftGroup.create("test_group", self.course_key) + group2, created = CourseShiftGroup.create("test_group2", self.course_key, start_date=date_shifted(days=-5)) + + shift_manager.sign_user_on_shift(user, group1, self.course_key) + shift_group = shift_manager.get_user_course_shift(user, self.course_key) + self.assertTrue(shift_group == group1, "User shift group is {}, should be {}".format( + str(shift_group), + str(group1) + )) + + shift_manager.sign_user_on_shift(user, shift_to=group2, shift_from=group1, course_key=self.course_key, shift_up_only=False) + shift_group = shift_manager.get_user_course_shift(user, self.course_key) + self.assertTrue(shift_group == group2, "User shift group is {}, should be {}".format( + str(shift_group), + str(group2) + )) + + shift_manager.sign_user_on_shift(user, shift_to=group1, course_key=self.course_key, forced=True) + shift_group = shift_manager.get_user_course_shift(user, self.course_key) + self.assertTrue(shift_group == group1, "User shift group is {}, should be {}".format( + str(shift_group), + str(group1) + )) + CourseShiftGroupMembership.transfer_user(user, group1, None) + group1.delete() + group2.delete() + + def test_sign_user_on_shift_invalid(self): + """ + Tests method sign_user_on_shift. + Invalid scenarios + """ + second_course = ToyCourseFactory.create(org="neworg") + second_course_key = second_course.id + + user = UserFactory(username="test", email="a@b.com") + shift_manager = CourseShiftUserManager(course_key=self.course_key) + shift_group = shift_manager.get_user_course_shift(user, self.course_key) + self.assertTrue(shift_group is None, "User shift group is {}, should be None".format(str(shift_group))) + + group1, created = CourseShiftGroup.create("test_group", self.course_key) + group2, created = CourseShiftGroup.create("test_group2", self.course_key, start_date=date_shifted(days=-5)) + + group_invalid, created = CourseShiftGroup.create("invalid_test_group", second_course_key) + + with self.assertRaises(ValueError) as context_manager: + shift_manager.sign_user_on_shift(user, group1, course_key=second_course_key) + exception_msg_parts = ("Shift's course_key:", ", given course_key:") + self.assertTrue(all(x in str(context_manager.exception) for x in exception_msg_parts)) + + shift_manager.sign_user_on_shift(user, group1, course_key=self.course_key) + + with self.assertRaises(ValueError) as context_manager: + shift_manager.sign_user_on_shift(user, shift_from=group1, shift_to=group_invalid, course_key=second_course_key) + exception_msg_parts = ("Shift_from's course_key:", "given course_key:") + self.assertTrue(all(x in str(context_manager.exception) for x in exception_msg_parts)) + + with self.assertRaises(ValueError) as context_manager: + shift_manager.sign_user_on_shift(user, group2, self.course_key) + exception_msg_parts = ("User's membership for given course is not None:",) + self.assertTrue(all(x in str(context_manager.exception) for x in exception_msg_parts)) + + membership = CourseShiftGroupMembership.get_user_membership(user, self.course_key) + if membership: + membership.delete() + group1.delete() + group2.delete() + group_invalid.delete() From f137170af5d1aa9a4f0071f1e661a4fa3ec96436 Mon Sep 17 00:00:00 2001 From: Boris Zimka Date: Wed, 13 Sep 2017 15:00:36 +0000 Subject: [PATCH 08/35] Review fixes --- README.rst | 4 +- models.py | 90 ++++++++++++++++++++++++-------------------- tests/test_shifts.py | 6 +-- 3 files changed, 54 insertions(+), 46 deletions(-) diff --git a/README.rst b/README.rst index b6b6045..99a880d 100644 --- a/README.rst +++ b/README.rst @@ -1,6 +1,8 @@ Description ----------- -This is django app for OpenEdx that should provide the way for student to move forward all due dates for given course according to the rules defined by the course staff. +This is django app based on OpenEdx Ficus release `"open-release/ficus.2" +`_ +that should provide the way for student to move forward all due dates for given course according to the rules defined by the course staff. Similar feature at the Coursera is called "Session Switch": when activated, course has several sessions with the same content but different deadlines and student can switch them at will. This feature can be useful when student have missed some deadlines but still wants to finish the course and get credit. diff --git a/models.py b/models.py index d567539..f2a7f37 100644 --- a/models.py +++ b/models.py @@ -32,20 +32,21 @@ class CourseShiftGroup(models.Model): state of shifts. """ course_user_group = models.OneToOneField(CourseUserGroup) + course_key = CourseKeyField( + max_length=255, + db_index=True, + help_text="Which course is this group associated with") start_date = models.DateField( default=date_now, - db_index=True, - help_text="Date when this shift started" + help_text="Date when this shift starts" ) - days_shift = models.IntegerField( default=0, help_text="Days to add to the block's due" ) - @property - def course_key(self): - return self.course_user_group.course_id + class Meta: + unique_together = ('course_key', 'start_date',) @property def users(self): @@ -73,7 +74,7 @@ def get_course_shifts(cls, course_key): """ if not isinstance(course_key, CourseKey): raise TypeError("course_key must be CourseKey, not {}".format(type(course_key))) - return cls.objects.filter(course_user_group__course_id=course_key).order_by('-start_date') + return cls.objects.filter(course_key=course_key).order_by('-start_date') @classmethod def create(cls, name, course_key, start_date=None, days_shift=None): @@ -81,44 +82,33 @@ def create(cls, name, course_key, start_date=None, days_shift=None): Creates new CourseShiftGroup. If shift with (name, course_key) combination already exists returns this shift """ - course_user_group, created = CourseUserGroup.create(name=name, course_id=course_key) + course_user_group, created_group = CourseUserGroup.create(name=name, course_id=course_key) kwargs = {"course_user_group": course_user_group} if start_date: kwargs["start_date"] = start_date if days_shift: kwargs["days_shift"] = days_shift + kwargs['course_key'] = course_key course_shift_group, created_shift = CourseShiftGroup.objects.get_or_create(**kwargs) - return course_shift_group, created and created_shift - - def validate_unique(self, *args, **kwargs): - """ - Checks that course_key and date combination is unique. - Can't be set is constraint because course_key is taken - from ForeignKey - """ - val = super(CourseShiftGroup, self).validate_unique(*args, **kwargs) - if not self.pk: - current_shifts = CourseShiftGroup.get_course_shifts(self.course_key) - already_have_such_date = any([x.start_date == self.start_date for x in current_shifts]) - if already_have_such_date: - raise ValidationError( - "Shift for course {} with date {} already exists".format( - str(self.course_key), str(self.start_date) - ) - ) - return val + is_created = created_group and created_shift + return course_shift_group, is_created def __unicode__(self): return u"'{}' in '{}'".format(self.name, str(self.course_key)) def delete(self, *args, **kwargs): - group = self.course_user_group - delete = super(CourseShiftGroup, self).delete(*args, **kwargs) - group.delete() - return delete + #group = self.course_user_group + #delete_result = super(CourseShiftGroup, self).delete(*args, **kwargs) + #group.delete() + self.course_user_group.delete() + return super(CourseShiftGroup, self).delete(*args, **kwargs) def save(self, *args, **kwargs): - self.validate_unique() + if self.course_key != self.course_user_group.course_id: + raise ValidationError("Different course keys in shift and user group: {}, {}".format( + str(self.course_key), + str(self.course_user_group.course_id) + )) return super(CourseShiftGroup, self).save(*args, **kwargs) @@ -140,10 +130,11 @@ def get_user_membership(cls, user, course_key): Returns CourseUserGroup for user and course if membership exists, else None """ all_memberships = cls.objects.filter(user=user) - course_membership = all_memberships.filter(course_shift_group__course_user_group__course_id=course_key) - membership = course_membership.first() - if membership: - return membership + try: + course_membership = cls.objects.get(user=user, course_shift_group__course_key=course_key) + except cls.DoesNotExist: + course_membership = None + return course_membership @classmethod def transfer_user(cls, user, course_shift_group_from, course_shift_group_to): @@ -189,7 +180,7 @@ def transfer_user(cls, user, course_shift_group_from, course_shift_group_to): @classmethod def _push_add_to_group(cls, course_shift_group, user): """ - Adds user to CourseShiftGroup if he has membership for this group or None. + Adds user to CourseShiftGroup if he has membership for this group or doesn't have membership. """ membership = CourseShiftGroupMembership.get_user_membership(user=user, course_key=course_shift_group.course_key) membership_group = membership and membership.course_shift_group @@ -264,18 +255,23 @@ class CourseShiftSettings(models.Model): autostart_period_days = models.IntegerField( default=28, db_column='autostart_period_days', - help_text="Period of generated groups", + help_text="Number of days between new automatically generated shifts."\ + "Used only in autostart mode.", null=True ) enroll_before_days = models.IntegerField( default=14, - help_text="Days before start when student can enroll already" + help_text="Days before shift start when student can enroll already."\ + "E.g. if shift start 20.01.2020 and value is 5 shift will be available"\ + "from 15.01.2020" ) enroll_after_days = models.IntegerField( default=7, - help_text="Days after start when student still can enroll" + help_text="Days after shift start when student still can enroll." \ + "E.g. if shift start 20.01.2020 and value is 10 shift will be available" \ + "till 30.01.2020" ) @property @@ -296,6 +292,11 @@ def get_course_settings(cls, course_key): return current_settings def update_shifts(self): + """ + Checks current date and creates new shifts if necessary + according to the settings + :return: if new shifts were created + """ plan = self.get_next_plan() is_updated = False while plan: @@ -307,6 +308,10 @@ def update_shifts(self): return is_updated def get_next_plan(self): + """ + Returns closest CourseShiftPlannedRun or None if + feature is turned off or no plans available currently + """ if not self.is_shift_enabled: return None if self.is_autostart: @@ -331,6 +336,9 @@ def _get_next_manual_plan(self): @classmethod def _naming(cls, course_key, date): + """ + Defines how should be shifts named + """ return "shift_{}_{}".format(str(course_key), str(date)) def create_plan(self, start_date): @@ -381,7 +389,7 @@ def get_course_plans(cls, course_key): def launch_shift(self, name, days_add): """ - Launches shift according to plan and self-destructs + Launches shift according to plan and then self-destructs """ shift, created = CourseShiftGroup.create( course_key=self.course_shift_settings.course_key, diff --git a/tests/test_shifts.py b/tests/test_shifts.py index 61cc274..5c11ac4 100644 --- a/tests/test_shifts.py +++ b/tests/test_shifts.py @@ -9,7 +9,7 @@ from student.tests.factories import UserFactory, RegistrationFactory, UserProfileFactory from xmodule.modulestore.tests.django_utils import TEST_DATA_MIXED_MODULESTORE, ModuleStoreTestCase from xmodule.modulestore.tests.factories import ToyCourseFactory -from django.core.exceptions import ValidationError +from django.db import IntegrityError from ..models import CourseShiftGroup, CourseShiftGroupMembership, CourseUserGroup, CourseShiftSettings, CourseShiftPlannedRun from ..manager import CourseShiftUserManager @@ -105,10 +105,8 @@ def test_shift_creation_errors(self): """ test_shift_group, created = CourseShiftGroup.create("test_shift_group", self.course_key) - with self.assertRaises(ValidationError) as context_manager: + with self.assertRaises(IntegrityError) as context_manager: test_shift_group2, created = CourseShiftGroup.create("test_shift_group2", self.course_key) - exception_msg_parts = ("Shift for course", "with date", "already exists") - self.assertTrue(all(x in str(context_manager.exception) for x in exception_msg_parts)) # when try to create group shift with same (name, key, date) already exists we get that old shift test_shift_group_same, created = CourseShiftGroup.create("test_shift_group", self.course_key) From dcb6a5334d1e1400c6ee24bdfd7e86d0f0fc93ac Mon Sep 17 00:00:00 2001 From: Boris Zimka Date: Thu, 14 Sep 2017 12:31:20 +0000 Subject: [PATCH 09/35] Logging, formating and small fix --- manager.py | 2 +- models.py | 98 +++++++++++++++++++++++++++----------------- tests/test_shifts.py | 39 ++++++++++++------ 3 files changed, 88 insertions(+), 51 deletions(-) diff --git a/manager.py b/manager.py index 5aece41..598c0c8 100644 --- a/manager.py +++ b/manager.py @@ -9,7 +9,7 @@ log = getLogger(__name__) -class CourseShiftUserManager(object): +class CourseShiftManager(object): """ Provides the interface to perform operations on users and course shifts: user transfer between shifts, due date calculation, diff --git a/models.py b/models.py index f2a7f37..4718961 100644 --- a/models.py +++ b/models.py @@ -24,7 +24,7 @@ class CourseShiftGroup(models.Model): """ Represents group of users with shifted due dates. It is based on CourseUserGroup. To ensure that - every user is enrolled to the one shift only + every user is enrolled in the one shift only CourseShiftMembership model is used (just like for CourseCohorts). Don't use this model's methods directly, they should be used by @@ -61,7 +61,7 @@ def get_due(self, user, block): return if user not in self.users.all(): - raise ValueError("User {} is not in shift {}".format( + raise ValueError("User '{}' is not in shift '{}'".format( user.username, str(self) )) @@ -97,18 +97,18 @@ def __unicode__(self): return u"'{}' in '{}'".format(self.name, str(self.course_key)) def delete(self, *args, **kwargs): - #group = self.course_user_group - #delete_result = super(CourseShiftGroup, self).delete(*args, **kwargs) - #group.delete() + log.info("Shift group is deleted: {}".format(str(self))) self.course_user_group.delete() return super(CourseShiftGroup, self).delete(*args, **kwargs) def save(self, *args, **kwargs): if self.course_key != self.course_user_group.course_id: - raise ValidationError("Different course keys in shift and user group: {}, {}".format( + raise ValidationError("Different course keys in shift and user group: '{}' and '{}'".format( str(self.course_key), str(self.course_user_group.course_id) )) + if not self.pk: + log.info("New shift group is created: '{}'".format(str(self))) return super(CourseShiftGroup, self).save(*args, **kwargs) @@ -129,7 +129,6 @@ def get_user_membership(cls, user, course_key): """ Returns CourseUserGroup for user and course if membership exists, else None """ - all_memberships = cls.objects.filter(user=user) try: course_membership = cls.objects.get(user=user, course_shift_group__course_key=course_key) except cls.DoesNotExist: @@ -140,7 +139,7 @@ def get_user_membership(cls, user, course_key): def transfer_user(cls, user, course_shift_group_from, course_shift_group_to): """ Transfers user from one shift to another one. If the first one is None, - user is enrolled to the 'course_shift_group_to'. If the last one + user is enrolled in the 'course_shift_group_to'. If the last one is None, user is unenrolled from shift 'course_shift_group_from' """ @@ -156,7 +155,7 @@ def transfer_user(cls, user, course_shift_group_from, course_shift_group_to): key_to = key(course_shift_group_to) if course_shift_group_from and course_shift_group_to: if str(key_from) != str(key_to): - raise ValueError("Course groups have different course_key's: {} and {}".format( + raise ValueError("Course groups have different course_key's: '{}' and '{}'".format( str(key_from), str(key_to) ) ) @@ -173,7 +172,6 @@ def transfer_user(cls, user, course_shift_group_from, course_shift_group_to): ) if membership: membership.delete() - if course_shift_group_to: return cls.objects.create(user=user, course_shift_group=course_shift_group_to) @@ -214,15 +212,22 @@ def _push_delete_from_group(cls, user, course_shift_group): def save(self, *args, **kwargs): if self.pk: raise ValueError("CourseShiftGroupMembership can't be changed, only deleted") - save = super(CourseShiftGroupMembership, self).save(*args, **kwargs) + save_result = super(CourseShiftGroupMembership, self).save(*args, **kwargs) + log.info("User '{}' is enrolled in shift '{}'".format( + self.user.username, + str(self.course_shift_group)) + ) if self.user not in self.course_shift_group.users.all(): self._push_add_to_group(self.course_shift_group, self.user) - return save + return save_result def delete(self, *args, **kwargs): - delete = super(CourseShiftGroupMembership, self).delete(*args, **kwargs) + log.info("User '{}' is unenrolled from shift '{}'".format( + self.user.username, + str(self.course_shift_group)) + ) + super(CourseShiftGroupMembership, self).delete(*args, **kwargs) self._push_delete_from_group(self.user, self.course_shift_group) - return delete def __unicode__(self): return u"'{}' in '{}'".format( @@ -233,18 +238,17 @@ def __unicode__(self): class CourseShiftSettings(models.Model): """ - Describes how should course shifts be run for - course session. + Describes Course Shift settings for start and due dates in the specific course run. """ course_key = CourseKeyField( max_length=255, db_index=True, unique=True, - ) + ) is_shift_enabled = models.BooleanField( default=False, - help_text="Is feature enabled for course" + help_text="True value if this feature is enabled for the course run" ) is_autostart = models.BooleanField( @@ -263,19 +267,22 @@ class CourseShiftSettings(models.Model): enroll_before_days = models.IntegerField( default=14, help_text="Days before shift start when student can enroll already."\ - "E.g. if shift start 20.01.2020 and value is 5 shift will be available"\ - "from 15.01.2020" + "E.g. if shift starts at 01/20/2020 and value is 5 then shift will be"\ + "available from 01/15/2020." ) enroll_after_days = models.IntegerField( default=7, help_text="Days after shift start when student still can enroll." \ - "E.g. if shift start 20.01.2020 and value is 10 shift will be available" \ - "till 30.01.2020" + "E.g. if shift starts at 01/20/2020 and value is 10 then shift will be" \ + "available till 01/20/2020" ) @property def last_start_date(self): + """ + Date when the last shift was started. + """ shifts = CourseShiftGroup.get_course_shifts(self.course_key) if not shifts: return self.course_start_date @@ -289,6 +296,10 @@ def course_start_date(self): @classmethod def get_course_settings(cls, course_key): current_settings, created = cls.objects.get_or_create(course_key=course_key) + if created: + log.info("Settings for {} are created".format( + str(course_key) + )) return current_settings def update_shifts(self): @@ -305,8 +316,23 @@ def update_shifts(self): days_add = int((plan.start_date - self.course_start_date).days) plan.launch_shift(name=name, days_add=days_add) plan = self.get_next_plan() + if is_updated: + log.info( + "Shifts for course '{}' are updated".format(str(self.course_key)) + ) return is_updated + def create_plan(self, start_date, launch_plan=False): + """ + Creates plan with given start date. + There is no check that shift are in manual mode + """ + created, plan = CourseShiftPlannedRun.objects.get_or_create( + course_shift_settings=self, + start_date=start_date, + ) + return plan + def get_next_plan(self): """ Returns closest CourseShiftPlannedRun or None if @@ -341,19 +367,14 @@ def _naming(cls, course_key, date): """ return "shift_{}_{}".format(str(course_key), str(date)) - def create_plan(self, start_date): - created, plan = CourseShiftPlannedRun.objects.get_or_create( - course_shift_settings=self, - start_date=start_date, - ) - return plan - class CourseShiftPlannedRun(models.Model): """ - Represents planned shift for course. Real plans are stored - in db and user only when new shifts are generated manually('is_autostart'=False) - Also used as a mock for autostart to keep same syntax + Represents planned shift for course. + Plan can be launched, then it creates the shift and disappears. + For 'autostart' mode in settings mocked plans can be created: + they can be launched, but they are not stored in db and don't hit + it at plan deletion. """ course_shift_settings = models.ForeignKey( CourseShiftSettings, @@ -377,12 +398,6 @@ def get_mocked_plan(cls, settings, start_date): mock.save = lambda: None return mock - @classmethod - def clear_course_shift_plans(cls, course_key): - plans = cls.objects.filter(course_shift_settings__course_key=course_key) - for x in plans: - x.delete() - @classmethod def get_course_plans(cls, course_key): return cls.objects.filter(course_shift_settings__course_key=course_key) @@ -391,12 +406,19 @@ def launch_shift(self, name, days_add): """ Launches shift according to plan and then self-destructs """ + shift, created = CourseShiftGroup.create( course_key=self.course_shift_settings.course_key, name=name, days_shift=days_add, start_date=self.start_date ) + log.info( + "Shift plan {} is launched as shift {}".format( + str(self), + str(shift) + ) + ) self.delete() return shift diff --git a/tests/test_shifts.py b/tests/test_shifts.py index 5c11ac4..0f7deff 100644 --- a/tests/test_shifts.py +++ b/tests/test_shifts.py @@ -11,7 +11,7 @@ from xmodule.modulestore.tests.factories import ToyCourseFactory from django.db import IntegrityError from ..models import CourseShiftGroup, CourseShiftGroupMembership, CourseUserGroup, CourseShiftSettings, CourseShiftPlannedRun -from ..manager import CourseShiftUserManager +from ..manager import CourseShiftManager def date_shifted(days): return (datetime.datetime.now() + datetime.timedelta(days=days)).date() @@ -156,13 +156,15 @@ def test_membership_errors(self): # user doesn't have shift, but transfer from test_shift_group with self.assertRaises(ValueError) as context_manager: CourseShiftGroupMembership.transfer_user(user, test_shift_group, test_shift_group2) - message_right = list(x in str(context_manager.exception) for x in ["User's membership is", "test_shift_group", "not"]) + message_list = ["User's membership is", "test_shift_group", "not"] + message_right = list(x in str(context_manager.exception) for x in message_list) self.assertTrue(all(message_right), "Message:{}".format(str(context_manager.exception), message_right)) # user doesn't have shift, but transfer from None with self.assertRaises(ValueError) as context_manager: CourseShiftGroupMembership.transfer_user(user, test_shift_group, None) - message_right = list(x in str(context_manager.exception) for x in ["User's membership is", "None", "not"]) + message_list = ["User's membership is", "None", "not"] + message_right = list(x in str(context_manager.exception) for x in message_list) self.assertTrue(all(message_right), "Message:{}".format(str(context_manager.exception), message_right)) CourseShiftGroupMembership.transfer_user(user, None, test_shift_group) @@ -170,7 +172,8 @@ def test_membership_errors(self): # user has shift test_shift_group, but transfer from test_shift_group2 with self.assertRaises(ValueError) as context_manager: CourseShiftGroupMembership.transfer_user(user, test_shift_group2, test_shift_group) - message_right = list(x in str(context_manager.exception) for x in ["User's membership is", "test_shift_group", "not"]) + message_list = ["User's membership is", "test_shift_group", "not"] + message_right = list(x in str(context_manager.exception) for x in message_list) self.assertTrue(all(message_right), "Message:{}".format(str(context_manager.exception), message_right)) fake_key = SlashSeparatedCourseKey('a', 'b', 'c') @@ -366,7 +369,8 @@ def test_manual_generation_zero(self): settings.update_shifts() course_shifts = CourseShiftGroup.get_course_shifts(self.course_key) - self.assertTrue(len(course_shifts) == 0, "Course shifts shouldn't be generated, found:{}".format(str(course_shifts))) + mes = "Course shifts shouldn't be generated, found:{}".format(str(course_shifts)) + self.assertTrue(len(course_shifts) == 0, mes) def test_manual_generation_one(self): """ @@ -419,7 +423,7 @@ def test_get_user_course_shift(self): Tests method get_user_course_shift """ user = UserFactory(username="test", email="a@b.com") - shift_manager = CourseShiftUserManager(course_key=self.course_key) + shift_manager = CourseShiftManager(course_key=self.course_key) shift_group = shift_manager.get_user_course_shift(user, self.course_key) self.assertTrue(shift_group is None, "User shift group is {}, should be None".format(str(shift_group))) @@ -433,7 +437,7 @@ def test_get_user_course_shift(self): self.shift_settings.is_shift_enabled = False self.shift_settings.save() - shift_manager = CourseShiftUserManager(self.course_key) + shift_manager = CourseShiftManager(self.course_key) shift_group = shift_manager.get_user_course_shift(user, self.course_key) self.assertTrue(shift_group is None, "User shift group is {}, should be None".format(str(shift_group))) @@ -446,7 +450,7 @@ def test_get_active_shifts(self): """ Tests method get_active_shifts """ - shift_manager = CourseShiftUserManager(self.course_key) + shift_manager = CourseShiftManager(self.course_key) course_shifts = shift_manager.get_active_shifts() self.assertTrue(len(course_shifts) == 0, "Must be zero shift groups, found:{}".format(str(course_shifts))) @@ -476,7 +480,7 @@ def test_sign_user_on_shift_valid(self): Valid scenarios """ user = UserFactory(username="test", email="a@b.com") - shift_manager = CourseShiftUserManager(course_key=self.course_key) + shift_manager = CourseShiftManager(course_key=self.course_key) shift_group = shift_manager.get_user_course_shift(user, self.course_key) self.assertTrue(shift_group is None, "User shift group is {}, should be None".format(str(shift_group))) @@ -490,7 +494,13 @@ def test_sign_user_on_shift_valid(self): str(group1) )) - shift_manager.sign_user_on_shift(user, shift_to=group2, shift_from=group1, course_key=self.course_key, shift_up_only=False) + shift_manager.sign_user_on_shift( + user=user, + shift_to=group2, + shift_from=group1, + course_key=self.course_key, + shift_up_only=False + ) shift_group = shift_manager.get_user_course_shift(user, self.course_key) self.assertTrue(shift_group == group2, "User shift group is {}, should be {}".format( str(shift_group), @@ -516,7 +526,7 @@ def test_sign_user_on_shift_invalid(self): second_course_key = second_course.id user = UserFactory(username="test", email="a@b.com") - shift_manager = CourseShiftUserManager(course_key=self.course_key) + shift_manager = CourseShiftManager(course_key=self.course_key) shift_group = shift_manager.get_user_course_shift(user, self.course_key) self.assertTrue(shift_group is None, "User shift group is {}, should be None".format(str(shift_group))) @@ -533,7 +543,12 @@ def test_sign_user_on_shift_invalid(self): shift_manager.sign_user_on_shift(user, group1, course_key=self.course_key) with self.assertRaises(ValueError) as context_manager: - shift_manager.sign_user_on_shift(user, shift_from=group1, shift_to=group_invalid, course_key=second_course_key) + shift_manager.sign_user_on_shift( + user=user, + shift_from=group1, + shift_to=group_invalid, + course_key=second_course_key + ) exception_msg_parts = ("Shift_from's course_key:", "given course_key:") self.assertTrue(all(x in str(context_manager.exception) for x in exception_msg_parts)) From a5bcd519adb1d47d04b7a241e02dd36ba7e991a5 Mon Sep 17 00:00:00 2001 From: Boris Zimka Date: Fri, 15 Sep 2017 10:06:35 +0000 Subject: [PATCH 10/35] Manager and tests refactoring --- manager.py | 3 +- models.py | 21 ++- tests/test_shifts.py | 403 +++++++++++++++++++++++++------------------ 3 files changed, 253 insertions(+), 174 deletions(-) diff --git a/manager.py b/manager.py index 598c0c8..cee79da 100644 --- a/manager.py +++ b/manager.py @@ -23,7 +23,7 @@ def __init__(self, course_key): @property def is_shift_enabled(self): return self.settings.is_shift_enabled - + def get_user_course_shift(self, user, course_key): """ Returns user's shift group for given course. @@ -103,4 +103,3 @@ def sign_user_on_shift(self, user, shift_to, course_key, shift_from=None, forced )) return CourseShiftGroupMembership.transfer_user(user, group_from, shift_to) - diff --git a/models.py b/models.py index 4718961..71d1430 100644 --- a/models.py +++ b/models.py @@ -56,16 +56,16 @@ def users(self): def name(self): return self.course_user_group.name - def get_due(self, user, block): - if not block.due: + def get_shifted_due(self, user, block, name): + value = getattr(block, name) + if not value: return - if user not in self.users.all(): raise ValueError("User '{}' is not in shift '{}'".format( user.username, str(self) )) - return block.due + timedelta(days=self.days_shift) + return value + timedelta(days=self.days_shift) @classmethod def get_course_shifts(cls, course_key): @@ -129,6 +129,8 @@ def get_user_membership(cls, user, course_key): """ Returns CourseUserGroup for user and course if membership exists, else None """ + if not course_key: + raise ValueError("Got course_key {}".format(str(course_key))) try: course_membership = cls.objects.get(user=user, course_shift_group__course_key=course_key) except cls.DoesNotExist: @@ -149,17 +151,15 @@ def transfer_user(cls, user, course_shift_group_from, course_shift_group_to): if course_shift_group_from == course_shift_group_to: return - key = lambda x: x.course_key if hasattr(x, "course_key") else None + key_from = course_shift_group_from and course_shift_group_from.course_key + key_to = course_shift_group_to and course_shift_group_to.course_key - key_from = key(course_shift_group_from) - key_to = key(course_shift_group_to) if course_shift_group_from and course_shift_group_to: if str(key_from) != str(key_to): raise ValueError("Course groups have different course_key's: '{}' and '{}'".format( str(key_from), str(key_to) ) ) - current_course_key = key_from or key_to membership = cls.get_user_membership(user, current_course_key) membership_group = membership and membership.course_shift_group @@ -212,6 +212,11 @@ def _push_delete_from_group(cls, user, course_shift_group): def save(self, *args, **kwargs): if self.pk: raise ValueError("CourseShiftGroupMembership can't be changed, only deleted") + current_membership = self.get_user_membership(self.user, self.course_key) + if current_membership: + raise ValueError("User already has membership for this course: {}".format( + str(current_membership) + )) save_result = super(CourseShiftGroupMembership, self).save(*args, **kwargs) log.info("User '{}' is enrolled in shift '{}'".format( self.user.username, diff --git a/tests/test_shifts.py b/tests/test_shifts.py index 0f7deff..71a0bea 100644 --- a/tests/test_shifts.py +++ b/tests/test_shifts.py @@ -18,7 +18,7 @@ def date_shifted(days): @attr(shard=2) -class TestCourseShifts(ModuleStoreTestCase): +class TestCourseShiftGroup(ModuleStoreTestCase): """ Test the course shifts feature """ @@ -28,222 +28,296 @@ def setUp(self): """ Make sure that course is reloaded every time--clear out the modulestore. """ - super(TestCourseShifts, self).setUp() + super(TestCourseShiftGroup, self).setUp() date = datetime.datetime.now() self.course = ToyCourseFactory.create(start=date) self.course_key = self.course.id - def date_shifted(self, shift): - return (datetime.datetime.now() + datetime.timedelta(days=shift)).date() - - def test_shift_group_creation(self): + def _no_groups_check(self): """ - Tests shifts groups creation and .get_course_shifts method. - Valid scenarios. + Checks that there is no groups. + Used at start and anywhere needed """ - groups = CourseShiftGroup.get_course_shifts(self.course_key) + groups = CourseUserGroup.objects.filter(course_id=self.course_key) self.assertTrue( len(groups) == 0, - "Course has shift groups at creation" - ) - - test_shift_group, created = CourseShiftGroup.create("test_shift_group", self.course_key) - groups = CourseShiftGroup.get_course_shifts(self.course_key) - self.assertTrue( - len(groups) == 1, - "Course has {} shifts, must have 1".format(len(groups)) + "Course has user groups at start" ) + shift_groups = CourseShiftGroup.get_course_shifts(self.course_key) self.assertTrue( - test_shift_group in groups, - "Created group is not in course shifts:'{}' not in '{}'".format( - str(test_shift_group),(str(groups)) - ) + len(shift_groups) == 0, + "Course has shift groups at start" ) - test_shift_group.delete() - groups = CourseShiftGroup.get_course_shifts(self.course_key) - self.assertTrue( - len(groups) == 0, - "Course has shift groups after group deletion" - ) + def _delete_all_shifts(self, key=None): + if not key: + key = self.course_key + shift_groups = CourseShiftGroup.get_course_shifts(key) + for x in shift_groups: + x.delete() - def test_shift_group_deletion(self): + def test_creates_cug(self): """ - Tests shifts groups deletion and .get_course_shifts method. - Valid scenarios. + Checks that CourseUserGroup is created when CSG created """ + self._no_groups_check() - # create shift, check user - test_shift_group, created = CourseShiftGroup.create("test_shift_group", self.course_key) - course_user_groups = CourseUserGroup.objects.all() - self.assertTrue( - len(course_user_groups) == 1, - "Group was not created: {}".format(str(course_user_groups)) - ) + name = "test_shift_group" + test_shift_group, created = CourseShiftGroup.create(name, self.course_key) - # delete user, check shift - test_shift_group.course_user_group.delete() - course_shift_groups = CourseShiftGroup.get_course_shifts(self.course_key) + groups = CourseUserGroup.objects.filter(course_id=self.course_key) + correct = len(groups) == 1 and groups.first().name == name + self.assertTrue(correct, "Should be only 'test_shift_group' user group, found:{}".format( + str([x.name for x in groups]) + )) + + shift_groups = CourseShiftGroup.get_course_shifts(self.course_key) + correct = len(shift_groups) == 1 and test_shift_group in shift_groups self.assertTrue( - len(course_shift_groups) == 0, - "More than zero course shift groups after deletion: {}".format(str(course_shift_groups)) - ) + correct, + "Should be only {}, found:{}".format( + str(test_shift_group), + str(shift_groups) + )) + + self._delete_all_shifts() - # create shift, delete shift, check user + def test_deletes_cug(self): + """ + Checks that CourseUserGroup us deleted hen CSG deleted + """ + self._no_groups_check() test_shift_group, created = CourseShiftGroup.create("test_shift_group", self.course_key) test_shift_group.delete() - course_user_groups = CourseUserGroup.objects.all() - self.assertTrue( - len(course_user_groups) == 0, - "Group was not deleted: {}".format(str(course_user_groups)) - ) + self._no_groups_check() + + def test_deleted_by_cug_delete(self): + """ + Checks that CourseShiftGroup is deleted when CourseUserGroup is deleted + """ + self._no_groups_check() + test_shift_group, created = CourseShiftGroup.create("test_shift_group", self.course_key) + test_shift_group.course_user_group.delete() + self._no_groups_check() - def test_shift_creation_errors(self): + def test_create_same_course_and_date_error(self): """ - Tests behavior of CourseShiftGroup.create in case of - incorrect course_key, name conflict + Checks that error raised for CSG creation with same course_key and + start_date, BUT DIFFERENT name """ + self._no_groups_check() test_shift_group, created = CourseShiftGroup.create("test_shift_group", self.course_key) with self.assertRaises(IntegrityError) as context_manager: test_shift_group2, created = CourseShiftGroup.create("test_shift_group2", self.course_key) + self._delete_all_shifts() - # when try to create group shift with same (name, key, date) already exists we get that old shift - test_shift_group_same, created = CourseShiftGroup.create("test_shift_group", self.course_key) - self.assertFalse(created) - self.assertTrue(test_shift_group.pk == test_shift_group_same.pk) - test_shift_group.delete() - - def test_membership_creation(self): + def test_create_same_course_dif_date_ok(self): """ - Tests shifts membership creation and deletion. - Valid scenarios only. + Checks that error NOT raised for CSG creation with same course_key + but different date """ + self._no_groups_check() test_shift_group, created = CourseShiftGroup.create("test_shift_group", self.course_key) - user = UserFactory(username="test", email="a@b.com") + test_shift_group2, created = CourseShiftGroup.create("test_shift_group2", self.course_key, + start_date=date_shifted(1)) + groups = CourseShiftGroup.get_course_shifts(self.course_key) + correct = test_shift_group2 in groups and \ + test_shift_group in groups and \ + len(groups) == 2 - CourseShiftGroupMembership.transfer_user(user, None, test_shift_group) - self.assertTrue(user in test_shift_group.users.all()) + self.assertTrue(correct, "Should be test_shift_group and test_shift_group2, found:{}".format( + str(groups) + )) + self._delete_all_shifts() - CourseShiftGroupMembership.transfer_user(user, test_shift_group, None) - self.assertTrue(len(test_shift_group.users.all()) == 0) + def test_create_same_course_and_date_copy(self): + """ + Checks that copy returned for CSG creation with same course_key and + start_date, AND SAME name + """ + self._no_groups_check() + name = "test_shift_group" + test_shift_group, created = CourseShiftGroup.create(name, self.course_key) + test_shift_group2, created2 = CourseShiftGroup.create(name, self.course_key) - date = datetime.datetime.now() + datetime.timedelta(days=7) - test_shift_group2, created = CourseShiftGroup.create("test_shift_group2", self.course_key, start_date=date) - CourseShiftGroupMembership.transfer_user(user, None, test_shift_group2) - CourseShiftGroupMembership.transfer_user(user, test_shift_group2, test_shift_group) + self.assertFalse(created2, "shift groups should be same: {}".format( + str(test_shift_group), + str(test_shift_group2) + )) + self._delete_all_shifts() + + def test_same_name_different_date_error(self): + """ + Checks that error raised for CSG creation with same course_key and name, + but different start_date + """ + self._no_groups_check() + name = "test_shift_group" + test_shift_group, created = CourseShiftGroup.create(name, self.course_key) + with self.assertRaises(IntegrityError) as context_manager: + test_shift_group2, created2 = CourseShiftGroup.create(name, self.course_key, start_date=date_shifted(1)) + self._delete_all_shifts() - self.assertTrue( - (user in test_shift_group.users.all()), - "User wasn't transfered:{}".format(str(CourseShiftGroupMembership.objects.all())) - ) - self.assertTrue( - (len(test_shift_group2.users.all())==0), - "test_shift_group2 is not empty:{}".format(str(test_shift_group2.users.all())) - ) - test_shift_group.delete() - test_shift_group2.delete() - def test_membership_errors(self): +@attr(shard=2) +class TestCourseShiftGroupMembership(ModuleStoreTestCase): + MODULESTORE = TEST_DATA_MIXED_MODULESTORE + + def setUp(self): """ - Tests transfer_user method versus wrong shift groups + Make sure that course is reloaded every time--clear out the modulestore. """ - test_shift_group, created = CourseShiftGroup.create("test_shift_group", self.course_key) - test_shift_group2, created = CourseShiftGroup.create("test_shift_group2", self.course_key, - start_date=date_shifted(days=10)) + super(TestCourseShiftGroupMembership, self).setUp() + date = datetime.datetime.now() + self.course = ToyCourseFactory.create(start=date) + self.course_key = self.course.id + self.user = UserFactory(username="test", email="a@b.com") + self.group, created = CourseShiftGroup.create("test_shift_group", self.course_key) - user = UserFactory(username="test", email="a@b.com") - # user doesn't have shift, but transfer from test_shift_group - with self.assertRaises(ValueError) as context_manager: - CourseShiftGroupMembership.transfer_user(user, test_shift_group, test_shift_group2) - message_list = ["User's membership is", "test_shift_group", "not"] - message_right = list(x in str(context_manager.exception) for x in message_list) - self.assertTrue(all(message_right), "Message:{}".format(str(context_manager.exception), message_right)) + self.second_course = ToyCourseFactory.create(org="neworg") + self.second_course_key = self.second_course.id - # user doesn't have shift, but transfer from None - with self.assertRaises(ValueError) as context_manager: - CourseShiftGroupMembership.transfer_user(user, test_shift_group, None) - message_list = ["User's membership is", "None", "not"] - message_right = list(x in str(context_manager.exception) for x in message_list) - self.assertTrue(all(message_right), "Message:{}".format(str(context_manager.exception), message_right)) + def _delete_all_memberships(self): + memberships = CourseShiftGroupMembership.objects.all() + for m in memberships: + m.delete() - CourseShiftGroupMembership.transfer_user(user, None, test_shift_group) + def _check_no_memberships(self): + mems = CourseShiftGroupMembership.objects.all() + self.assertTrue(len(mems) == 0) - # user has shift test_shift_group, but transfer from test_shift_group2 - with self.assertRaises(ValueError) as context_manager: - CourseShiftGroupMembership.transfer_user(user, test_shift_group2, test_shift_group) - message_list = ["User's membership is", "test_shift_group", "not"] - message_right = list(x in str(context_manager.exception) for x in message_list) - self.assertTrue(all(message_right), "Message:{}".format(str(context_manager.exception), message_right)) + def test_membership_creation(self): + """ + Tests shifts transfer to group pushes user to CourseShiftGroup + """ + membership = CourseShiftGroupMembership.transfer_user(self.user, None, self.group) + self.assertTrue(self.user in self.group.users.all()) + self._delete_all_memberships() + + def test_membership_deletion(self): + """ + Tests membership deletion and transfer to None removes user from Group + """ + membership = CourseShiftGroupMembership.transfer_user(self.user, None, self.group) + membership.delete() + self.assertTrue(len(self.group.users.all()) == 0) - fake_key = SlashSeparatedCourseKey('a', 'b', 'c') - fake_shift_group, created = CourseShiftGroup.create("fake_shift_group", fake_key) + membership = CourseShiftGroupMembership.transfer_user(self.user, None, self.group) + CourseShiftGroupMembership.transfer_user(self.user, self.group, None) + self.assertTrue(len(self.group.users.all()) == 0) - # transfer from one course to other + def test_membership_course_user_unique(self): + """ + Tests that there can't be two membership for user in same course_key + """ + group2, created = CourseShiftGroup.create("test_shift_group2", self.course_key, + start_date=date_shifted(1)) + CourseShiftGroupMembership.transfer_user(self.user, None, self.group) with self.assertRaises(ValueError) as context_manager: - CourseShiftGroupMembership.transfer_user(user, test_shift_group, fake_shift_group) - message_right = list(x in str(context_manager.exception) for x in ["Course groups have different course_key"]) - self.assertTrue(all(message_right), "Message:{}".format(str(context_manager.exception), message_right)) + CourseShiftGroupMembership.objects.create(user=self.user, course_shift_group=group2) + group2.delete() + self._delete_all_memberships() - test_shift_group.delete() - test_shift_group2.delete() - fake_shift_group.delete() + def test_user_membership_two_courses(self): + """ + Tests that user can have two memberships in two different courses + """ + group2, created = CourseShiftGroup.create("test_shift_group", self.second_course_key) + membership = CourseShiftGroupMembership.transfer_user(self.user, None, self.group) + membership2 = CourseShiftGroupMembership.transfer_user(self.user, None, group2) + mems = CourseShiftGroupMembership.objects.all() + self.assertTrue(len(mems) == 2, "Must be 2 memberships, found: {}".format( + str(mems) + )) + self._delete_all_memberships() - def test_several_courses_conflicts(self): - """Tests several memberships in different courses""" - second_course = ToyCourseFactory.create(org="neworg") - second_course_key = second_course.id + def test_two_users_for_course_membership(self): + """ + Tests that there can be two users in CourseShiftGroup + """ + user2 = UserFactory(username="test2", email="a2@b.com") + membership = CourseShiftGroupMembership.transfer_user(self.user, None, self.group) + membership = CourseShiftGroupMembership.transfer_user(user2, None, self.group) + mems = CourseShiftGroupMembership.objects.all() + self.assertTrue(len(mems) == 2, "Must be 2 memberships, found: {}".format( + str(mems) + )) + self._delete_all_memberships() - test_a_shift_group, created = CourseShiftGroup.create("test_A_shift_group", self.course_key) - test_a_shift_group2, created = CourseShiftGroup.create("test_A_shift_group2", self.course_key, - start_date=date_shifted(days=10)) + def test_membership_unchangable(self): + """ + Tests that membership can't be changed + """ + membership = CourseShiftGroupMembership.transfer_user(self.user, None, self.group) + group2, created = CourseShiftGroup.create("test_shift_group2", self.second_course_key) + membership.course_shift_group = group2 + with self.assertRaises(ValueError): + membership.save() + group2.delete() + self._delete_all_memberships() - test_b_shift_group, created = CourseShiftGroup.create("test_B_shift_group", second_course_key) - test_b_shift_group2, created = CourseShiftGroup.create("test_B_shift_group2", second_course_key, - start_date=date_shifted(days=10)) + def test_membership_transfer_valid(self): + """ + Tests transfer from None, to shift group from the same course, to None + """ + self.assertTrue(len(self.group.users.all()) == 0) - user = UserFactory(username="test", email="a@b.com") + membership = CourseShiftGroupMembership.transfer_user(self.user, None, self.group) + self.assertTrue(self.user in self.group.users.all()) - membership_a = CourseShiftGroupMembership.get_user_membership(user, course_key=self.course_key) - membership_b = CourseShiftGroupMembership.get_user_membership(user, course_key=second_course_key) - self.assertTrue(membership_a is None, "User's membership:{}, should be None".format(str(membership_a))) - self.assertTrue(membership_b is None, "User's membership:{}, should be None".format(str(membership_b))) + group2, created = CourseShiftGroup.create("test_shift_group2", self.course_key, start_date=date_shifted(1)) + membership = CourseShiftGroupMembership.transfer_user(self.user, self.group, group2) + self.assertTrue(self.user in group2.users.all()) + self.assertTrue(len(self.group.users.all()) == 0) - CourseShiftGroupMembership.transfer_user(user, None, test_a_shift_group) - CourseShiftGroupMembership.transfer_user(user, None, test_b_shift_group) - membership_a = CourseShiftGroupMembership.get_user_membership(user, course_key=self.course_key) - membership_b = CourseShiftGroupMembership.get_user_membership(user, course_key=second_course_key) - group_1 = membership_a and membership_a.course_shift_group - group_2 = membership_b and membership_b.course_shift_group - self.assertTrue(group_1 == test_a_shift_group, "User's membership {}, should be {}".format( - str(group_1), - str(test_a_shift_group) - )) - self.assertTrue(group_2 == test_b_shift_group, "User's membership {}, should be {}".format( - str(group_1), - str(test_b_shift_group) - )) + membership = CourseShiftGroupMembership.transfer_user(self.user, group2, None) + self.assertTrue(len(self.group.users.all()) == 0) + self.assertTrue(len(group2.users.all()) == 0) + group2.delete() - CourseShiftGroupMembership.transfer_user(user, test_a_shift_group, test_a_shift_group2) - CourseShiftGroupMembership.transfer_user(user, test_b_shift_group, test_b_shift_group2) - membership_a = CourseShiftGroupMembership.get_user_membership(user, course_key=self.course_key) - membership_b = CourseShiftGroupMembership.get_user_membership(user, course_key=second_course_key) - group_1 = membership_a and membership_a.course_shift_group - group_2 = membership_b and membership_b.course_shift_group - self.assertTrue(group_1 == test_a_shift_group2, "User's membership {}, should be {}".format( - str(group_1), - str(test_a_shift_group2) - )) - self.assertTrue(group_2 == test_b_shift_group2, "User's membership {}, should be {}".format( - str(group_1), - str(test_b_shift_group2) - )) - CourseShiftGroupMembership.transfer_user(user, test_a_shift_group2, None) - CourseShiftGroupMembership.transfer_user(user, test_b_shift_group2, None) - test_a_shift_group.delete() - test_a_shift_group2.delete() - test_b_shift_group.delete() - test_b_shift_group2.delete() + def test_transfer_intercourse_error(self): + """ + Tests user can't be transfered between to the shift from + the different course + """ + group2, created = CourseShiftGroup.create("test_shift_group2", self.second_course_key) + membership = CourseShiftGroupMembership.transfer_user(self.user, None, self.group) + with self.assertRaises(ValueError): + membership = CourseShiftGroupMembership.transfer_user(self.user, self.group, group2) + group2.delete() + + def test_transfer_from_error(self): + """ + Tests that transfer raises error when shift_from is incorrect + """ + group2, created = CourseShiftGroup.create("test_shift_group2", self.course_key, start_date=date_shifted(1)) + + with self.assertRaises(ValueError) as context_manager: + membership = CourseShiftGroupMembership.transfer_user(self.user, self.group, group2) + message_list = ["User's membership is", "None", "not"] + message_right = list(x in str(context_manager.exception) for x in message_list) + self.assertTrue(all(message_right), "Message:{}".format(str(context_manager.exception))) + + membership = CourseShiftGroupMembership.transfer_user(self.user, None, self.group) + + with self.assertRaises(ValueError) as context_manager: + membership = CourseShiftGroupMembership.transfer_user(self.user, group2, None) + message_list = ["User's membership is", "test_shift_group2", "test_shift_group", "not"] + message_right = list(x in str(context_manager.exception) for x in message_list) + self.assertTrue(all(message_right), "Message:{}".format(str(context_manager.exception))) + + self._delete_all_memberships() + + def test_get_user_membership(self): + membership = CourseShiftGroupMembership.get_user_membership(self.user, self.course_key) + self.assertIsNone(membership) + + CourseShiftGroupMembership.transfer_user(self.user, None, self.group) + membership = CourseShiftGroupMembership.get_user_membership(self.user, self.course_key) + self.assertTrue(membership.course_shift_group == self.group, "Membershift group:{}".format( + str(membership.course_shift_group) + )) @attr(shard=2) @@ -277,7 +351,8 @@ def test_settings_generation_and_saving(self): """ Tests that settings got by get_course_settings saved correctly """ - settings = self._settings_setup() + self._settings_setup() + settings = CourseShiftSettings.get_course_settings(self.course_key) self.assertTrue(settings.is_shift_enabled == True) self.assertTrue(settings.is_autostart == True) From 8591f0ad86a74ce9de8075e179c3a4b3340ca562 Mon Sep 17 00:00:00 2001 From: Boris Zimka Date: Sun, 17 Sep 2017 19:36:20 +0000 Subject: [PATCH 11/35] Manager and tests refactoring 2 --- manager.py | 93 +++++++++------- models.py | 178 ++++++++++++------------------ tests/test_shifts.py | 250 +++++++++++++++++++------------------------ 3 files changed, 229 insertions(+), 292 deletions(-) diff --git a/manager.py b/manager.py index cee79da..416eaa5 100644 --- a/manager.py +++ b/manager.py @@ -23,8 +23,8 @@ def __init__(self, course_key): @property def is_shift_enabled(self): return self.settings.is_shift_enabled - - def get_user_course_shift(self, user, course_key): + + def get_user_shift(self, user, course_key): """ Returns user's shift group for given course. """ @@ -35,71 +35,84 @@ def get_user_course_shift(self, user, course_key): if membership: return membership.course_shift_group - def get_active_shifts(self, date_threshold=None): + def get_all_shifts(self): + return CourseShiftGroup.get_course_shifts(self.course_key) + + def get_active_shifts(self, user=None): """ - Returns shifts that are are active at this moment according to the settings. - date_threshold add additional filter for shifts start date against threshold - (e.g. for user switching shift to the newer shift) + Returns shifts that are are active at this moment according to the settings, + i.e. enrollment have started but haven't finished yet. + If user is given and he has membership all started shifts are considered + as active """ if not self.settings.is_shift_enabled: return [] - current_date = date_now() all_shifts = CourseShiftGroup.get_course_shifts(self.course_key) if not all_shifts: return [] + now = date_now() active_shifts = [] + current_start_date = None + if user: + current_group = self.get_user_shift(user, self.course_key) + current_start_date = current_group and current_group.start_date + for shift in all_shifts: - enroll_finish = shift.start_date + timedelta(days=self.settings.enroll_after_days) enroll_start = shift.start_date - timedelta(days=self.settings.enroll_before_days) - if not(enroll_start < current_date < enroll_finish): - continue - if date_threshold and shift.start_date < date_threshold: - continue - active_shifts.append(shift) + enroll_finish = shift.start_date + timedelta(days=self.settings.enroll_after_days) + if current_start_date and current_start_date < shift.start_date: + enroll_finish = now + + if enroll_start < now <= enroll_finish: + active_shifts.append(shift) + return active_shifts - def sign_user_on_shift(self, user, shift_to, course_key, shift_from=None, forced=False, shift_up_only=True): + def sign_user_on_shift(self, user, shift, course_key): """ Transfers user to given shift group. User's enrollment is not checked because at course enrollment user should be firstly transfered to shift and only then enrolled on course. - If forced, then user unenrolled from current course shift automatically, - otherwise user mustn't have any current shift membership :param user: user to enroll on shift - :param shift_to: CourseShiftGroup to enroll - :param course_key: to which course shift_to (and shif_from if not None) belongs - :param forced: unenroll from current shift if shift_from is not given - :param shift_up_only: allow to change only on later shifts + :param shift: CourseShiftGroup to enroll + :param course_key: to which course shift_to (and shift_from if not None) belongs """ - if shift_to.course_key != course_key: + if shift.course_key != course_key: raise ValueError("Shift's course_key: '{}', given course_key:'{}'".format( - str(shift_to.course_key), - str(course_key) - )) - if shift_from and shift_from.course_key != course_key: - raise ValueError("Shift_from's course_key: '{}', given course_key:'{}'".format( - str(shift_from.course_key), + str(shift.course_key), str(course_key) )) membership = CourseShiftGroupMembership.get_user_membership(user=user, course_key=course_key) - group_from = membership and membership.course_shift_group - if group_from == shift_to: + shift_from = membership and membership.course_shift_group + if shift_from == shift: return membership - if not forced and group_from != shift_from: - raise ValueError("User's membership for given course is not None:{}".format(str(membership))) - - date_threshold = shift_from and shift_from.start_date - if not shift_up_only: - date_threshold = None - - active_shifts = self.get_active_shifts(date_threshold=date_threshold) - if shift_to not in active_shifts: + active_shifts = self.get_active_shifts(user) + if shift not in active_shifts: raise ValueError("Shift {} is not in active shifts: {}".format( - str(shift_to), + str(shift), str(active_shifts) )) - return CourseShiftGroupMembership.transfer_user(user, group_from, shift_to) + return CourseShiftGroupMembership.transfer_user(user, shift_from, shift) + + def create_shift(self, start_date): + """ + Creates plan with given start date. + """ + if not self.settings.is_shift_enabled: + return ValueError("Can't create shift: feature is turned off for course") + if self.settings.is_autostart: + raise ValueError("Can't create shift in autostart mode") + + name = self.settings.build_name(start_date) + days_shift = self.settings.calculate_days_add(start_date) + shift, created = CourseShiftGroup.create( + name=name, + course_key=self.course_key, + start_date=start_date, + days_shift=days_shift + ) + return shift \ No newline at end of file diff --git a/models.py b/models.py index 71d1430..23c5d9d 100644 --- a/models.py +++ b/models.py @@ -67,6 +67,25 @@ def get_shifted_due(self, user, block, name): )) return value + timedelta(days=self.days_shift) + def get_enrollment_limits(self, shift_settings=None): + """ + Return tuple of enrollment start and end dates + """ + if not shift_settings: + shift_settings = CourseShiftSettings.get_course_settings(self.course_key) + return ( + self.start_date - timedelta(days=shift_settings.enroll_before_days), + self.start_date + timedelta(days=shift_settings.enroll_after_days) + ) + + def is_enrollable_now(self, shift_settings=None): + if not shift_settings: + shift_settings = CourseShiftSettings.get_course_settings(self.course_key) + date_start, date_end = self.get_enrollment_limits(shift_settings) + if date_start < date_now() < date_end: + return True + return False + @classmethod def get_course_shifts(cls, course_key): """ @@ -261,7 +280,7 @@ class CourseShiftSettings(models.Model): help_text="Are groups generated automatically with period " "or according to the manually set plan") - autostart_period_days = models.IntegerField( + autostart_period_days = models.PositiveIntegerField( default=28, db_column='autostart_period_days', help_text="Number of days between new automatically generated shifts."\ @@ -269,20 +288,24 @@ class CourseShiftSettings(models.Model): null=True ) - enroll_before_days = models.IntegerField( + enroll_before_days = models.PositiveIntegerField( default=14, help_text="Days before shift start when student can enroll already."\ "E.g. if shift starts at 01/20/2020 and value is 5 then shift will be"\ "available from 01/15/2020." ) - enroll_after_days = models.IntegerField( + enroll_after_days = models.PositiveIntegerField( default=7, help_text="Days after shift start when student still can enroll." \ "E.g. if shift starts at 01/20/2020 and value is 10 then shift will be" \ "available till 01/20/2020" ) + def __init__(self, *args, **kwargs): + super(CourseShiftSettings, self).__init__(*args, **kwargs) + self._update_shifts_autostart() + @property def last_start_date(self): """ @@ -290,7 +313,7 @@ def last_start_date(self): """ shifts = CourseShiftGroup.get_course_shifts(self.course_key) if not shifts: - return self.course_start_date + return None return shifts[0].start_date @property @@ -307,125 +330,56 @@ def get_course_settings(cls, course_key): )) return current_settings - def update_shifts(self): + def build_name(self, **kwargs): """ - Checks current date and creates new shifts if necessary - according to the settings - :return: if new shifts were created - """ - plan = self.get_next_plan() - is_updated = False - while plan: - is_updated = True - name = self._naming(self.course_key, plan.start_date) - days_add = int((plan.start_date - self.course_start_date).days) - plan.launch_shift(name=name, days_add=days_add) - plan = self.get_next_plan() - if is_updated: - log.info( - "Shifts for course '{}' are updated".format(str(self.course_key)) - ) - return is_updated - - def create_plan(self, start_date, launch_plan=False): - """ - Creates plan with given start date. - There is no check that shift are in manual mode - """ - created, plan = CourseShiftPlannedRun.objects.get_or_create( - course_shift_settings=self, - start_date=start_date, - ) - return plan - - def get_next_plan(self): - """ - Returns closest CourseShiftPlannedRun or None if - feature is turned off or no plans available currently + :param start_date + Defines how should be shifts named """ - if not self.is_shift_enabled: - return None - if self.is_autostart: - plan = self._get_next_autostart_plan() - else: - plan = self._get_next_manual_plan() - return plan - - def _get_next_autostart_plan(self): - last_date = self.last_start_date - next_start_date = last_date + timedelta(days=self.autostart_period_days) - now_time = date_now() - if next_start_date > now_time: - return None - return CourseShiftPlannedRun.get_mocked_plan(self, next_start_date) + date = kwargs.get("start_date") + return "shift_{}_{}".format(str(self.course_key), str(date)) - def _get_next_manual_plan(self): - course_shifts_plans = self.plans.all().order_by('start_date') - if not course_shifts_plans: - return False - return course_shifts_plans.first() + def calculate_days_add(self, start_date): + return int((start_date - self.course_start_date).days) - @classmethod - def _naming(cls, course_key, date): + def get_next_autostart_date(self): """ - Defines how should be shifts named + In autostart mode returns date when next shift starts + In manual mode returns None """ - return "shift_{}_{}".format(str(course_key), str(date)) - - -class CourseShiftPlannedRun(models.Model): - """ - Represents planned shift for course. - Plan can be launched, then it creates the shift and disappears. - For 'autostart' mode in settings mocked plans can be created: - they can be launched, but they are not stored in db and don't hit - it at plan deletion. - """ - course_shift_settings = models.ForeignKey( - CourseShiftSettings, - related_name="plans") - start_date = models.DateField(default=timezone.now) - - class Meta: - unique_together = ('course_shift_settings', 'start_date',) - - MOCKING_FLAG = "mocking_flag" + if not self.is_autostart: + return + if not self.last_start_date: + return self.course_start_date + return self.last_start_date + timedelta(days=self.autostart_period_days) - @classmethod - def get_mocked_plan(cls, settings, start_date): + def _calculate_launch_date(self, start_date): """ - Returns mocked plan for autostart mode. It can be launched, - but doesn't hit database at deletion + Returns date when shift with given start date + should be launched. Now it is created at the moment of + enrollment start, but it can be changed in future """ - mock = cls(course_shift_settings=settings, start_date=start_date) - setattr(mock, cls.MOCKING_FLAG, True) - mock.delete = lambda: None - mock.save = lambda: None - return mock + return start_date - timedelta(days=self.enroll_before_days) - @classmethod - def get_course_plans(cls, course_key): - return cls.objects.filter(course_shift_settings__course_key=course_key) - - def launch_shift(self, name, days_add): + def _update_shifts_autostart(self): """ - Launches shift according to plan and then self-destructs + Creates new shifts if required by autostart settings """ - - shift, created = CourseShiftGroup.create( - course_key=self.course_shift_settings.course_key, - name=name, - days_shift=days_add, - start_date=self.start_date - ) - log.info( - "Shift plan {} is launched as shift {}".format( - str(self), - str(shift) + start_date = self.get_next_autostart_date() + if not start_date: + return + launch_date = self._calculate_launch_date(start_date) + while launch_date < date_now(): + name = self.build_name(start_date=start_date) + days_shift = self.calculate_days_add(start_date=start_date) + + group, created = CourseShiftGroup.create( + name=name, + start_date=start_date, + days_shift=days_shift, + course_key=self.course_key ) - ) - self.delete() - return shift + if created: + log.info("Shift {} automatically created".format(str(group))) + start_date = self.get_next_autostart_date() + launch_date = self._calculate_launch_date(start_date) - def __unicode__(self): - return u"{} for {}".format(str(self.start_date), str(self.course_shift_settings.course_key)) diff --git a/tests/test_shifts.py b/tests/test_shifts.py index 71a0bea..1b1aa65 100644 --- a/tests/test_shifts.py +++ b/tests/test_shifts.py @@ -10,7 +10,7 @@ from xmodule.modulestore.tests.django_utils import TEST_DATA_MIXED_MODULESTORE, ModuleStoreTestCase from xmodule.modulestore.tests.factories import ToyCourseFactory from django.db import IntegrityError -from ..models import CourseShiftGroup, CourseShiftGroupMembership, CourseUserGroup, CourseShiftSettings, CourseShiftPlannedRun +from ..models import CourseShiftGroup, CourseShiftGroupMembership, CourseUserGroup, CourseShiftSettings from ..manager import CourseShiftManager def date_shifted(days): @@ -326,160 +326,121 @@ class TestCourseShiftSettings(ModuleStoreTestCase): Test the course shifts settings """ MODULESTORE = TEST_DATA_MIXED_MODULESTORE + _ENROLL_BEFORE = 7 + _ENROLL_AFTER = 0 + _PERIOD = 20 + _COURSE_DATE_START = 14 def setUp(self): """ Make sure that course is reloaded every time--clear out the modulestore. """ super(TestCourseShiftSettings, self).setUp() - date = datetime.datetime.now() - datetime.timedelta(days=14) + date = datetime.datetime.now() - datetime.timedelta(days=self._COURSE_DATE_START) self.course = ToyCourseFactory.create(start=date) self.course_key = self.course.id - def _settings_setup(self, period=10, autostart=True): + def _settings_setup(self, period=_PERIOD, autostart=False): """ Not included into setUp because should be tests """ settings = CourseShiftSettings.get_course_settings(self.course_key) settings.is_shift_enabled = True + settings.enroll_before_days = 0 + settings.enroll_before_days = 7 settings.is_autostart = autostart settings.autostart_period_days = period settings.save() - return settings - - def test_settings_generation_and_saving(self): - """ - Tests that settings got by get_course_settings saved correctly - """ - self._settings_setup() - settings = CourseShiftSettings.get_course_settings(self.course_key) - - self.assertTrue(settings.is_shift_enabled == True) - self.assertTrue(settings.is_autostart == True) - self.assertTrue(settings.autostart_period_days == 10) - settings.delete() - - def test_autostart_generation_single(self): - """ - Single shift must be generated automatically - """ - settings = self._settings_setup(period=9) - course_shifts = CourseShiftGroup.get_course_shifts(self.course_key) - self.assertTrue(len(course_shifts) == 0, "There are course shifts at start:{}".format(str(course_shifts))) + return - settings.update_shifts() - course_shifts = CourseShiftGroup.get_course_shifts(self.course_key) - self.assertTrue(len(course_shifts) == 1, "Must be single shift, found:{}".format(str(course_shifts))) - for x in course_shifts: + def _delete_groups(self): + shift_groups = CourseShiftGroup.objects.all() + for x in shift_groups: x.delete() - def test_autostart_generation_three(self): + def _number_of_shifts(self, custom_period): """ - Three shifts must be generated automatically + Calculates how many shifts should be in + current settings """ - settings = self._settings_setup(period=4) - course_shifts = CourseShiftGroup.get_course_shifts(self.course_key) - self.assertTrue(len(course_shifts) == 0, "There are course shifts at start:{}".format(str(course_shifts))) + course_started_days_ago = self._COURSE_DATE_START + # enroll_before effectively shifts start date here + # E.g. course started 15.01, period is 10, enroll_before is 5 + # First shift is created immediately,second is created at 20.01, + # next one is created at 30.01. - settings.update_shifts() - course_shifts = CourseShiftGroup.get_course_shifts(self.course_key) - self.assertTrue(len(course_shifts) == 3, "Must be 3 shifts, found:{}".format(str(course_shifts))) - for x in course_shifts: - x.delete() + course_started_days_ago += self._ENROLL_BEFORE + shifts_number = int(course_started_days_ago / custom_period) + shifts_number += 1 + return shifts_number - def test_autostart_generation_zero(self): + def _no_groups_check(self): """ - Autostart but no shift should be generated. + Checks that there is no groups. + Used at start and anywhere needed """ - settings = self._settings_setup(period=30) - course_shifts = CourseShiftGroup.get_course_shifts(self.course_key) - self.assertTrue(len(course_shifts) == 0, "There are course shifts at start:{}".format(str(course_shifts))) - - settings.update_shifts() - course_shifts = CourseShiftGroup.get_course_shifts(self.course_key) - self.assertTrue(len(course_shifts) == 0, "Must be 0 shifts, found: {}".format(str(course_shifts))) + shift_groups = CourseShiftGroup.get_course_shifts(self.course_key) + self.assertTrue( + len(shift_groups) == 0, + "Course has shift groups at start:{}".format(shift_groups) + ) - def test_plan_generation(self): + def test_settings_generation_and_saving(self): """ - Tests that plans are generated correctly + Tests that settings got by get_course_settings saved correctly """ - settings = self._settings_setup(autostart=False) - plans = CourseShiftPlannedRun.get_course_plans(self.course_key) - self.assertTrue(len(plans) == 0, "There are shift plans at start:{}".format(str(plans))) - - settings.create_plan(date_shifted(-3)) - plans = CourseShiftPlannedRun.get_course_plans(self.course_key) - self.assertTrue(len(plans) == 1, "Must be single plan, found:{}".format(str(plans))) - plans[0].delete() - - def test_plan_launch(self): - settings = self._settings_setup(autostart=False) - - plans = CourseShiftPlannedRun.get_course_plans(self.course_key) - self.assertTrue(len(plans) == 0, "There are shift plans at start:{}".format(str(plans))) - - course_shifts = CourseShiftGroup.get_course_shifts(self.course_key) - self.assertTrue(len(course_shifts) == 0, "There are course shifts at start:{}".format(str(course_shifts))) - - settings.create_plan(date_shifted(-3)) - next_plan = settings.get_next_plan() - self.assertTrue(next_plan, "Plan is :{}".format(str(next_plan))) - next_plan.launch_shift(name="doesnt_matter", days_add=7) - - plans = CourseShiftPlannedRun.get_course_plans(self.course_key) - self.assertTrue(len(plans) == 0, "Shouldn't be any plans, found:{}".format(str(plans))) + self._settings_setup(autostart=False) + settings = CourseShiftSettings.get_course_settings(self.course_key) - course_shifts = CourseShiftGroup.get_course_shifts(self.course_key) - self.assertTrue(len(course_shifts) == 1, "Must be single shift, found:{}".format(str(course_shifts))) + self.assertTrue(settings.is_shift_enabled == True) + self.assertTrue(settings.is_autostart == False) + self.assertTrue(settings.autostart_period_days == self._PERIOD) + settings.delete() + self._delete_groups() - def test_manual_generation_zero(self): + def test_autostart_generation_one(self): """ - Tests manually preset plans. - Test with zero planned runs + Single start should be generated - default shift at start """ - settings = self._settings_setup(autostart=False) - course_shifts = CourseShiftGroup.get_course_shifts(self.course_key) - self.assertTrue(len(course_shifts) == 0, "There are course shifts at start:{}".format(str(course_shifts))) - - settings.update_shifts() + self._no_groups_check() + custom_period = 30 + self._settings_setup(period=custom_period, autostart=True) + settings = CourseShiftSettings.get_course_settings(self.course_key) course_shifts = CourseShiftGroup.get_course_shifts(self.course_key) - mes = "Course shifts shouldn't be generated, found:{}".format(str(course_shifts)) - self.assertTrue(len(course_shifts) == 0, mes) + shifts_number = self._number_of_shifts(custom_period) + self.assertTrue(len(course_shifts) == shifts_number, "Must be {} shift, found: {}".format(shifts_number, str(course_shifts))) + self._delete_groups() - def test_manual_generation_one(self): + def test_autostart_generation_two(self): """ - Tests manually preset plans. - Test with single planned run + Two shifts must be generated automatically, default and one more """ - settings = self._settings_setup(autostart=False) - settings.create_plan(start_date=date_shifted(-2)) - - course_shifts = CourseShiftGroup.get_course_shifts(self.course_key) - self.assertTrue(len(course_shifts) == 0, "There are course shifts at start:{}".format(str(course_shifts))) - - settings.update_shifts() - + self._no_groups_check() + custom_period = 12 + self._settings_setup(period=custom_period, autostart=True) + shifts_number = self._number_of_shifts(custom_period) + settings = CourseShiftSettings.get_course_settings(self.course_key) course_shifts = CourseShiftGroup.get_course_shifts(self.course_key) - self.assertTrue(len(course_shifts) == 1, "Must be single shift, found:{}".format(str(course_shifts))) + self.assertTrue(len(course_shifts) == shifts_number, "Must be {} shifts, found:{}".format( + shifts_number, + str(course_shifts))) + self._delete_groups() - def test_manual_generation_three(self): + def test_autostart_generation_three(self): """ - Tests manually preset plans. - Test with three planned runs + Three shifts must be generated automatically """ - settings = self._settings_setup(autostart=False) - settings.create_plan(start_date=date_shifted(-6)) - settings.create_plan(start_date=date_shifted(-4)) - settings.create_plan(start_date=date_shifted(-2)) - - course_shifts = CourseShiftGroup.get_course_shifts(self.course_key) - self.assertTrue(len(course_shifts) == 0, "There are course shifts at start:{}".format(str(course_shifts))) - - settings.update_shifts() - + self._no_groups_check() + custom_period = 8 + self._settings_setup(period=custom_period, autostart=True) + shifts_number = self._number_of_shifts(custom_period) + settings = CourseShiftSettings.get_course_settings(self.course_key) course_shifts = CourseShiftGroup.get_course_shifts(self.course_key) - self.assertTrue(len(course_shifts) == 3, "Must be single shift, found:{}".format(str(course_shifts))) + self.assertTrue(len(course_shifts) == shifts_number, "Must be {} shifts, found:{}".format( + shifts_number, + str(course_shifts))) + self._delete_groups() @attr(shard=2) @@ -493,37 +454,57 @@ def setUp(self): self.shift_settings.is_shift_enabled = True self.shift_settings.save() + def _delete_groups(self): + for x in CourseShiftGroup.objects.all(): + x.delete() + + def _no_groups_check(self): + """ + Checks that there is no groups. + Used at start and anywhere needed + """ + shift_groups = CourseShiftGroup.get_course_shifts(self.course_key) + self.assertTrue( + len(shift_groups) == 0, + "Course has shift groups at start" + ) + def test_get_user_course_shift(self): """ Tests method get_user_course_shift """ user = UserFactory(username="test", email="a@b.com") shift_manager = CourseShiftManager(course_key=self.course_key) - shift_group = shift_manager.get_user_course_shift(user, self.course_key) - self.assertTrue(shift_group is None, "User shift group is {}, should be None".format(str(shift_group))) + shift_group = shift_manager.get_user_shift(user, self.course_key) + self.assertFalse(shift_group, "User shift group is {}, should be None".format(str(shift_group))) test_a_shift_group, created = CourseShiftGroup.create("test_shift_group", self.course_key) CourseShiftGroupMembership.transfer_user(user, None, test_a_shift_group) - shift_group = shift_manager.get_user_course_shift(user, self.course_key) + shift_group = shift_manager.get_user_shift(user, self.course_key) self.assertTrue(shift_group==test_a_shift_group, "User shift group is {}, should be {}".format( str(shift_group), str(test_a_shift_group) )) + self._delete_groups() + + def test_get_user_course_shift_disabled(self): + user = UserFactory(username="test", email="a@b.com") + test_a_shift_group, created = CourseShiftGroup.create("test_shift_group", self.course_key) + CourseShiftGroupMembership.transfer_user(user, None, test_a_shift_group) self.shift_settings.is_shift_enabled = False self.shift_settings.save() shift_manager = CourseShiftManager(self.course_key) - shift_group = shift_manager.get_user_course_shift(user, self.course_key) + shift_group = shift_manager.get_user_shift(user, self.course_key) self.assertTrue(shift_group is None, "User shift group is {}, should be None".format(str(shift_group))) self.shift_settings.is_shift_enabled = True self.shift_settings.save() - CourseShiftGroupMembership.transfer_user(user, test_a_shift_group, None) - test_a_shift_group.delete() + self._delete_groups() def test_get_active_shifts(self): """ - Tests method get_active_shifts + Tests method get_active_shifts without user """ shift_manager = CourseShiftManager(self.course_key) course_shifts = shift_manager.get_active_shifts() @@ -539,15 +520,7 @@ def test_get_active_shifts(self): str(group2), str(course_shifts) )) - - course_shifts = shift_manager.get_active_shifts(date_threshold=date_shifted(-2)) - correct = (group1 in course_shifts) and (len(course_shifts) == 1) - self.assertTrue(correct, "Shifts should be {}, found {}".format( - str(group1), - str(course_shifts) - )) - group1.delete() - group2.delete() + self._delete_groups() def test_sign_user_on_shift_valid(self): """ @@ -556,14 +529,14 @@ def test_sign_user_on_shift_valid(self): """ user = UserFactory(username="test", email="a@b.com") shift_manager = CourseShiftManager(course_key=self.course_key) - shift_group = shift_manager.get_user_course_shift(user, self.course_key) + shift_group = shift_manager.get_user_shift(user, self.course_key) self.assertTrue(shift_group is None, "User shift group is {}, should be None".format(str(shift_group))) group1, created = CourseShiftGroup.create("test_group", self.course_key) group2, created = CourseShiftGroup.create("test_group2", self.course_key, start_date=date_shifted(days=-5)) shift_manager.sign_user_on_shift(user, group1, self.course_key) - shift_group = shift_manager.get_user_course_shift(user, self.course_key) + shift_group = shift_manager.get_user_shift(user, self.course_key) self.assertTrue(shift_group == group1, "User shift group is {}, should be {}".format( str(shift_group), str(group1) @@ -571,19 +544,17 @@ def test_sign_user_on_shift_valid(self): shift_manager.sign_user_on_shift( user=user, - shift_to=group2, - shift_from=group1, course_key=self.course_key, - shift_up_only=False + shift=group2 ) - shift_group = shift_manager.get_user_course_shift(user, self.course_key) + shift_group = shift_manager.get_user_shift(user, self.course_key) self.assertTrue(shift_group == group2, "User shift group is {}, should be {}".format( str(shift_group), str(group2) )) - shift_manager.sign_user_on_shift(user, shift_to=group1, course_key=self.course_key, forced=True) - shift_group = shift_manager.get_user_course_shift(user, self.course_key) + shift_manager.sign_user_on_shift(user, shift=group1, course_key=self.course_key) + shift_group = shift_manager.get_user_shift(user, self.course_key) self.assertTrue(shift_group == group1, "User shift group is {}, should be {}".format( str(shift_group), str(group1) @@ -592,7 +563,7 @@ def test_sign_user_on_shift_valid(self): group1.delete() group2.delete() - def test_sign_user_on_shift_invalid(self): + def _test_sign_user_on_shift_invalid(self): """ Tests method sign_user_on_shift. Invalid scenarios @@ -602,7 +573,7 @@ def test_sign_user_on_shift_invalid(self): user = UserFactory(username="test", email="a@b.com") shift_manager = CourseShiftManager(course_key=self.course_key) - shift_group = shift_manager.get_user_course_shift(user, self.course_key) + shift_group = shift_manager.get_user_shift(user, self.course_key) self.assertTrue(shift_group is None, "User shift group is {}, should be None".format(str(shift_group))) group1, created = CourseShiftGroup.create("test_group", self.course_key) @@ -620,8 +591,7 @@ def test_sign_user_on_shift_invalid(self): with self.assertRaises(ValueError) as context_manager: shift_manager.sign_user_on_shift( user=user, - shift_from=group1, - shift_to=group_invalid, + shift=group2, course_key=second_course_key ) exception_msg_parts = ("Shift_from's course_key:", "given course_key:") From cbfae5599548cfb27354a68c874bf1af94fcd22f Mon Sep 17 00:00:00 2001 From: Boris Zimka Date: Tue, 19 Sep 2017 10:26:56 +0000 Subject: [PATCH 12/35] Manager and test refactoring 3 --- README.rst | 6 +- manager.py | 80 +++++---- models.py | 53 ++++-- tests/test_shifts.py | 397 +++++++++++++++++++++++++++++++++---------- 4 files changed, 398 insertions(+), 138 deletions(-) diff --git a/README.rst b/README.rst index 99a880d..c3c69cf 100644 --- a/README.rst +++ b/README.rst @@ -8,13 +8,13 @@ finish the course and get credit. There are several differences between this app and course rerun/CCX: -1. The content of the course is the same in all course shifts. Therefore it should be easier for staff to upgrade such course if necessary. It also doesn't spend additional resources. +1. The content of the course is the same in all course shifts. Therefore it should be easier for staff to upgrade such course if necessary. It also doesn't spend additional system resources. -2. Forum is shared between all course shifts. +2. Forum is shared between all course shifts. This can be useful when there are not so much students in each shift. 3. Students can use this function when they want, and therefore course schedule becomes more flexible. Details ------- Feature is implemented via additional FieldOverrideProvider and CourseUserGroups, similar to the way it's done for 'INDIVIDUAL_DUE_DATES' feature. -Every course student is associated with some CourseUserGroup, and provider checks for membership and shift due dates accordingly. +Every course student is associated with some CourseUserGroup, and provider checks for membership and shifts due dates accordingly. diff --git a/manager.py b/manager.py index 416eaa5..28f131a 100644 --- a/manager.py +++ b/manager.py @@ -1,19 +1,17 @@ from datetime import timedelta -from logging import getLogger from django.utils import timezone from models import CourseShiftGroup, CourseShiftGroupMembership, CourseShiftSettings date_now = lambda: timezone.now().date() -log = getLogger(__name__) class CourseShiftManager(object): """ Provides the interface to perform operations on users and - course shifts: user transfer between shifts, due date calculation, - active shifts etc. + shifts for given course: user transfer between shifts, shift creation, + data about available shifts. Supposed to be used outside the app in edx """ def __init__(self, course_key): @@ -21,17 +19,17 @@ def __init__(self, course_key): self.settings = CourseShiftSettings.get_course_settings(self.course_key) @property - def is_shift_enabled(self): + def is_enabled(self): return self.settings.is_shift_enabled - def get_user_shift(self, user, course_key): + def get_user_shift(self, user): """ - Returns user's shift group for given course. + Returns user's shift group for manager's course. """ if not self.settings.is_shift_enabled: return - membership = CourseShiftGroupMembership.get_user_membership(user, course_key) + membership = CourseShiftGroupMembership.get_user_membership(user, self.course_key) if membership: return membership.course_shift_group @@ -42,7 +40,7 @@ def get_active_shifts(self, user=None): """ Returns shifts that are are active at this moment according to the settings, i.e. enrollment have started but haven't finished yet. - If user is given and he has membership all started shifts are considered + If user is given and he has membership all later started shifts are considered as active """ if not self.settings.is_shift_enabled: @@ -55,64 +53,80 @@ def get_active_shifts(self, user=None): active_shifts = [] current_start_date = None if user: - current_group = self.get_user_shift(user, self.course_key) - current_start_date = current_group and current_group.start_date + current_shift = self.get_user_shift(user) + current_start_date = current_shift and current_shift.start_date for shift in all_shifts: enroll_start = shift.start_date - timedelta(days=self.settings.enroll_before_days) enroll_finish = shift.start_date + timedelta(days=self.settings.enroll_after_days) if current_start_date and current_start_date < shift.start_date: + # If user is in group 'current_shift,' which is older than given 'shift', + # then all groups later than 'current' are available for user. + # This means that enroll_finish for this shift should be ignored. + # Because later enroll_finish is compared with 'now', it is replaced + # by 'now' to pass the check successfully anyway enroll_finish = now - if enroll_start < now <= enroll_finish: active_shifts.append(shift) return active_shifts - def sign_user_on_shift(self, user, shift, course_key): + def enroll_user(self, user, shift, forced=False): """ - Transfers user to given shift group. User's enrollment is not checked - because at course enrollment user should be firstly transfered to shift and - only then enrolled on course. - :param user: user to enroll on shift - :param shift: CourseShiftGroup to enroll - :param course_key: to which course shift_to (and shift_from if not None) belongs + Enrolls user on given shift. If user is enrolled on other shift, + his current shift membership canceled. If shift is None only current membership + is canceled. Enrollment is allowed only on 'active shifts' for given user + (watch 'get_active_shift') + If forced is True, user can be enrolled on inactive shift. """ - if shift.course_key != course_key: - raise ValueError("Shift's course_key: '{}', given course_key:'{}'".format( + if shift and shift.course_key != self.course_key: + raise ValueError("Shift's course_key: '{}', manager course_key:'{}'".format( str(shift.course_key), - str(course_key) + str(self.course_key) )) - membership = CourseShiftGroupMembership.get_user_membership(user=user, course_key=course_key) + membership = CourseShiftGroupMembership.get_user_membership( + user=user, + course_key=self.course_key + ) shift_from = membership and membership.course_shift_group if shift_from == shift: return membership - active_shifts = self.get_active_shifts(user) - if shift not in active_shifts: + user_can_be_enrolled = forced + if not shift: # unenroll is possible at any time + user_can_be_enrolled = True + active_shifts = [] + if not user_can_be_enrolled: + active_shifts = self.get_active_shifts(user) + if shift in active_shifts: + user_can_be_enrolled = True + if not user_can_be_enrolled: raise ValueError("Shift {} is not in active shifts: {}".format( str(shift), str(active_shifts) )) - return CourseShiftGroupMembership.transfer_user(user, shift_from, shift) - def create_shift(self, start_date): + def create_shift(self, start_date=None, name=None): """ - Creates plan with given start date. + Creates shift with given start date and name.If start_date is not + specified then shift created with start_date 'now'. + If name is not specified, name is got from 'settings.build_default_name' """ if not self.settings.is_shift_enabled: - return ValueError("Can't create shift: feature is turned off for course") + raise ValueError("Can't create shift: feature is turned off for course") if self.settings.is_autostart: raise ValueError("Can't create shift in autostart mode") - - name = self.settings.build_name(start_date) - days_shift = self.settings.calculate_days_add(start_date) + if not start_date: + start_date = date_now() + if not name: + name = self.settings.build_default_name(start_date=start_date) + days_shift = self.settings.calculate_days_shift(start_date) shift, created = CourseShiftGroup.create( name=name, course_key=self.course_key, start_date=start_date, days_shift=days_shift ) - return shift \ No newline at end of file + return shift diff --git a/models.py b/models.py index 23c5d9d..17345a1 100644 --- a/models.py +++ b/models.py @@ -1,7 +1,7 @@ """ This file contains the logic for course shifts. """ -from datetime import timedelta +from datetime import timedelta, datetime from logging import getLogger from django.core.exceptions import ValidationError @@ -56,16 +56,13 @@ def users(self): def name(self): return self.course_user_group.name - def get_shifted_due(self, user, block, name): - value = getattr(block, name) - if not value: - return + def get_shifted_date(self, user, date): if user not in self.users.all(): raise ValueError("User '{}' is not in shift '{}'".format( user.username, str(self) )) - return value + timedelta(days=self.days_shift) + return date + timedelta(days=self.days_shift) def get_enrollment_limits(self, shift_settings=None): """ @@ -304,7 +301,6 @@ class CourseShiftSettings(models.Model): def __init__(self, *args, **kwargs): super(CourseShiftSettings, self).__init__(*args, **kwargs) - self._update_shifts_autostart() @property def last_start_date(self): @@ -323,6 +319,10 @@ def course_start_date(self): @classmethod def get_course_settings(cls, course_key): + """ + Return shift settings for given course. Creates + if doesn't exist + """ current_settings, created = cls.objects.get_or_create(course_key=course_key) if created: log.info("Settings for {} are created".format( @@ -330,15 +330,19 @@ def get_course_settings(cls, course_key): )) return current_settings - def build_name(self, **kwargs): + def build_default_name(self, **kwargs): """ :param start_date - Defines how should be shifts named + Defines how should be shifts named if specific name wasn't given """ date = kwargs.get("start_date") return "shift_{}_{}".format(str(self.course_key), str(date)) - def calculate_days_add(self, start_date): + def calculate_days_shift(self, start_date): + """ + For given shift start date calculates days_shift value + as a difference between course and shift start dates + """ return int((start_date - self.course_start_date).days) def get_next_autostart_date(self): @@ -360,17 +364,19 @@ def _calculate_launch_date(self, start_date): """ return start_date - timedelta(days=self.enroll_before_days) - def _update_shifts_autostart(self): + def update_shifts_autostart(self): """ Creates new shifts if required by autostart settings """ + if not (self.is_autostart and self.is_shift_enabled): + return start_date = self.get_next_autostart_date() if not start_date: return launch_date = self._calculate_launch_date(start_date) while launch_date < date_now(): - name = self.build_name(start_date=start_date) - days_shift = self.calculate_days_add(start_date=start_date) + name = "auto_" + self.build_default_name(start_date=start_date) + days_shift = self.calculate_days_shift(start_date=start_date) group, created = CourseShiftGroup.create( name=name, @@ -379,7 +385,26 @@ def _update_shifts_autostart(self): course_key=self.course_key ) if created: - log.info("Shift {} automatically created".format(str(group))) + log.info("Shift {} automatically created, launch date is {}; start date is {}, enroll_before is {}".format( + str(group), + str(launch_date), + str(start_date), + str(self.enroll_before_days) + )) start_date = self.get_next_autostart_date() launch_date = self._calculate_launch_date(start_date) + def save(self, *args, **kwargs): + self.update_shifts_autostart() + return super(CourseShiftSettings, self).save(*args, **kwargs) + + def __unicode__(self): + text = u"{}; -{}/+{} days,".format( + unicode(self.course_key), + unicode(self.enroll_before_days), + unicode(self.enroll_after_days)) + if self.is_autostart: + text += u"auto" + else: + text += u"manual" + return text \ No newline at end of file diff --git a/tests/test_shifts.py b/tests/test_shifts.py index 1b1aa65..af7b144 100644 --- a/tests/test_shifts.py +++ b/tests/test_shifts.py @@ -320,16 +320,19 @@ def test_get_user_membership(self): )) +class EnrollClsFields(object): + _ENROLL_BEFORE = 7 + _ENROLL_AFTER = 0 + _PERIOD = 20 + _COURSE_DATE_START = 14 + + @attr(shard=2) -class TestCourseShiftSettings(ModuleStoreTestCase): +class TestCourseShiftSettings(ModuleStoreTestCase, EnrollClsFields): """ Test the course shifts settings """ MODULESTORE = TEST_DATA_MIXED_MODULESTORE - _ENROLL_BEFORE = 7 - _ENROLL_AFTER = 0 - _PERIOD = 20 - _COURSE_DATE_START = 14 def setUp(self): """ @@ -339,18 +342,27 @@ def setUp(self): date = datetime.datetime.now() - datetime.timedelta(days=self._COURSE_DATE_START) self.course = ToyCourseFactory.create(start=date) self.course_key = self.course.id + self._no_groups_check() + + def tearDown(self): + self._delete_groups() - def _settings_setup(self, period=_PERIOD, autostart=False): + def _settings_setup(self, period=None, autostart=False): """ Not included into setUp because should be tests """ + if not period: + period = self._PERIOD settings = CourseShiftSettings.get_course_settings(self.course_key) settings.is_shift_enabled = True - settings.enroll_before_days = 0 - settings.enroll_before_days = 7 + settings.enroll_before_days = self._ENROLL_BEFORE + settings.enroll_after_days = self._ENROLL_AFTER settings.is_autostart = autostart settings.autostart_period_days = period settings.save() + settings = CourseShiftSettings.get_course_settings(self.course_key) + self.assertTrue(settings.enroll_before_days == self._ENROLL_BEFORE) + self.assertTrue(settings.enroll_after_days == self._ENROLL_AFTER) return def _delete_groups(self): @@ -360,8 +372,8 @@ def _delete_groups(self): def _number_of_shifts(self, custom_period): """ - Calculates how many shifts should be in - current settings + Calculates how many shifts should be according + to the current settings """ course_started_days_ago = self._COURSE_DATE_START # enroll_before effectively shifts start date here @@ -395,28 +407,21 @@ def test_settings_generation_and_saving(self): self.assertTrue(settings.is_shift_enabled == True) self.assertTrue(settings.is_autostart == False) self.assertTrue(settings.autostart_period_days == self._PERIOD) - settings.delete() - self._delete_groups() def test_autostart_generation_one(self): """ Single start should be generated - default shift at start """ - self._no_groups_check() custom_period = 30 self._settings_setup(period=custom_period, autostart=True) - - settings = CourseShiftSettings.get_course_settings(self.course_key) course_shifts = CourseShiftGroup.get_course_shifts(self.course_key) shifts_number = self._number_of_shifts(custom_period) self.assertTrue(len(course_shifts) == shifts_number, "Must be {} shift, found: {}".format(shifts_number, str(course_shifts))) - self._delete_groups() def test_autostart_generation_two(self): """ Two shifts must be generated automatically, default and one more """ - self._no_groups_check() custom_period = 12 self._settings_setup(period=custom_period, autostart=True) shifts_number = self._number_of_shifts(custom_period) @@ -425,7 +430,6 @@ def test_autostart_generation_two(self): self.assertTrue(len(course_shifts) == shifts_number, "Must be {} shifts, found:{}".format( shifts_number, str(course_shifts))) - self._delete_groups() def test_autostart_generation_three(self): """ @@ -440,11 +444,22 @@ def test_autostart_generation_three(self): self.assertTrue(len(course_shifts) == shifts_number, "Must be {} shifts, found:{}".format( shifts_number, str(course_shifts))) - self._delete_groups() + + def test_turn_off_autostart(self): + """ + Checks that when autostart is turned off + shifts aren't created + """ + self._no_groups_check() + self._settings_setup(autostart=False, period=8) + self._no_groups_check() + settings = CourseShiftSettings.get_course_settings(self.course_key) + self._no_groups_check() @attr(shard=2) -class TestCourseShiftManager(ModuleStoreTestCase): +class TestCourseShiftManager(ModuleStoreTestCase, EnrollClsFields): + def setUp(self): super(TestCourseShiftManager, self).setUp() date = datetime.datetime.now() - datetime.timedelta(days=14) @@ -452,7 +467,28 @@ def setUp(self): self.course_key = self.course.id self.shift_settings = CourseShiftSettings.get_course_settings(self.course_key) self.shift_settings.is_shift_enabled = True + self.shift_settings.is_autostart = False self.shift_settings.save() + self.user = UserFactory(username="test", email="a@b.com") + self._no_groups_check() + + def tearDown(self): + self._delete_groups() + + def _settings_setup(self, period=None, autostart=False): + """ + Not included into setUp because should be tests + """ + if not period: + period = self._PERIOD + settings = CourseShiftSettings.get_course_settings(self.course_key) + settings.is_shift_enabled = True + settings.enroll_before_days = self._ENROLL_BEFORE + settings.enroll_after_days = self._ENROLL_AFTER + settings.is_autostart = autostart + settings.autostart_period_days = period + settings.save() + return def _delete_groups(self): for x in CourseShiftGroup.objects.all(): @@ -464,54 +500,59 @@ def _no_groups_check(self): Used at start and anywhere needed """ shift_groups = CourseShiftGroup.get_course_shifts(self.course_key) + correct = len(shift_groups) == 0 + message = str(shift_groups) + if not correct: + self._delete_groups() self.assertTrue( - len(shift_groups) == 0, - "Course has shift groups at start" + correct, + message ) def test_get_user_course_shift(self): """ Tests method get_user_course_shift """ - user = UserFactory(username="test", email="a@b.com") + self._settings_setup() + user = self.user shift_manager = CourseShiftManager(course_key=self.course_key) - shift_group = shift_manager.get_user_shift(user, self.course_key) + shift_group = shift_manager.get_user_shift(user) self.assertFalse(shift_group, "User shift group is {}, should be None".format(str(shift_group))) - test_a_shift_group, created = CourseShiftGroup.create("test_shift_group", self.course_key) + test_a_shift_group, created = CourseShiftGroup.create("test_shift_group_t1", self.course_key) CourseShiftGroupMembership.transfer_user(user, None, test_a_shift_group) - shift_group = shift_manager.get_user_shift(user, self.course_key) + shift_group = shift_manager.get_user_shift(user) self.assertTrue(shift_group==test_a_shift_group, "User shift group is {}, should be {}".format( str(shift_group), str(test_a_shift_group) )) self._delete_groups() + self._no_groups_check() def test_get_user_course_shift_disabled(self): - user = UserFactory(username="test", email="a@b.com") - test_a_shift_group, created = CourseShiftGroup.create("test_shift_group", self.course_key) + self._settings_setup() + user = self.user + test_a_shift_group, created = CourseShiftGroup.create("test_shift_group_t2", self.course_key) CourseShiftGroupMembership.transfer_user(user, None, test_a_shift_group) self.shift_settings.is_shift_enabled = False self.shift_settings.save() shift_manager = CourseShiftManager(self.course_key) - shift_group = shift_manager.get_user_shift(user, self.course_key) + shift_group = shift_manager.get_user_shift(user) self.assertTrue(shift_group is None, "User shift group is {}, should be None".format(str(shift_group))) self.shift_settings.is_shift_enabled = True - self.shift_settings.save() - self._delete_groups() def test_get_active_shifts(self): """ Tests method get_active_shifts without user """ + self._settings_setup() + self._no_groups_check() shift_manager = CourseShiftManager(self.course_key) - course_shifts = shift_manager.get_active_shifts() - self.assertTrue(len(course_shifts) == 0, "Must be zero shift groups, found:{}".format(str(course_shifts))) group1, created = CourseShiftGroup.create("test_group", self.course_key) - group2, created = CourseShiftGroup.create("test_group2", self.course_key, start_date=date_shifted(days=-5)) + group2, created = CourseShiftGroup.create("test_group2", self.course_key, start_date=date_shifted(days=5)) course_shifts = shift_manager.get_active_shifts() correct = (group1 in course_shifts) and (group2 in course_shifts) and (len(course_shifts) == 2) @@ -520,91 +561,271 @@ def test_get_active_shifts(self): str(group2), str(course_shifts) )) + + def test_create_shift(self): + """ + Tests manager.create_shift + """ + self._settings_setup() + self._no_groups_check() + shift_manager = CourseShiftManager(self.course_key) + test_group = shift_manager.create_shift() + groups = shift_manager.get_all_shifts() + correct = test_group in groups and len(groups) == 1 + self.assertTrue(correct, "Should be only {}, found: {}".format( + str(test_group), + str(groups) + )) + + test_group_same = shift_manager.create_shift() + groups = shift_manager.get_all_shifts() + correct = test_group_same in groups and len(groups) == 1 + self.assertTrue(correct, "Should be only {}, found: {}".format( + str(test_group), + str(groups) + )) + self.assertTrue(test_group_same==test_group, "Groups different: {} and {}".format( + str(test_group), + str(test_group_same) + )) + + test_group_other = shift_manager.create_shift(date_shifted(1)) + groups = shift_manager.get_all_shifts() + correct = test_group_same in groups \ + and test_group_other in groups \ + and len(groups) == 2 + self.assertTrue(correct, "Should be {} and {}, found: {}".format( + str(test_group), + str(test_group_other), + str(groups) + )) + + def test_create_shift_different_name_error(self): + """ + Checks error at shift creation with same date + but different name + """ + self._settings_setup() + self._no_groups_check() + shift_manager = CourseShiftManager(self.course_key) + test_group = shift_manager.create_shift() + with self.assertRaises(IntegrityError): + test_group_error = shift_manager.create_shift(name="same_date_different_name") + + def test_create_shift_different_date_error(self): + """ + Checks error at shift creation with same name + but different date. + Checks that for same name and same date error not raised + """ + self._settings_setup() + self._no_groups_check() + shift_manager = CourseShiftManager(self.course_key) + + test_group = shift_manager.create_shift() + name = test_group.name + with self.assertRaises(IntegrityError): + test_group_error = shift_manager.create_shift(name=name, start_date=date_shifted(1)) + + test_group2 = shift_manager.create_shift() + self.assertTrue(test_group==test_group2, "Different groups: {} {}".format( + str(test_group), + str(test_group2) + )) self._delete_groups() + self._no_groups_check() + + def test_get_active_groups(self): + """ + Checks get_active_groups without user + """ + self._settings_setup() + self._no_groups_check() + shift_manager = CourseShiftManager(self.course_key) + self._no_groups_check() + group = shift_manager.create_shift(date_shifted(-20)) + active_groups = shift_manager.get_active_shifts() + self.assertTrue(len(active_groups) == 0, "Should be empty, found {}".format(str(active_groups))) + + group2 = shift_manager.create_shift(date_shifted(1)) + active_groups = shift_manager.get_active_shifts() + correct = len(active_groups) == 1 and group2 in active_groups + self.assertTrue(correct, "Should be {}, found {}".format( + str(group2), + str(active_groups) + )) + self._delete_groups() + self._no_groups_check() + + def test_get_active_groups_user(self): + """ + Checks get_active_groups with user. + Old groups are inactive but if has membership, later groups are active + """ + self._settings_setup() + self._no_groups_check() + shift_manager = CourseShiftManager(self.course_key) + group = shift_manager.create_shift(date_shifted(-20)) + group2 = shift_manager.create_shift(date_shifted(-30)) + + active_user_groups = shift_manager.get_active_shifts(self.user) + correct = len(active_user_groups) == 0 + self.assertTrue(correct, "Active user groups: {}".format( + str(active_user_groups) + )) + + CourseShiftGroupMembership.transfer_user(self.user, None, group2) + active_user_groups = shift_manager.get_active_shifts(self.user) + correct = len(active_user_groups) == 1 and group in active_user_groups + self.assertTrue(correct, "Active user groups: {}".format( + str(active_user_groups) + )) + self._delete_groups() + self._no_groups_check() + + def test_get_active_groups_future(self): + """ + Checks that future groups are inactive + without user and with user that has older + membership + """ + self._settings_setup() + self._no_groups_check() + shift_manager = CourseShiftManager(self.course_key) + group = shift_manager.create_shift(start_date=date_shifted(-1)) + CourseShiftGroupMembership.transfer_user(self.user, None, group) + + group_future = shift_manager.create_shift(start_date=date_shifted(14)) + + self.assertTrue(group_future.start_date == date_shifted(14), + "Start date is {}, must be {}".format( + str(group_future.start_date), + str(date_shifted(14)) + )) + active_groups = shift_manager.get_active_shifts() + active_user_groups = shift_manager.get_active_shifts(self.user) - def test_sign_user_on_shift_valid(self): + correct = len(active_user_groups) == 0 and len(active_user_groups) == 0 + self.assertTrue(correct, "Active groups: {}; \nActive user groups: {}".format( + str(active_groups), + str(active_user_groups) + )) + self._delete_groups() + self._no_groups_check() + + def test_enroll_user(self): """ Tests method sign_user_on_shift. Valid scenarios """ - user = UserFactory(username="test", email="a@b.com") - shift_manager = CourseShiftManager(course_key=self.course_key) - shift_group = shift_manager.get_user_shift(user, self.course_key) - self.assertTrue(shift_group is None, "User shift group is {}, should be None".format(str(shift_group))) + self._no_groups_check() + user = self.user + shift_manager = CourseShiftManager(self.course_key) - group1, created = CourseShiftGroup.create("test_group", self.course_key) - group2, created = CourseShiftGroup.create("test_group2", self.course_key, start_date=date_shifted(days=-5)) + group1 = shift_manager.create_shift() + group2 = shift_manager.create_shift(date_shifted(days=-5)) - shift_manager.sign_user_on_shift(user, group1, self.course_key) - shift_group = shift_manager.get_user_shift(user, self.course_key) + shift_manager.enroll_user(user, group1) + shift_group = shift_manager.get_user_shift(user) self.assertTrue(shift_group == group1, "User shift group is {}, should be {}".format( str(shift_group), str(group1) )) - shift_manager.sign_user_on_shift( + shift_manager.enroll_user( user=user, - course_key=self.course_key, shift=group2 ) - shift_group = shift_manager.get_user_shift(user, self.course_key) + shift_group = shift_manager.get_user_shift(user) self.assertTrue(shift_group == group2, "User shift group is {}, should be {}".format( str(shift_group), str(group2) )) - shift_manager.sign_user_on_shift(user, shift=group1, course_key=self.course_key) - shift_group = shift_manager.get_user_shift(user, self.course_key) + shift_manager.enroll_user(user, shift=group1) + shift_group = shift_manager.get_user_shift(user) self.assertTrue(shift_group == group1, "User shift group is {}, should be {}".format( str(shift_group), str(group1) )) - CourseShiftGroupMembership.transfer_user(user, group1, None) - group1.delete() - group2.delete() + self._delete_groups() - def _test_sign_user_on_shift_invalid(self): + def test_enroll_user_error_course_key(self): """ - Tests method sign_user_on_shift. - Invalid scenarios + Checks that error is raised when enroll_user + gets shift from other course """ - second_course = ToyCourseFactory.create(org="neworg") - second_course_key = second_course.id - - user = UserFactory(username="test", email="a@b.com") - shift_manager = CourseShiftManager(course_key=self.course_key) - shift_group = shift_manager.get_user_shift(user, self.course_key) - self.assertTrue(shift_group is None, "User shift group is {}, should be None".format(str(shift_group))) - - group1, created = CourseShiftGroup.create("test_group", self.course_key) - group2, created = CourseShiftGroup.create("test_group2", self.course_key, start_date=date_shifted(days=-5)) + self._no_groups_check() + user = self.user + shift_manager = CourseShiftManager(self.course_key) - group_invalid, created = CourseShiftGroup.create("invalid_test_group", second_course_key) + other_course = ToyCourseFactory.create(org="neworg") + other_course_key = other_course.id + other_manager = CourseShiftManager(other_course_key) + other_manager.settings.is_shift_enabled = True + other_manager.settings.is_autostart = False + other_group = other_manager.create_shift() - with self.assertRaises(ValueError) as context_manager: - shift_manager.sign_user_on_shift(user, group1, course_key=second_course_key) - exception_msg_parts = ("Shift's course_key:", ", given course_key:") - self.assertTrue(all(x in str(context_manager.exception) for x in exception_msg_parts)) + with self.assertRaises(ValueError): + shift_manager.enroll_user(user, other_group) + self._delete_groups() - shift_manager.sign_user_on_shift(user, group1, course_key=self.course_key) + def test_enroll_user_error_inactive(self): + """ + Checks that enroll_user raises error + if shift is not active + """ + self._no_groups_check() + shift_manager = CourseShiftManager(self.course_key) + group = shift_manager.create_shift(date_shifted(-20)) + active_groups = shift_manager.get_active_shifts(self.user) + self.assertTrue( + not(group in active_groups), + "Active groups : {}".format(str(active_groups)) + ) + with self.assertRaises(ValueError): + shift_manager.enroll_user(self.user, group) + self._delete_groups() - with self.assertRaises(ValueError) as context_manager: - shift_manager.sign_user_on_shift( - user=user, - shift=group2, - course_key=second_course_key + def test_enroll_user_inactive_forced(self): + """ + Checks that no error raised when enroll_user + is used in forced mode for inactive shift + """ + self._no_groups_check() + shift_manager = CourseShiftManager(self.course_key) + group = shift_manager.create_shift(date_shifted(-20)) + active_groups = shift_manager.get_active_shifts() + self.assertTrue( + not(group in active_groups), + "Active groups : {}".format(str(active_groups)) + ) + shift_manager.enroll_user(self.user, group, forced=True) + user_shift = shift_manager.get_user_shift(self.user) + self.assertTrue( + user_shift==group, + "User shift:{}, should be {}".format( + str(user_shift), + str(group) ) - exception_msg_parts = ("Shift_from's course_key:", "given course_key:") - self.assertTrue(all(x in str(context_manager.exception) for x in exception_msg_parts)) + ) - with self.assertRaises(ValueError) as context_manager: - shift_manager.sign_user_on_shift(user, group2, self.course_key) - exception_msg_parts = ("User's membership for given course is not None:",) - self.assertTrue(all(x in str(context_manager.exception) for x in exception_msg_parts)) - - membership = CourseShiftGroupMembership.get_user_membership(user, self.course_key) - if membership: - membership.delete() - group1.delete() - group2.delete() - group_invalid.delete() + def test_unenroll_user(self): + """ + Tests that enroll with None leads to unenrollment + """ + shift_manager = CourseShiftManager(self.course_key) + group = shift_manager.create_shift(date_shifted(-5)) + + shift_manager.enroll_user(self.user, None) + current_shift = shift_manager.get_user_shift(self.user) + self.assertTrue( + current_shift is None, + "Current shift should be None, but it is {}".format(str(current_shift)) + ) + shift_manager.enroll_user(self.user, group) + shift_manager.enroll_user(self.user, None) + self.assertTrue( + current_shift is None, + "Current shift should be None, but it is {}".format(str(current_shift)) + ) From 22b261ada5915f25e3e9b00f74f6d8e255a64b2d Mon Sep 17 00:00:00 2001 From: Boris Zimka Date: Wed, 20 Sep 2017 13:29:58 +0000 Subject: [PATCH 13/35] Field Override Provider added --- README.rst | 19 ++++++++++++ provider.py | 89 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 108 insertions(+) create mode 100644 provider.py diff --git a/README.rst b/README.rst index c3c69cf..a3e2440 100644 --- a/README.rst +++ b/README.rst @@ -18,3 +18,22 @@ Details ------- Feature is implemented via additional FieldOverrideProvider and CourseUserGroups, similar to the way it's done for 'INDIVIDUAL_DUE_DATES' feature. Every course student is associated with some CourseUserGroup, and provider checks for membership and shifts due dates accordingly. + +Installation +------------ + +1. 'course_shifts' should be added to the INSTALLED_APPS variable: + +:: + + INSTALLED_APPS += ('openedx.core.djangoapps.course_shifts',) + +2. course_shifts.provider.CourseShiftOverrideProvider should be added to the FIELD_OVERRIDE_PROVIDERS + +:: + + FIELD_OVERRIDE_PROVIDERS += ( + 'openedx.core.djangoapps.course_shifts.provider.CourseShiftOverrideProvider', + ) + +Note that if feature INDIVIDUAL_DUE_DATES is also used, than IndividualStudentOverrideProvider must be added before CourseShiftOverrideProvider. diff --git a/provider.py b/provider.py new file mode 100644 index 0000000..a641f87 --- /dev/null +++ b/provider.py @@ -0,0 +1,89 @@ +from datetime import timedelta +from lms.djangoapps.courseware.field_overrides import FieldOverrideProvider +from .manager import CourseShiftManager + + +class CourseShiftOverrideProvider(FieldOverrideProvider): + """ + This override provider shifts due dates for courseware + based on user's membership in CourseShiftGroups + """ + + COURSE_OVERRIDEN_NAMES = ( + 'due', + ) + BLOCK_OVERRIDEN_NAMES = ( + 'due', + 'start' + ) + BLOCK_OVERRIDEN_CATEGORIES = ( + 'chapter', + 'sequential', + ) + + def should_shift(self, block, name): + """ + Defines when to shift(override) field value + """ + category = block.category + if category == 'course': + if name in self.COURSE_OVERRIDEN_NAMES: + return True + if category in self.BLOCK_OVERRIDEN_CATEGORIES: + if name in self.BLOCK_OVERRIDEN_NAMES: + return True + return False + + def get(self, block, name, default): + if not self.should_shift(block, name): + return default + course_key = block.location.course_key + shift_manager = CourseShiftManager(course_key) + + if not shift_manager.is_enabled: + return default + shift_group = shift_manager.get_user_shift(self.user) + if not shift_group: + return default + base_value = get_default_fallback_field_value(block, name) + if base_value: + shifted_value = shift_group.get_shifted_date(self.user, base_value) + return shifted_value + return default + + @classmethod + def enabled_for(cls, course): + """This simple override provider is always enabled""" + return True + + +def get_default_fallback_field_value(block, name): + """ + This function returns value of block's field + avoiding recursive entering into the shift provider. + """ + fallback = block._field_data._authored_data._source.fallback + base_value = None + if fallback.has(block, name): + base_value = fallback.get(block, name) + return base_value + + +def _get_default_scoped_field_value(block, name): + """ + This function returns value of block's field + avoiding recursive entering into the shift provider. + """ + # This is a bit more hacky way to get base value. + # It is slower and stranger than the one with fallback + safe_scope_names = ("preferences", "user_info") + scope_field_data_dict = block._field_data._scope_mappings + scope_name_dict = dict((x.name,x) for x in scope_field_data_dict.keys()) + + for scope_name in safe_scope_names: + scope = scope_name_dict.get(scope_name) + if not scope: + continue + field_data = scope_field_data_dict.get(scope) + if field_data.has(block, name): + return field_data.get(block, name) From b940d4fab47678a5e28a4a6bfa1d06d98e6952dd Mon Sep 17 00:00:00 2001 From: Boris Zimka Date: Thu, 21 Sep 2017 16:50:29 +0000 Subject: [PATCH 14/35] Settings serializer and api added --- README.rst | 8 ++++++++ api.py | 34 ++++++++++++++++++++++++++++++++++ models.py | 10 +++++++--- serializers.py | 41 +++++++++++++++++++++++++++++++++++++++++ urls.py | 12 ++++++++++++ 5 files changed, 102 insertions(+), 3 deletions(-) create mode 100644 api.py create mode 100644 serializers.py create mode 100644 urls.py diff --git a/README.rst b/README.rst index a3e2440..e6b1797 100644 --- a/README.rst +++ b/README.rst @@ -37,3 +37,11 @@ Installation ) Note that if feature INDIVIDUAL_DUE_DATES is also used, than IndividualStudentOverrideProvider must be added before CourseShiftOverrideProvider. + +3. Add urls to the urls.py in lms/ and cms/: + +:: + + urlpatterns += ( + url(r'^course_shifts/', include('openedx.core.djangoapps.course_shifts.urls', app_name="course_shifts", namespace="course_shifts")), + ) diff --git a/api.py b/api.py new file mode 100644 index 0000000..da549f0 --- /dev/null +++ b/api.py @@ -0,0 +1,34 @@ +from rest_framework import views, permissions, response, status +from opaque_keys.edx.keys import CourseKey +from openedx.core.lib.api.permissions import IsStaffOrOwner + +from .models import CourseShiftSettings +from .serializers import CourseShiftSettingsSerializer + + +class CourseShiftSettingsView(views.APIView): + """ + Allows instructor to manipulate course shift settings + """ + #permission_classes = (permissions.IsAuthenticated, IsStaffOrOwner) + permissions = tuple() + + def get(self, request, course_id): + course_key = CourseKey.from_string(course_id) + shift_settings = CourseShiftSettings.get_course_settings(course_key) + serial_shift_settings = CourseShiftSettingsSerializer(shift_settings) + data = serial_shift_settings.data + data.pop('course_key') + return response.Response(data=data) + + def post(self, request, course_id): + data = request.data + data['course_key'] = course_id + serial_shift_settings = CourseShiftSettingsSerializer(data=data) + if serial_shift_settings.is_valid(): + course_key = serial_shift_settings.validated_data['course_key'] + instance = CourseShiftSettings.get_course_settings(course_key) + serial_shift_settings.update(instance, serial_shift_settings.validated_data) + return response.Response() + else: + return response.Response(status=status.HTTP_400_BAD_REQUEST, data=serial_shift_settings.errors) diff --git a/models.py b/models.py index 17345a1..86a435d 100644 --- a/models.py +++ b/models.py @@ -5,6 +5,7 @@ from logging import getLogger from django.core.exceptions import ValidationError +from django.core.validators import MinValueValidator from django.contrib.auth.models import User from django.db import models, IntegrityError from django.utils import timezone @@ -282,21 +283,24 @@ class CourseShiftSettings(models.Model): db_column='autostart_period_days', help_text="Number of days between new automatically generated shifts."\ "Used only in autostart mode.", - null=True + null=True, + validators=[MinValueValidator(0)] ) enroll_before_days = models.PositiveIntegerField( default=14, help_text="Days before shift start when student can enroll already."\ "E.g. if shift starts at 01/20/2020 and value is 5 then shift will be"\ - "available from 01/15/2020." + "available from 01/15/2020.", + validators=[MinValueValidator(0)] ) enroll_after_days = models.PositiveIntegerField( default=7, help_text="Days after shift start when student still can enroll." \ "E.g. if shift starts at 01/20/2020 and value is 10 then shift will be" \ - "available till 01/20/2020" + "available till 01/20/2020", + validators=[MinValueValidator(0)] ) def __init__(self, *args, **kwargs): diff --git a/serializers.py b/serializers.py new file mode 100644 index 0000000..b42aade --- /dev/null +++ b/serializers.py @@ -0,0 +1,41 @@ +from rest_framework import serializers +from openedx.core.lib.api.serializers import CourseKeyField +from .models import CourseShiftSettings + + +class CourseShiftSettingsSerializer(serializers.ModelSerializer): + """ + Serializes CourseShiftSettings. + is_shift_enabled mustn't be changed by api, therefore + dropped from representation and is considered always to be True + """ + course_key = CourseKeyField() + enroll_after_days = serializers.IntegerField() + enroll_before_days = serializers.IntegerField() + autostart_period_days = serializers.IntegerField() + is_autostart = serializers.BooleanField() + + class Meta: + model = CourseShiftSettings + fields = ( + 'course_key', + 'enroll_after_days', + 'enroll_before_days', + 'autostart_period_days', + 'is_autostart', + ) + + def validate_enroll_after_days(self, value): + message = "Enrollment days number after start can't be negative" + if value < 0: + raise serializers.ValidationError(message) + + def validate_enroll_before_days(self, value): + message = "Enrollment days number before start can't be negative" + if value < 0: + raise serializers.ValidationError(message) + + def validate_autostart_period_days(self, value): + message = "Autostart period must be positive" + if value <= 0: + raise serializers.ValidationError(message) diff --git a/urls.py b/urls.py new file mode 100644 index 0000000..908cd10 --- /dev/null +++ b/urls.py @@ -0,0 +1,12 @@ +""" +URLs for course shifts app +""" +from django.conf import settings +from django.conf.urls import patterns, url + +from .api import CourseShiftSettingsView + +urlpatterns = patterns( + 'course_shifts', + url(r'^settings/{}/$'.format(settings.COURSE_ID_PATTERN), CourseShiftSettingsView.as_view(), name='settings'), +) From a008c6f5c953c61e5f002bbfa9305bc861996de0 Mon Sep 17 00:00:00 2001 From: Boris Zimka Date: Mon, 25 Sep 2017 17:03:59 +0000 Subject: [PATCH 15/35] Added settings edit view --- api.py | 21 +++++--- models.py | 2 +- serializers.py | 15 +++++- static/course_shifts.html | 54 +++++++++++++++++++ static/course_shifts.js | 108 ++++++++++++++++++++++++++++++++++++++ 5 files changed, 192 insertions(+), 8 deletions(-) create mode 100644 static/course_shifts.html create mode 100644 static/course_shifts.js diff --git a/api.py b/api.py index da549f0..73e07db 100644 --- a/api.py +++ b/api.py @@ -1,9 +1,9 @@ -from rest_framework import views, permissions, response, status +from rest_framework import views, permissions, response, status, generics from opaque_keys.edx.keys import CourseKey from openedx.core.lib.api.permissions import IsStaffOrOwner -from .models import CourseShiftSettings -from .serializers import CourseShiftSettingsSerializer +from .models import CourseShiftSettings, CourseShiftGroup +from .serializers import CourseShiftSettingsSerializer, CourseShiftSerializer class CourseShiftSettingsView(views.APIView): @@ -22,13 +22,22 @@ def get(self, request, course_id): return response.Response(data=data) def post(self, request, course_id): - data = request.data + data = dict(request.data.iteritems()) + data = dict((x,str(data[x])) for x in data) data['course_key'] = course_id - serial_shift_settings = CourseShiftSettingsSerializer(data=data) + serial_shift_settings = CourseShiftSettingsSerializer(data=data, partial=True) if serial_shift_settings.is_valid(): course_key = serial_shift_settings.validated_data['course_key'] instance = CourseShiftSettings.get_course_settings(course_key) serial_shift_settings.update(instance, serial_shift_settings.validated_data) return response.Response() else: - return response.Response(status=status.HTTP_400_BAD_REQUEST, data=serial_shift_settings.errors) + errors = serial_shift_settings.errors + errors_by_key = [] + for key in errors.keys(): + if not errors[key]: + continue + errors_by_key.append(u"{}:{}".format(key, ",".join(errors[key]))) + error_message = u";
".join(errors_by_key) + return response.Response(status=status.HTTP_400_BAD_REQUEST, data={"error": error_message}) + diff --git a/models.py b/models.py index 86a435d..393952a 100644 --- a/models.py +++ b/models.py @@ -408,7 +408,7 @@ def __unicode__(self): unicode(self.enroll_before_days), unicode(self.enroll_after_days)) if self.is_autostart: - text += u"auto" + text += u"auto({})".format(self.autostart_period_days) else: text += u"manual" return text \ No newline at end of file diff --git a/serializers.py b/serializers.py index b42aade..c939990 100644 --- a/serializers.py +++ b/serializers.py @@ -1,6 +1,6 @@ from rest_framework import serializers from openedx.core.lib.api.serializers import CourseKeyField -from .models import CourseShiftSettings +from .models import CourseShiftSettings, CourseShiftGroup class CourseShiftSettingsSerializer(serializers.ModelSerializer): @@ -26,16 +26,29 @@ class Meta: ) def validate_enroll_after_days(self, value): + if not isinstance(value, int): + value = int(value) message = "Enrollment days number after start can't be negative" if value < 0: raise serializers.ValidationError(message) + return value def validate_enroll_before_days(self, value): + if not isinstance(value, int): + value = int(value) + message = "Enrollment days number before start can't be negative" if value < 0: raise serializers.ValidationError(message) + return value def validate_autostart_period_days(self, value): + if not isinstance(value, int): + value = int(value) + message = "Autostart period must be positive" if value <= 0: raise serializers.ValidationError(message) + return value + + diff --git a/static/course_shifts.html b/static/course_shifts.html new file mode 100644 index 0000000..bfc3cef --- /dev/null +++ b/static/course_shifts.html @@ -0,0 +1,54 @@ +<%page args="section_data" expression_filter="h"/> +<%namespace name='static' file='../../static_content.html'/> + +<%! +from django.utils.translation import ugettext as _ +from openedx.core.djangolib.js_utils import dump_js_escaped_json, js_escaped_string +%> + + +
+
+
+

${_("Settings Editor")}

+ % if section_data.get('current_settings'): +
+
    + +
  • + True + False + + +
  • +
  • + + +
  • +
  • + + +
  • +
  • + + +
  • +
+ + +
+ % else: +

${_("Course shifts are disabled. Actually, you shouldn't even see this page!")}

+ % endif +
+
+
+
diff --git a/static/course_shifts.js b/static/course_shifts.js new file mode 100644 index 0000000..a9af3a9 --- /dev/null +++ b/static/course_shifts.js @@ -0,0 +1,108 @@ +/* globals _ */ + +(function() { + 'use strict'; + var CourseShifts; + + CourseShifts = (function() { + function course_shifts($section) { + var ext = this; + this.$section = $section; + this.$section.data('wrapper', this); + + this.$enroll_after_days = this.$section.find("input[name='enroll-after-days']"); + this.$enroll_before_days = this.$section.find("input[name='enroll-before-days']"); + this.$autostart_period_days = this.$section.find("input[name='autostart-period-days']"); + this.$is_autostart = this.$section.find("input[name='is-autostart']"); + this.$settings_submit = this.$section.find("input[name='settings-submit']"); + + this.$section.find('.request-response').hide(); + this.$section.find('.request-response-error').hide(); + + this.$settings_submit.click(function() { + var sendData; + ext.clear_display(); + sendData = { + enroll_after_days: ext.$enroll_after_days.val(), + enroll_before_days: ext.$enroll_before_days.val(), + is_autostart: ext.$is_autostart.filter(":checked").val() + }; + if (ext.$autostart_period_days){ + sendData['autostart_period_days'] = ext.$autostart_period_days.val(); + } + return $.ajax({ + type: 'POST', + dataType: 'json', + url: ext.$settings_submit.data('endpoint'), + data: sendData, + success: function(data) { + return ext.display_response('course-shifts', data); + }, + error: function(xhr) { + return ext.fail_with_error('course-shifts', 'Error changing settings', xhr); + } + }); + }); + + var autostart_change = function (){ + var value = ext.$is_autostart.filter(":checked").val(); + if (value == "True"){ + ext.$autostart_period_days.attr("disabled", false); + } + if (value == "False"){ + ext.$autostart_period_days.val(null); + ext.$autostart_period_days.attr("disabled", true); + } + }; + autostart_change(); + this.$is_autostart.change(autostart_change); + } + + course_shifts.prototype.clear_display = function() { + this.$section.find('.request-response-error').empty().hide(); + return this.$section.find('.request-response').empty().hide(); + }; + + course_shifts.prototype.display_response = function(id, data) { + var $taskError, $taskResponse; + $taskError = this.$section.find('#' + id + ' .request-response-error'); + $taskResponse = this.$section.find('#' + id + ' .request-response'); + $taskError.empty().hide(); + if (!data){ + data = "Success."; + } + $taskResponse.empty().text(data); + return $taskResponse.show(); + }; + + course_shifts.prototype.fail_with_error = function(id, msg, xhr) { + var $taskError, $taskResponse, data, + message = msg; + $taskError = this.$section.find('#' + id + ' .request-response-error'); + $taskResponse = this.$section.find('#' + id + ' .request-response'); + this.clear_display(); + data = $.parseJSON(xhr.responseText); + message += ': ' + data.error; + $taskResponse.empty(); + $taskError.empty(); + $taskError.text(message); + return $taskError.show(); + }; + + course_shifts.prototype.onClickTitle = function() {}; + + return course_shifts; + }()); + + _.defaults(window, { + InstructorDashboard: {} + }); + + _.defaults(window.InstructorDashboard, { + sections: {} + }); + + _.defaults(window.InstructorDashboard.sections, { + CourseShifts: CourseShifts + }); +}).call(this); From 006febb0f15c590b98e01d0a89e9ce2d3fe18ae1 Mon Sep 17 00:00:00 2001 From: Boris Zimka Date: Tue, 26 Sep 2017 14:51:33 +0000 Subject: [PATCH 16/35] Added list and detail api for course shifts --- api.py | 84 +++++++++++++++++++++++++++++++++++++++++++++++++- manager.py | 6 ++++ models.py | 33 ++++++++++++++++++-- serializers.py | 12 ++++++++ urls.py | 10 ++++-- 5 files changed, 140 insertions(+), 5 deletions(-) diff --git a/api.py b/api.py index 73e07db..6d09246 100644 --- a/api.py +++ b/api.py @@ -4,11 +4,12 @@ from .models import CourseShiftSettings, CourseShiftGroup from .serializers import CourseShiftSettingsSerializer, CourseShiftSerializer +from .manager import CourseShiftManager class CourseShiftSettingsView(views.APIView): """ - Allows instructor to manipulate course shift settings + Allows instructor to edit course shift settings """ #permission_classes = (permissions.IsAuthenticated, IsStaffOrOwner) permissions = tuple() @@ -41,3 +42,84 @@ def post(self, request, course_id): error_message = u";
".join(errors_by_key) return response.Response(status=status.HTTP_400_BAD_REQUEST, data={"error": error_message}) + +class CourseShiftListView(generics.ListAPIView): + """ + Returns list of shifts for given course + """ + serializer_class = CourseShiftSerializer + + def get_queryset(self): + course_id = self.kwargs['course_id'] + course_key = CourseKey.from_string(course_id) + return CourseShiftGroup.get_course_shifts(course_key) + + def list(self, request, course_id): + queryset = self.get_queryset() + serializer = CourseShiftSerializer(queryset, many=True) + data = serializer.data + return response.Response(data=data) + + +class CourseShiftDetailView(views.APIView): + """ + Allows instructor to watch, to create and to delete course_shifts + """ + + def _get_shift(self, course_id, name): + course_key = CourseKey.from_string(course_id) + shift_manager = CourseShiftManager(course_key) + shift = shift_manager.get_shift(name) + if not shift: + message = "Shift with name {} not found for {}".format(name, course_key) + return None, response.Response(status=status.HTTP_400_BAD_REQUEST, data={"error": message}) + return shift, None + + def get(self, request, course_id): + name = request.query_params.get("name") + shift, error_response = self._get_shift(course_id, name) + if not shift: + return error_response + data = CourseShiftSerializer(shift).data + + enroll_start, enroll_finish = shift.get_enrollment_limits() + data["enroll_start"] = str(enroll_start) + data["enroll_finish"] = str(enroll_finish) + data["users_count"] = shift.users.count() + return response.Response(data=data) + + def delete(self, request, course_id): + name = request.data.get("name") + shift, error_response = self._get_shift(course_id, name) + if not shift: + return error_response + shift.delete() + return response.Response() + + def post(self, request, course_id): + data = {} + data["start_date"] = request.data.get("start_date") + data["name"] = request.data.get("name") + data['course_key'] = course_id + serial = CourseShiftSerializer(data=data, partial=True) + if serial.is_valid(): + kwargs = serial.validated_data + else: + errors = serial.errors + errors_dict = {} + for key in errors.keys(): + if not errors[key]: + continue + key_message = u",".join(unicode(x) for x in errors[key]) + errors_dict[key] = key_message + return response.Response(status=status.HTTP_400_BAD_REQUEST, data={"error": errors_dict}) + + kwargs.pop('course_key') + course_key = CourseKey.from_string(course_id) + shift_manager = CourseShiftManager(course_key) + try: + shift_manager.create_shift(**kwargs) + except Exception as e: + error_message = e.message or str(e) + return response.Response(status=status.HTTP_400_BAD_REQUEST, data={"error": error_message}) + return response.Response() diff --git a/manager.py b/manager.py index 28f131a..aa795da 100644 --- a/manager.py +++ b/manager.py @@ -36,6 +36,12 @@ def get_user_shift(self, user): def get_all_shifts(self): return CourseShiftGroup.get_course_shifts(self.course_key) + def get_shift(self, name): + shift = CourseShiftGroup.get_shift(course_key=self.course_key, name=name) + if shift: + shift.settings = self.settings + return shift + def get_active_shifts(self, user=None): """ Returns shifts that are are active at this moment according to the settings, diff --git a/models.py b/models.py index 393952a..1e1c8c5 100644 --- a/models.py +++ b/models.py @@ -57,6 +57,16 @@ def users(self): def name(self): return self.course_user_group.name + @property + def settings(self): + if not hasattr(self, '_shift_settings'): + self._shift_settings = CourseShiftSettings.get_course_settings(self.course_key) + return self._shift_settings + + @settings.setter + def settings(self, value): + self._shift_settings = value + def get_shifted_date(self, user, date): if user not in self.users.all(): raise ValueError("User '{}' is not in shift '{}'".format( @@ -70,7 +80,8 @@ def get_enrollment_limits(self, shift_settings=None): Return tuple of enrollment start and end dates """ if not shift_settings: - shift_settings = CourseShiftSettings.get_course_settings(self.course_key) + shift_settings = self._shift_settings + return ( self.start_date - timedelta(days=shift_settings.enroll_before_days), self.start_date + timedelta(days=shift_settings.enroll_after_days) @@ -78,7 +89,7 @@ def get_enrollment_limits(self, shift_settings=None): def is_enrollable_now(self, shift_settings=None): if not shift_settings: - shift_settings = CourseShiftSettings.get_course_settings(self.course_key) + shift_settings = self.settings date_start, date_end = self.get_enrollment_limits(shift_settings) if date_start < date_now() < date_end: return True @@ -93,6 +104,18 @@ def get_course_shifts(cls, course_key): raise TypeError("course_key must be CourseKey, not {}".format(type(course_key))) return cls.objects.filter(course_key=course_key).order_by('-start_date') + @classmethod + def get_shift(cls, course_key, name): + """ + Returns shift for given course with given name if exists + """ + if not isinstance(course_key, CourseKey): + raise TypeError("course_key must be CourseKey, not {}".format(type(course_key))) + try: + return cls.objects.get(course_key=course_key, course_user_group__name=name) + except: + return None + @classmethod def create(cls, name, course_key, start_date=None, days_shift=None): """ @@ -100,6 +123,12 @@ def create(cls, name, course_key, start_date=None, days_shift=None): If shift with (name, course_key) combination already exists returns this shift """ course_user_group, created_group = CourseUserGroup.create(name=name, course_id=course_key) + if not created_group: + shift = CourseShiftGroup.objects.get(course_user_group=course_user_group) + if shift.name != name: + raise ValueError("Shift already exists with different name: {}".format(str(shift.name))) + if shift.start_date != start_date: + raise ValueError("Shift already exists with different start_date: {}".format(str(shift.start_date))) kwargs = {"course_user_group": course_user_group} if start_date: kwargs["start_date"] = start_date diff --git a/serializers.py b/serializers.py index c939990..c1d2437 100644 --- a/serializers.py +++ b/serializers.py @@ -52,3 +52,15 @@ def validate_autostart_period_days(self, value): return value +class CourseShiftSerializer(serializers.ModelSerializer): + course_key = CourseKeyField() + name = serializers.CharField(max_length=255, allow_null=True) + start_date = serializers.DateField(allow_null=True) + + class Meta: + model = CourseShiftGroup + fields = ( + 'course_key', + 'name', + 'start_date', + ) diff --git a/urls.py b/urls.py index 908cd10..2333bbd 100644 --- a/urls.py +++ b/urls.py @@ -4,9 +4,15 @@ from django.conf import settings from django.conf.urls import patterns, url -from .api import CourseShiftSettingsView +from .api import CourseShiftSettingsView, CourseShiftListView, CourseShiftDetailView urlpatterns = patterns( 'course_shifts', - url(r'^settings/{}/$'.format(settings.COURSE_ID_PATTERN), CourseShiftSettingsView.as_view(), name='settings'), + url(r'^detail/{}/$'.format(settings.COURSE_ID_PATTERN), CourseShiftDetailView.as_view(), + name='course_shifts_detail'), + url(r'^settings/{}/$'.format(settings.COURSE_ID_PATTERN), CourseShiftSettingsView.as_view(), + name='settings'), + url(r'^{}/$'.format(settings.COURSE_ID_PATTERN), CourseShiftListView.as_view(), + name='course_shifts_list'), + ) From 89e9b5babfa06101f8fc440492f52d52766ce967 Mon Sep 17 00:00:00 2001 From: Boris Zimka Date: Wed, 27 Sep 2017 18:07:29 +0000 Subject: [PATCH 17/35] Added shift edit view; GET part --- __init__.py | 29 +++++++++ static/course-shifts-detail.underscore | 46 ++++++++++++++ static/course_shifts.html | 9 ++- static/course_shifts.js | 83 ++++++++++++++++++++++++-- urls.py | 4 +- 5 files changed, 161 insertions(+), 10 deletions(-) create mode 100644 static/course-shifts-detail.underscore diff --git a/__init__.py b/__init__.py index e69de29..49b458a 100644 --- a/__init__.py +++ b/__init__.py @@ -0,0 +1,29 @@ +from django.core.urlresolvers import reverse +from django.utils.translation import ugettext as _ + +from .models import CourseShiftSettings +from .serializers import CourseShiftSettingsSerializer + + +def _section_course_shifts(course, access): + + course_key = course.id + course_id = str(course_key) + url_settings = reverse('course_shifts:settings', kwargs={"course_id":course_id}) + url_list = reverse('course_shifts:list', kwargs={"course_id": course_id}) + url_detail = reverse('course_shifts:detail', kwargs={"course_id": course_id}) + + current_settings = CourseShiftSettings.get_course_settings(course_key) + if not current_settings.is_shift_enabled: + return {} + serial_settings = CourseShiftSettingsSerializer(current_settings) + section_data = { + 'section_key': 'course_shifts', + 'section_display_name': _('Course Shifts'), + 'access': access, + 'course_shifts_settings_url': url_settings, + 'course_shifts_list_url': url_list, + 'course_shifts_detail_url': url_detail, + 'current_settings': serial_settings.data, + } + return section_data \ No newline at end of file diff --git a/static/course-shifts-detail.underscore b/static/course-shifts-detail.underscore new file mode 100644 index 0000000..4923f33 --- /dev/null +++ b/static/course-shifts-detail.underscore @@ -0,0 +1,46 @@ +
+

<%- gettext("Shifts Editing View") %>

+
+
+ +
+
+ + + +
+ + + + + + + + + + + + + + + +
<%- gettext("Enrollement start")%>
<%- gettext("Enrollement finish")%>
<%- gettext("Users in shift")%>
+
+ +
+ +
+
diff --git a/static/course_shifts.html b/static/course_shifts.html index bfc3cef..dc8cfe7 100644 --- a/static/course_shifts.html +++ b/static/course_shifts.html @@ -10,7 +10,7 @@
-

${_("Settings Editor")}

+

${_("Settings Editor")}

% if section_data.get('current_settings'):
    @@ -43,12 +43,15 @@

    ${_("Settings Editor")}

- +
% else: -

${_("Course shifts are disabled. Actually, you shouldn't even see this page!")}

+

${_("Course shifts are disabled")}

% endif
+
+
+
diff --git a/static/course_shifts.js b/static/course_shifts.js index a9af3a9..8c8b2b8 100644 --- a/static/course_shifts.js +++ b/static/course_shifts.js @@ -20,9 +20,8 @@ this.$section.find('.request-response-error').hide(); this.$settings_submit.click(function() { - var sendData; ext.clear_display(); - sendData = { + var sendData = { enroll_after_days: ext.$enroll_after_days.val(), enroll_before_days: ext.$enroll_before_days.val(), is_autostart: ext.$is_autostart.filter(":checked").val() @@ -44,7 +43,7 @@ }); }); - var autostart_change = function (){ + this.autostart_change = function (){ var value = ext.$is_autostart.filter(":checked").val(); if (value == "True"){ ext.$autostart_period_days.attr("disabled", false); @@ -54,8 +53,82 @@ ext.$autostart_period_days.attr("disabled", true); } }; - autostart_change(); - this.$is_autostart.change(autostart_change); + this.autostart_change(); + this.$is_autostart.change(this.autostart_change); + this.$course_shifts_view = ext.$section.find('#course-shifts-view'); + + this.get_shift_list = function(handle){ + return $.ajax({ + type: 'GET', + dataType: 'json', + url: this.$course_shifts_view.data('url-list'), + success: function(data) { + return handle(data); + }, + error: function(xhr) { + return handle([]); + } + }); + }; + + this.render_list = function() { + this.get_shift_list(function (data) { + var rendered_shifts = edx.HtmlUtils.template($('#course-shifts-detail-tpl').text())({ + shifts_list: data + }); + ext.$course_shifts_view.html(rendered_shifts["text"]); + var select_shift = ext.$section.find("#shift-select"); + select_shift.change(function () { + ext.render_shift(this.value); + }) + }); + }; + + this.render_shift_info = function(data){ + var name_field = ext.$course_shifts_view.find("input[name='course-shift-name']"); + var date_field = ext.$course_shifts_view.find("input[name='course-shift-date']"); + var enroll_start_field = ext.$course_shifts_view.find("#current-shift-enrollement-start"); + var enroll_finish_field = ext.$course_shifts_view.find("#current-shift-enrollement-finish"); + var users_count = ext.$course_shifts_view.find("#current-shift-users-count"); + if ($.isEmptyObject(data)){ + name_field.attr("value", ''); + date_field.attr("value", ''); + enroll_start_field.html(''); + enroll_finish_field.html(''); + users_count.html(''); + return; + } + name_field.attr("value", data["name"]); + date_field.attr("value", data["start_date"]); + enroll_start_field.html(data["enroll_start"]); + enroll_finish_field.html(data["enroll_finish"]); + users_count.html(data["users_count"]); + }; + + this.render_shift = function(name){ + + if (name.includes("create-new-shift")){ + ext.render_shift_info({}); + return; + } + var data = {"name": name}; + return $.ajax({ + type: 'GET', + dataType: 'json', + url: this.$course_shifts_view.data('url-detail'), + data:data, + success: function(data) { + ext.render_shift_info(data); + }, + error: function(xhr) { + return ext.fail_with_error('course-shifts', 'Error getting shift data', xhr); + } + }); + }; + + + this.render_list(); + } course_shifts.prototype.clear_display = function() { diff --git a/urls.py b/urls.py index 2333bbd..c03092a 100644 --- a/urls.py +++ b/urls.py @@ -9,10 +9,10 @@ urlpatterns = patterns( 'course_shifts', url(r'^detail/{}/$'.format(settings.COURSE_ID_PATTERN), CourseShiftDetailView.as_view(), - name='course_shifts_detail'), + name='detail'), url(r'^settings/{}/$'.format(settings.COURSE_ID_PATTERN), CourseShiftSettingsView.as_view(), name='settings'), url(r'^{}/$'.format(settings.COURSE_ID_PATTERN), CourseShiftListView.as_view(), - name='course_shifts_list'), + name='list'), ) From 83f872dad2a48ccf588bb9966d3293795d8a3fda Mon Sep 17 00:00:00 2001 From: Boris Zimka Date: Thu, 28 Sep 2017 15:45:10 +0000 Subject: [PATCH 18/35] Added shift edit view; PATCH, POST and DELETE parts --- api.py | 44 ++++- models.py | 22 +++ serializers.py | 13 +- static/course-shifts-detail.underscore | 8 +- static/course_shifts.js | 225 +++++++++++++++++-------- 5 files changed, 232 insertions(+), 80 deletions(-) diff --git a/api.py b/api.py index 6d09246..40f5c6c 100644 --- a/api.py +++ b/api.py @@ -31,7 +31,7 @@ def post(self, request, course_id): course_key = serial_shift_settings.validated_data['course_key'] instance = CourseShiftSettings.get_course_settings(course_key) serial_shift_settings.update(instance, serial_shift_settings.validated_data) - return response.Response() + return response.Response({}) else: errors = serial_shift_settings.errors errors_by_key = [] @@ -94,14 +94,42 @@ def delete(self, request, course_id): if not shift: return error_response shift.delete() - return response.Response() + return response.Response({}) - def post(self, request, course_id): - data = {} - data["start_date"] = request.data.get("start_date") - data["name"] = request.data.get("name") + def patch(self, request, course_id): + name = request.data.get("name") + shift, error_response = self._get_shift(course_id, name) + if not shift: + return error_response + + data = { + "start_date": request.data.get("new_start_date"), + "name": request.data.get("new_name"), + } + if not data: + return response.Response(status=status.HTTP_400_BAD_REQUEST, data={"error": "Nothing to change"}) data['course_key'] = course_id - serial = CourseShiftSerializer(data=data, partial=True) + serial = CourseShiftSerializer(shift, data=data, partial=True) + + if not serial.is_valid(): + return response.Response(status=status.HTTP_400_BAD_REQUEST, data={"error": serial.error_dict()}) + try: + data = serial.validated_data + if data["start_date"]: + shift.set_start_date(data["start_date"]) + if data["name"]: + shift.set_name(data["name"]) + except ValueError as e: + return response.Response(status=status.HTTP_400_BAD_REQUEST, data={"error": e.message}) + return response.Response({}) + + def post(self, request, course_id): + data = { + "start_date": request.data.get("start_date"), + "name": request.data.get("name"), + 'course_key': course_id + } + serial = CourseShiftSerializer(data=data) if serial.is_valid(): kwargs = serial.validated_data else: @@ -122,4 +150,4 @@ def post(self, request, course_id): except Exception as e: error_message = e.message or str(e) return response.Response(status=status.HTTP_400_BAD_REQUEST, data={"error": error_message}) - return response.Response() + return response.Response({}) diff --git a/models.py b/models.py index 1e1c8c5..cb18dae 100644 --- a/models.py +++ b/models.py @@ -67,7 +67,29 @@ def settings(self): def settings(self, value): self._shift_settings = value + def set_name(self, value): + if self.name == value: + return + same_name_shifts = CourseShiftGroup.objects.filter(course_key=self.course_key, course_user_group__name=value) + if same_name_shifts.first(): + raise ValueError("Shift with name {} already exists for {}".format(value, str(self.course_key))) + self.course_user_group.name = value + self.course_user_group.save() + + def set_start_date(self, value): + if self.start_date == value: + return + same_start_date_shifts = CourseShiftGroup.objects.filter(course_key=self.course_key, start_date=value) + if same_start_date_shifts.first(): + raise ValueError("Shift with start date {} already exists for {}".format(str(value), str(self.course_key))) + self.start_date = value + self.save() + def get_shifted_date(self, user, date): + """ + Returns shifted due or start date according to + the settings + """ if user not in self.users.all(): raise ValueError("User '{}' is not in shift '{}'".format( user.username, diff --git a/serializers.py b/serializers.py index c1d2437..94a5c14 100644 --- a/serializers.py +++ b/serializers.py @@ -53,7 +53,7 @@ def validate_autostart_period_days(self, value): class CourseShiftSerializer(serializers.ModelSerializer): - course_key = CourseKeyField() + course_key = CourseKeyField(required=False) name = serializers.CharField(max_length=255, allow_null=True) start_date = serializers.DateField(allow_null=True) @@ -64,3 +64,14 @@ class Meta: 'name', 'start_date', ) + + def error_dict(self): + errors = self.errors + errors_by_key = {} + for key in errors.keys(): + if not errors[key]: + continue + message = u";".join(unicode(x) for x in errors[key]) + errors_by_key[key] = message + print(errors_by_key) + return errors_by_key \ No newline at end of file diff --git a/static/course-shifts-detail.underscore b/static/course-shifts-detail.underscore index 4923f33..5dae12b 100644 --- a/static/course-shifts-detail.underscore +++ b/static/course-shifts-detail.underscore @@ -20,7 +20,7 @@
- + @@ -37,9 +37,13 @@
- + + diff --git a/static/course_shifts.js b/static/course_shifts.js index 8c8b2b8..044fd0c 100644 --- a/static/course_shifts.js +++ b/static/course_shifts.js @@ -45,92 +45,170 @@ this.autostart_change = function (){ var value = ext.$is_autostart.filter(":checked").val(); - if (value == "True"){ + if (value.includes("True")){ ext.$autostart_period_days.attr("disabled", false); } - if (value == "False"){ + if (value.includes("False")){ ext.$autostart_period_days.val(null); ext.$autostart_period_days.attr("disabled", true); } }; - this.autostart_change(); this.$is_autostart.change(this.autostart_change); this.$course_shifts_view = ext.$section.find('#course-shifts-view'); + this.create_shift_code = 'create-new-shift'; + this.autostart_change(); + this.render_shift_view(); + } - this.get_shift_list = function(handle){ - return $.ajax({ - type: 'GET', - dataType: 'json', - url: this.$course_shifts_view.data('url-list'), - success: function(data) { - return handle(data); - }, - error: function(xhr) { - return handle([]); - } + course_shifts.prototype.render_shift_view = function() { + var ext = this; + var render = function (data) { + var rendered_shifts = edx.HtmlUtils.template($('#course-shifts-detail-tpl').text())({ + shifts_list: data }); - }; - - this.render_list = function() { - this.get_shift_list(function (data) { - var rendered_shifts = edx.HtmlUtils.template($('#course-shifts-detail-tpl').text())({ - shifts_list: data - }); - ext.$course_shifts_view.html(rendered_shifts["text"]); - var select_shift = ext.$section.find("#shift-select"); - select_shift.change(function () { - ext.render_shift(this.value); - }) + ext.$course_shifts_view.html(rendered_shifts["text"]); + var select_shift = ext.$section.find("#shift-select"); + select_shift.change(function () { + ext.render_shift(this.value); }); - }; - this.render_shift_info = function(data){ - var name_field = ext.$course_shifts_view.find("input[name='course-shift-name']"); - var date_field = ext.$course_shifts_view.find("input[name='course-shift-date']"); - var enroll_start_field = ext.$course_shifts_view.find("#current-shift-enrollement-start"); - var enroll_finish_field = ext.$course_shifts_view.find("#current-shift-enrollement-finish"); - var users_count = ext.$course_shifts_view.find("#current-shift-users-count"); - if ($.isEmptyObject(data)){ - name_field.attr("value", ''); - date_field.attr("value", ''); - enroll_start_field.html(''); - enroll_finish_field.html(''); - users_count.html(''); - return; - } - name_field.attr("value", data["name"]); - date_field.attr("value", data["start_date"]); - enroll_start_field.html(data["enroll_start"]); - enroll_finish_field.html(data["enroll_finish"]); - users_count.html(data["users_count"]); - }; + ext.render_shift(select_shift.val()); - this.render_shift = function(name){ + ext.$submit_shift_view_button = ext.$course_shifts_view.find("#change-create-shift-button"); + var shift_view_submit_clicked = function() { + var name = ext.$course_shifts_view.find("input[name='course-shift-name']").attr("value"); + var date = ext.$course_shifts_view.find("input[name='course-shift-date']").attr("value"); + var select = ext.$course_shifts_view.find("#shift-select").val(); + if (select.includes(ext.create_shift_code)) { + data = {}; + if (name) { + data["name"] = name; + } + if (date) { + data["start_date"] = date; + } + return $.ajax({ + type: 'POST', + dataType: 'json', + url: ext.$course_shifts_view.data('url-detail'), + data: data, + success: function (data) { + ext.display_response('course-shifts', data); + ext.render_shift_view(); + }, + error: function (xhr) { + return ext.fail_with_error('course-shifts', 'Error getting shift data', xhr); + } + }); + } + else { + data = {}; + data["name"] = select; + if (name) { + data["new_name"] = name; + } + if (date) { + data["new_start_date"] = date; + } + return $.ajax({ + type: 'PATCH', + dataType: 'json', + url: ext.$course_shifts_view.data('url-detail'), + data: data, + success: function (data) { + ext.display_response('course-shifts', data); + ext.render_shift_view(); + }, + error: function (xhr) { + return ext.fail_with_error('course-shifts', 'Error getting shift data', xhr); + } + }); + } + }; + ext.$submit_shift_view_button.click(shift_view_submit_clicked); - if (name.includes("create-new-shift")){ - ext.render_shift_info({}); - return; - } - var data = {"name": name}; - return $.ajax({ - type: 'GET', - dataType: 'json', - url: this.$course_shifts_view.data('url-detail'), - data:data, - success: function(data) { - ext.render_shift_info(data); - }, - error: function(xhr) { - return ext.fail_with_error('course-shifts', 'Error getting shift data', xhr); + ext.$delete_shift_button = ext.$course_shifts_view.find("#delete-shift-button"); + ext.$delete_shift_button.click(function () { + ext.clear_display(); + var select = ext.$course_shifts_view.find("#shift-select").val(); + if (select.includes(ext.create_shift_code)){ + return } - }); + data = {"name":select}; + return $.ajax({ + type: 'DELETE', + dataType: 'json', + url: ext.$course_shifts_view.data('url-detail'), + data: data, + success: function (data) { + ext.display_response('course-shifts', data); + ext.render_shift_view(); + }, + error: function (xhr) { + return ext.fail_with_error('course-shifts', 'Error deleting shift:', xhr); + } + }) + }) + }; + return $.ajax({ + type: 'GET', + dataType: 'json', + url: this.$course_shifts_view.data('url-list'), + success: function(data) { + return render(data); + }, + error: function(xhr) { + return render([]); + } + }); + }; - this.render_list(); + course_shifts.prototype.render_shift = function(name){ + var ext = this; + var render_shift_info = function(data){ + var name_field = ext.$course_shifts_view.find("input[name='course-shift-name']"); + var date_field = ext.$course_shifts_view.find("input[name='course-shift-date']"); + var enroll_start_field = ext.$course_shifts_view.find("#current-shift-enrollement-start"); + var enroll_finish_field = ext.$course_shifts_view.find("#current-shift-enrollement-finish"); + var users_count = ext.$course_shifts_view.find("#current-shift-users-count"); + if ($.isEmptyObject(data)){ + name_field.attr("value", ''); + date_field.attr("value", ''); + enroll_start_field.html(''); + enroll_finish_field.html(''); + users_count.html(''); + return; + } + name_field.attr("value", data["name"]); + date_field.attr("value", data["start_date"]); + enroll_start_field.html(data["enroll_start"]); + enroll_finish_field.html(data["enroll_finish"]); + users_count.html(data["users_count"]); + }; + if (name.includes(ext.create_shift_code)){ + render_shift_info({}); + return; + } + var data = {"name": name}; + return $.ajax({ + type: 'GET', + dataType: 'json', + url: this.$course_shifts_view.data('url-detail'), + data:data, + success: function(data) { + render_shift_info(data); + }, + error: function(xhr) { + return ext.fail_with_error('course-shifts', 'Error getting shift data', xhr); + } + }); + }; - } + course_shifts.prototype.shift_view_submit_clicked = function (ext) { + }; course_shifts.prototype.clear_display = function() { this.$section.find('.request-response-error').empty().hide(); return this.$section.find('.request-response').empty().hide(); @@ -141,10 +219,11 @@ $taskError = this.$section.find('#' + id + ' .request-response-error'); $taskResponse = this.$section.find('#' + id + ' .request-response'); $taskError.empty().hide(); - if (!data){ + if ($.isEmptyObject(data)){ data = "Success."; } - $taskResponse.empty().text(data); + var message = data; + $taskResponse.empty().text(message); return $taskResponse.show(); }; @@ -155,7 +234,15 @@ $taskResponse = this.$section.find('#' + id + ' .request-response'); this.clear_display(); data = $.parseJSON(xhr.responseText); - message += ': ' + data.error; + + var error_message = data.error; + if ($.type(error_message) != 'string'){ + error_message = ''; + for (var key in data.error){ + error_message += key + ":" +data.error[key] +"
"; + } + } + message += ': ' + error_message; $taskResponse.empty(); $taskError.empty(); $taskError.text(message); From 2b80f457d7b9de93e986d0656c0822aae10737ae Mon Sep 17 00:00:00 2001 From: Boris Zimka Date: Thu, 28 Sep 2017 16:35:38 +0000 Subject: [PATCH 19/35] Added user api and view --- __init__.py | 2 ++ api.py | 26 +++++++++++++++++++++++- static/course-shifts-detail.underscore | 9 ++++++++- static/course_shifts.html | 5 ++++- static/course_shifts.js | 28 ++++++++++++++++++++++++++ urls.py | 4 +++- 6 files changed, 70 insertions(+), 4 deletions(-) diff --git a/__init__.py b/__init__.py index 49b458a..84e3a26 100644 --- a/__init__.py +++ b/__init__.py @@ -12,6 +12,7 @@ def _section_course_shifts(course, access): url_settings = reverse('course_shifts:settings', kwargs={"course_id":course_id}) url_list = reverse('course_shifts:list', kwargs={"course_id": course_id}) url_detail = reverse('course_shifts:detail', kwargs={"course_id": course_id}) + url_membership = reverse('course_shifts:membership', kwargs={"course_id": course_id}) current_settings = CourseShiftSettings.get_course_settings(course_key) if not current_settings.is_shift_enabled: @@ -24,6 +25,7 @@ def _section_course_shifts(course, access): 'course_shifts_settings_url': url_settings, 'course_shifts_list_url': url_list, 'course_shifts_detail_url': url_detail, + 'course_shifts_membership_url':url_membership, 'current_settings': serial_settings.data, } return section_data \ No newline at end of file diff --git a/api.py b/api.py index 40f5c6c..2697aed 100644 --- a/api.py +++ b/api.py @@ -5,7 +5,7 @@ from .models import CourseShiftSettings, CourseShiftGroup from .serializers import CourseShiftSettingsSerializer, CourseShiftSerializer from .manager import CourseShiftManager - +from django.contrib.auth.models import User class CourseShiftSettingsView(views.APIView): """ @@ -151,3 +151,27 @@ def post(self, request, course_id): error_message = e.message or str(e) return response.Response(status=status.HTTP_400_BAD_REQUEST, data={"error": error_message}) return response.Response({}) + + +class CourseShiftUserView(views.APIView): + + def post(self, request, course_id): + course_key = CourseKey.from_string(course_id) + shift_name = request.data.get("shift_name") + shift_manager = CourseShiftManager(course_key) + shift = shift_manager.get_shift(shift_name) + if not shift: + message = "Shift with name {} not found for {}".format(shift_name, course_key) + return response.Response(status=status.HTTP_400_BAD_REQUEST, data={"error": message}) + + username = request.data.get("username") + try: + user = User.objects.get(username=username) + except User.DoesNotExist: + message = "User with username {} not found".format(username) + return response.Response(status=status.HTTP_400_BAD_REQUEST, data={"error": message}) + try: + shift_manager.enroll_user(user, shift, forced=True) + except ValueError as e: + return response.Response(status=status.HTTP_400_BAD_REQUEST, data={"error": e.message}) + return response.Response({}) \ No newline at end of file diff --git a/static/course-shifts-detail.underscore b/static/course-shifts-detail.underscore index 5dae12b..5e26125 100644 --- a/static/course-shifts-detail.underscore +++ b/static/course-shifts-detail.underscore @@ -43,7 +43,14 @@ - +
+ +
+ +
+ diff --git a/static/course_shifts.html b/static/course_shifts.html index dc8cfe7..1912347 100644 --- a/static/course_shifts.html +++ b/static/course_shifts.html @@ -50,7 +50,10 @@

${_("Settings Editor")}

% endif
-
+
diff --git a/static/course_shifts.js b/static/course_shifts.js index 044fd0c..7f64548 100644 --- a/static/course_shifts.js +++ b/static/course_shifts.js @@ -148,6 +148,34 @@ return ext.fail_with_error('course-shifts', 'Error deleting shift:', xhr); } }) + }); + + ext.$delete_shift_button = ext.$course_shifts_view.find("#course-shift-add-user-button"); + ext.$delete_shift_button.click(function () { + ext.clear_display(); + var select_value = ext.$course_shifts_view.find("#shift-select").val(); + if (select_value.includes(ext.create_shift_code)){ + return + } + var username_add = ext.$course_shifts_view.find("input[name='course-shift-username-add']"); + var data = { + shift_name:select_value, + username:username_add.attr("value") + }; + + return $.ajax({ + type: 'POST', + dataType: 'json', + url: ext.$course_shifts_view.data('url-membership'), + data: data, + success: function (data) { + ext.display_response('course-shifts', data); + ext.render_shift_view(); + }, + error: function (xhr) { + return ext.fail_with_error('course-shifts', 'Error adding user:', xhr); + } + }) }) }; diff --git a/urls.py b/urls.py index c03092a..60fc0f4 100644 --- a/urls.py +++ b/urls.py @@ -4,12 +4,14 @@ from django.conf import settings from django.conf.urls import patterns, url -from .api import CourseShiftSettingsView, CourseShiftListView, CourseShiftDetailView +from .api import CourseShiftSettingsView, CourseShiftListView, CourseShiftDetailView, CourseShiftUserView urlpatterns = patterns( 'course_shifts', url(r'^detail/{}/$'.format(settings.COURSE_ID_PATTERN), CourseShiftDetailView.as_view(), name='detail'), + url(r'^membership/{}$'.format(settings.COURSE_ID_PATTERN), CourseShiftUserView.as_view(), + name='membership'), url(r'^settings/{}/$'.format(settings.COURSE_ID_PATTERN), CourseShiftSettingsView.as_view(), name='settings'), url(r'^{}/$'.format(settings.COURSE_ID_PATTERN), CourseShiftListView.as_view(), From 7b8d665437cd5686a5e96747957ebddfd9978533 Mon Sep 17 00:00:00 2001 From: Boris Zimka Date: Fri, 29 Sep 2017 11:35:23 +0000 Subject: [PATCH 20/35] UI fixes --- api.py | 13 +++-- static/course-shifts-detail.underscore | 48 ++++++++------- static/course_shifts.html | 75 ++++++++++++------------ static/course_shifts.js | 81 +++++++++++++++----------- 4 files changed, 119 insertions(+), 98 deletions(-) diff --git a/api.py b/api.py index 2697aed..f8b2f12 100644 --- a/api.py +++ b/api.py @@ -7,12 +7,12 @@ from .manager import CourseShiftManager from django.contrib.auth.models import User + class CourseShiftSettingsView(views.APIView): """ Allows instructor to edit course shift settings """ - #permission_classes = (permissions.IsAuthenticated, IsStaffOrOwner) - permissions = tuple() + permission_classes = (permissions.IsAuthenticated, IsStaffOrOwner) def get(self, request, course_id): course_key = CourseKey.from_string(course_id) @@ -63,8 +63,9 @@ def list(self, request, course_id): class CourseShiftDetailView(views.APIView): """ - Allows instructor to watch, to create and to delete course_shifts + Allows instructor to watch, to create, to modify and to delete course shifts """ + permission_classes = (permissions.IsAuthenticated, IsStaffOrOwner) def _get_shift(self, course_id, name): course_key = CourseKey.from_string(course_id) @@ -154,6 +155,10 @@ def post(self, request, course_id): class CourseShiftUserView(views.APIView): + """ + Allows instructor to add users to shifts + """ + permission_classes = (permissions.IsAuthenticated, IsStaffOrOwner) def post(self, request, course_id): course_key = CourseKey.from_string(course_id) @@ -174,4 +179,4 @@ def post(self, request, course_id): shift_manager.enroll_user(user, shift, forced=True) except ValueError as e: return response.Response(status=status.HTTP_400_BAD_REQUEST, data={"error": e.message}) - return response.Response({}) \ No newline at end of file + return response.Response({}) diff --git a/static/course-shifts-detail.underscore b/static/course-shifts-detail.underscore index 5e26125..6a56301 100644 --- a/static/course-shifts-detail.underscore +++ b/static/course-shifts-detail.underscore @@ -1,7 +1,7 @@ -
-

<%- gettext("Shifts Editing View") %>

-
-
+
+

<%- gettext("Shifts Editing View") %>

+
+

- - - -
- - - + + +
+ + +
@@ -37,21 +36,28 @@
<%- gettext("Enrollement start")%>
+ - -
- -
- -
-
- +
+
+
+
+
+ +
+ +
+ +
+
+
diff --git a/static/course_shifts.html b/static/course_shifts.html index 1912347..a9f474f 100644 --- a/static/course_shifts.html +++ b/static/course_shifts.html @@ -8,46 +8,45 @@
-
-
+
+

${_("Settings Editor")}

- % if section_data.get('current_settings'): -
-
    - -
  • - True - False - +
    +
      + +
    • + True + False + -
    • -
    • - - -
    • -
    • - - -
    • -
    • - - -
    • -
    +
  • +
  • + + +
  • +
  • + + +
  • +
  • + + +
  • +
- -
- % else: -

${_("Course shifts are disabled")}

- % endif + +
+
+

${_("Settings Editor")} data-url-detail="${section_data['course_shifts_detail_url']}" data-url-membership="${section_data['course_shifts_membership_url']}">
-
-
diff --git a/static/course_shifts.js b/static/course_shifts.js index 7f64548..e340a10 100644 --- a/static/course_shifts.js +++ b/static/course_shifts.js @@ -26,7 +26,7 @@ enroll_before_days: ext.$enroll_before_days.val(), is_autostart: ext.$is_autostart.filter(":checked").val() }; - if (ext.$autostart_period_days){ + if (!(ext.$autostart_period_days.attr("disabled"))){ sendData['autostart_period_days'] = ext.$autostart_period_days.val(); } return $.ajax({ @@ -35,10 +35,11 @@ url: ext.$settings_submit.data('endpoint'), data: sendData, success: function(data) { - return ext.display_response('course-shifts', data); + ext.render_shift_view(); + return ext.display_response('course-shifts-settings-editor', data); }, error: function(xhr) { - return ext.fail_with_error('course-shifts', 'Error changing settings', xhr); + return ext.fail_with_error('course-shifts-settings-editor', 'Error changing settings', xhr); } }); }); @@ -46,6 +47,7 @@ this.autostart_change = function (){ var value = ext.$is_autostart.filter(":checked").val(); if (value.includes("True")){ + ext.$autostart_period_days.val(ext.$autostart_period_days.data('default-value')); ext.$autostart_period_days.attr("disabled", false); } if (value.includes("False")){ @@ -62,6 +64,10 @@ course_shifts.prototype.render_shift_view = function() { var ext = this; + if (ext.$is_autostart.filter(":checked").val().includes("True")){ + ext.$course_shifts_view.html(''); + return + } var render = function (data) { var rendered_shifts = edx.HtmlUtils.template($('#course-shifts-detail-tpl').text())({ shifts_list: data @@ -93,11 +99,11 @@ url: ext.$course_shifts_view.data('url-detail'), data: data, success: function (data) { - ext.display_response('course-shifts', data); + ext.display_response('course-shift-edit-view', data); ext.render_shift_view(); }, error: function (xhr) { - return ext.fail_with_error('course-shifts', 'Error getting shift data', xhr); + return ext.fail_with_error('course-shift-edit-view', 'Error creating shift', xhr); } }); } @@ -116,11 +122,11 @@ url: ext.$course_shifts_view.data('url-detail'), data: data, success: function (data) { - ext.display_response('course-shifts', data); + ext.display_response('course-shift-edit-view', data); ext.render_shift_view(); }, error: function (xhr) { - return ext.fail_with_error('course-shifts', 'Error getting shift data', xhr); + return ext.fail_with_error('course-shift-edit-view', 'Error updating shift info', xhr); } }); } @@ -141,17 +147,17 @@ url: ext.$course_shifts_view.data('url-detail'), data: data, success: function (data) { - ext.display_response('course-shifts', data); + ext.display_response('course-shift-edit-view', data); ext.render_shift_view(); }, error: function (xhr) { - return ext.fail_with_error('course-shifts', 'Error deleting shift:', xhr); + return ext.fail_with_error('course-shift-edit-view', 'Error deleting shift', xhr); } }) }); - ext.$delete_shift_button = ext.$course_shifts_view.find("#course-shift-add-user-button"); - ext.$delete_shift_button.click(function () { + ext.$user_add_button= ext.$course_shifts_view.find("#course-shift-add-user-button"); + ext.$user_add_button.click(function () { ext.clear_display(); var select_value = ext.$course_shifts_view.find("#shift-select").val(); if (select_value.includes(ext.create_shift_code)){ @@ -169,15 +175,15 @@ url: ext.$course_shifts_view.data('url-membership'), data: data, success: function (data) { - ext.display_response('course-shifts', data); + ext.display_response('course-shift-view-user', data); ext.render_shift_view(); }, error: function (xhr) { - return ext.fail_with_error('course-shifts', 'Error adding user:', xhr); + return ext.fail_with_error('course-shift-view-user', 'Error adding user', xhr); } }) - }) - + }); + ext.clear_display(); }; return $.ajax({ @@ -195,26 +201,32 @@ course_shifts.prototype.render_shift = function(name){ var ext = this; + ext.clear_display(); var render_shift_info = function(data){ - var name_field = ext.$course_shifts_view.find("input[name='course-shift-name']"); - var date_field = ext.$course_shifts_view.find("input[name='course-shift-date']"); - var enroll_start_field = ext.$course_shifts_view.find("#current-shift-enrollement-start"); - var enroll_finish_field = ext.$course_shifts_view.find("#current-shift-enrollement-finish"); - var users_count = ext.$course_shifts_view.find("#current-shift-users-count"); - if ($.isEmptyObject(data)){ - name_field.attr("value", ''); - date_field.attr("value", ''); - enroll_start_field.html(''); - enroll_finish_field.html(''); - users_count.html(''); - return; - } - name_field.attr("value", data["name"]); - date_field.attr("value", data["start_date"]); - enroll_start_field.html(data["enroll_start"]); - enroll_finish_field.html(data["enroll_finish"]); - users_count.html(data["users_count"]); - }; + var name_field = ext.$course_shifts_view.find("input[name='course-shift-name']"); + var date_field = ext.$course_shifts_view.find("input[name='course-shift-date']"); + var enroll_start_field = ext.$course_shifts_view.find("#current-shift-enrollement-start"); + var enroll_finish_field = ext.$course_shifts_view.find("#current-shift-enrollement-finish"); + var users_count = ext.$course_shifts_view.find("#current-shift-users-count"); + var create_shift_disable = ext.$course_shifts_view.find(".create-shift-disable"); + if ($.isEmptyObject(data)){ + name_field.attr("value", ''); + date_field.attr("value", ''); + enroll_start_field.html(''); + enroll_finish_field.html(''); + users_count.html(''); + create_shift_disable.attr("disabled", true); + return; + } + name_field.attr("value", data["name"]); + date_field.attr("value", data["start_date"]); + enroll_start_field.html(data["enroll_start"]); + enroll_finish_field.html(data["enroll_finish"]); + users_count.html(data["users_count"]); + if (create_shift_disable.attr("disabled")){ + create_shift_disable.attr("disabled", false); + } + }; if (name.includes(ext.create_shift_code)){ render_shift_info({}); return; @@ -237,6 +249,7 @@ course_shifts.prototype.shift_view_submit_clicked = function (ext) { }; + course_shifts.prototype.clear_display = function() { this.$section.find('.request-response-error').empty().hide(); return this.$section.find('.request-response').empty().hide(); From d24cb9280a7b63b649e06a0778c5bc4656364f00 Mon Sep 17 00:00:00 2001 From: Boris Zimka Date: Fri, 29 Sep 2017 15:12:44 +0000 Subject: [PATCH 21/35] Edx files that are modified to make feature work --- modified/common.djangoapps.student.views.py | 2746 +++++++++++++++++ .../lms.djangoapps.courseware.views.views.py | 1632 ++++++++++ ...lms.templates.courseware.course_about.html | 348 +++ 3 files changed, 4726 insertions(+) create mode 100644 modified/common.djangoapps.student.views.py create mode 100644 modified/lms.djangoapps.courseware.views.views.py create mode 100644 modified/lms.templates.courseware.course_about.html diff --git a/modified/common.djangoapps.student.views.py b/modified/common.djangoapps.student.views.py new file mode 100644 index 0000000..ccd6503 --- /dev/null +++ b/modified/common.djangoapps.student.views.py @@ -0,0 +1,2746 @@ +""" +Student Views +""" +import datetime +import logging +import uuid +import json +import warnings +from collections import defaultdict +from urlparse import urljoin, urlsplit, parse_qs, urlunsplit + +from django.views.generic import TemplateView +from pytz import UTC +from requests import HTTPError +from ipware.ip import get_ip + +import edx_oauth2_provider +from django.conf import settings +from django.contrib.auth import logout, authenticate, login +from django.contrib.auth.models import User, AnonymousUser +from django.contrib.auth.decorators import login_required +from django.contrib.auth.views import password_reset_confirm +from django.contrib import messages +from django.core.context_processors import csrf +from django.core import mail +from django.core.exceptions import PermissionDenied, ObjectDoesNotExist +from django.core.urlresolvers import reverse, NoReverseMatch, reverse_lazy +from django.core.validators import validate_email, ValidationError +from django.db import IntegrityError, transaction +from django.http import HttpResponse, HttpResponseBadRequest, HttpResponseForbidden, HttpResponseServerError, Http404 +from django.shortcuts import redirect +from django.utils.encoding import force_bytes, force_text +from django.utils.translation import ungettext +from django.utils.http import base36_to_int, is_safe_url, urlsafe_base64_encode, urlencode +from django.utils.translation import ugettext as _, get_language +from django.views.decorators.csrf import csrf_exempt, ensure_csrf_cookie +from django.views.decorators.http import require_POST, require_GET +from django.db.models.signals import post_save +from django.dispatch import receiver, Signal +from django.template.response import TemplateResponse +from provider.oauth2.models import Client +from ratelimitbackend.exceptions import RateLimitException + +from social.apps.django_app import utils as social_utils +from social.backends import oauth as social_oauth +from social.exceptions import AuthException, AuthAlreadyAssociated + +from edxmako.shortcuts import render_to_response, render_to_string + +from course_modes.models import CourseMode +from shoppingcart.api import order_history +from student.models import ( + Registration, UserProfile, + PendingEmailChange, CourseEnrollment, CourseEnrollmentAttribute, unique_id_for_user, + CourseEnrollmentAllowed, UserStanding, LoginFailures, + create_comments_service_user, PasswordHistory, UserSignupSource, + DashboardConfiguration, LinkedInAddToProfileConfiguration, ManualEnrollmentAudit, ALLOWEDTOENROLL_TO_ENROLLED, + LogoutViewConfiguration, RegistrationCookieConfiguration) +from student.forms import AccountCreationForm, PasswordResetFormNoActive, get_registration_extension_form +from student.tasks import send_activation_email +from lms.djangoapps.commerce.utils import EcommerceService # pylint: disable=import-error +from lms.djangoapps.verify_student.models import SoftwareSecurePhotoVerification # pylint: disable=import-error +from bulk_email.models import Optout, BulkEmailFlag # pylint: disable=import-error +from certificates.models import ( # pylint: disable=import-error + CertificateStatuses, GeneratedCertificate, certificate_status_for_student +) +from certificates.api import ( # pylint: disable=import-error + get_certificate_url, + has_html_certificates_enabled, +) +from lms.djangoapps.grades.new.course_grade_factory import CourseGradeFactory + +from xmodule.modulestore.django import modulestore +from opaque_keys import InvalidKeyError +from opaque_keys.edx.keys import CourseKey +from opaque_keys.edx.locations import SlashSeparatedCourseKey +from opaque_keys.edx.locator import CourseLocator + +from collections import namedtuple + +from courseware.courses import get_courses, sort_by_announcement, sort_by_start_date # pylint: disable=import-error +from courseware.access import has_access + +from django_comment_common.models import Role + +from openedx.core.djangoapps.external_auth.models import ExternalAuthMap +import openedx.core.djangoapps.external_auth.views +from openedx.core.djangoapps.external_auth.login_and_register import ( + login as external_auth_login, + register as external_auth_register +) +from openedx.core.djangoapps import monitoring_utils + +import track.views + +import dogstats_wrapper as dog_stats_api + +from util.db import outer_atomic +from util.json_request import JsonResponse +from util.bad_request_rate_limiter import BadRequestRateLimiter +from util.milestones_helpers import ( + get_pre_requisite_courses_not_completed, +) + +from util.password_policy_validators import validate_password_strength +import third_party_auth +from third_party_auth import pipeline, provider +from student.helpers import ( + check_verify_status_by_course, + auth_pipeline_urls, get_next_url_for_login_page, + DISABLE_UNENROLL_CERT_STATES, + destroy_oauth_tokens +) +from student.cookies import set_logged_in_cookies, delete_logged_in_cookies, set_user_info_cookie +from student.models import anonymous_id_for_user, UserAttribute, EnrollStatusChange +from shoppingcart.models import DonationConfiguration, CourseRegistrationCode + +from openedx.core.djangoapps.embargo import api as embargo_api +from openedx.features.enterprise_support.api import get_dashboard_consent_notification + +import analytics +from eventtracking import tracker + +# Note that this lives in LMS, so this dependency should be refactored. +from notification_prefs.views import enable_notifications + +from openedx.core.djangoapps.catalog.utils import get_programs_with_type +from openedx.core.djangoapps.credit.email_utils import get_credit_provider_display_names, make_providers_strings +from openedx.core.djangoapps.lang_pref import LANGUAGE_KEY +from openedx.core.djangoapps.programs.models import ProgramsApiConfig +from openedx.core.djangoapps.programs.utils import ProgramProgressMeter +from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers +from openedx.core.djangoapps.theming import helpers as theming_helpers +from openedx.core.djangoapps.user_api.preferences import api as preferences_api + + +log = logging.getLogger("edx.student") +AUDIT_LOG = logging.getLogger("audit") +ReverifyInfo = namedtuple('ReverifyInfo', 'course_id course_name course_number date status display') # pylint: disable=invalid-name +SETTING_CHANGE_INITIATED = 'edx.user.settings.change_initiated' +# Used as the name of the user attribute for tracking affiliate registrations +REGISTRATION_AFFILIATE_ID = 'registration_affiliate_id' +REGISTRATION_UTM_PARAMETERS = { + 'utm_source': 'registration_utm_source', + 'utm_medium': 'registration_utm_medium', + 'utm_campaign': 'registration_utm_campaign', + 'utm_term': 'registration_utm_term', + 'utm_content': 'registration_utm_content', +} +REGISTRATION_UTM_CREATED_AT = 'registration_utm_created_at' +# used to announce a registration +REGISTER_USER = Signal(providing_args=["user", "profile"]) + +# Disable this warning because it doesn't make sense to completely refactor tests to appease Pylint +# pylint: disable=logging-format-interpolation + + +def csrf_token(context): + """A csrf token that can be included in a form.""" + token = context.get('csrf_token', '') + if token == 'NOTPROVIDED': + return '' + return (u'
' % (token)) + + +# NOTE: This view is not linked to directly--it is called from +# branding/views.py:index(), which is cached for anonymous users. +# This means that it should always return the same thing for anon +# users. (in particular, no switching based on query params allowed) +def index(request, extra_context=None, user=AnonymousUser()): + """ + Render the edX main page. + + extra_context is used to allow immediate display of certain modal windows, eg signup, + as used by external_auth. + """ + if extra_context is None: + extra_context = {} + + programs_list = [] + courses = get_courses(user) + + if configuration_helpers.get_value( + "ENABLE_COURSE_SORTING_BY_START_DATE", + settings.FEATURES["ENABLE_COURSE_SORTING_BY_START_DATE"], + ): + courses = sort_by_start_date(courses) + else: + courses = sort_by_announcement(courses) + + context = {'courses': courses} + + context['homepage_overlay_html'] = configuration_helpers.get_value('homepage_overlay_html') + + # This appears to be an unused context parameter, at least for the master templates... + context['show_partners'] = configuration_helpers.get_value('show_partners', True) + + # TO DISPLAY A YOUTUBE WELCOME VIDEO + # 1) Change False to True + context['show_homepage_promo_video'] = configuration_helpers.get_value('show_homepage_promo_video', False) + + # 2) Add your video's YouTube ID (11 chars, eg "123456789xX"), or specify via site configuration + # Note: This value should be moved into a configuration setting and plumbed-through to the + # context via the site configuration workflow, versus living here + youtube_video_id = configuration_helpers.get_value('homepage_promo_video_youtube_id', "your-youtube-id") + context['homepage_promo_video_youtube_id'] = youtube_video_id + + # allow for theme override of the courses list + context['courses_list'] = theming_helpers.get_template_path('courses_list.html') + + # Insert additional context for use in the template + context.update(extra_context) + + # Get the active programs of the type configured for the current site from the catalog service. The programs_list + # is being added to the context but it's not being used currently in courseware/courses.html. To use this list, + # you need to create a custom theme that overrides courses.html. The modifications to courses.html to display the + # programs will be done after the support for edx-pattern-library is added. + program_types = configuration_helpers.get_value('ENABLED_PROGRAM_TYPES') + + # Do not add programs to the context if there are no program types enabled for the site. + if program_types: + programs_list = get_programs_with_type(program_types) + + context["programs_list"] = programs_list + + return render_to_response('index.html', context) + + +def process_survey_link(survey_link, user): + """ + If {UNIQUE_ID} appears in the link, replace it with a unique id for the user. + Currently, this is sha1(user.username). Otherwise, return survey_link. + """ + return survey_link.format(UNIQUE_ID=unique_id_for_user(user)) + + +def cert_info(user, course_overview, course_mode): + """ + Get the certificate info needed to render the dashboard section for the given + student and course. + + Arguments: + user (User): A user. + course_overview (CourseOverview): A course. + course_mode (str): The enrollment mode (honor, verified, audit, etc.) + + Returns: + dict: Empty dict if certificates are disabled or hidden, or a dictionary with keys: + 'status': one of 'generating', 'ready', 'notpassing', 'processing', 'restricted' + 'show_download_url': bool + 'download_url': url, only present if show_download_url is True + 'show_disabled_download_button': bool -- true if state is 'generating' + 'show_survey_button': bool + 'survey_url': url, only if show_survey_button is True + 'grade': if status is not 'processing' + 'can_unenroll': if status allows for unenrollment + """ + if not course_overview.may_certify(): + return {} + return _cert_info( + user, + course_overview, + certificate_status_for_student(user, course_overview.id), + course_mode + ) + + +def reverification_info(statuses): + """ + Returns reverification-related information for *all* of user's enrollments whose + reverification status is in statuses. + + Args: + statuses (list): a list of reverification statuses we want information for + example: ["must_reverify", "denied"] + + Returns: + dictionary of lists: dictionary with one key per status, e.g. + dict["must_reverify"] = [] + dict["must_reverify"] = [some information] + """ + reverifications = defaultdict(list) + + # Sort the data by the reverification_end_date + for status in statuses: + if reverifications[status]: + reverifications[status].sort(key=lambda x: x.date) + return reverifications + + +def get_course_enrollments(user, orgs_to_include, orgs_to_exclude): + """ + Given a user, return a filtered set of his or her course enrollments. + + Arguments: + user (User): the user in question. + orgs_to_include (list[str]): If not None, ONLY courses of these orgs will be returned. + orgs_to_exclude (list[str]): If orgs_to_include is not None, this + argument is ignored. Else, courses of this org will be excluded. + + Returns: + generator[CourseEnrollment]: a sequence of enrollments to be displayed + on the user's dashboard. + """ + for enrollment in CourseEnrollment.enrollments_for_user_with_overviews_preload(user): + + # If the course is missing or broken, log an error and skip it. + course_overview = enrollment.course_overview + if not course_overview: + log.error( + "User %s enrolled in broken or non-existent course %s", + user.username, + enrollment.course_id + ) + continue + + # Filter out anything that is not attributed to the orgs to include. + if orgs_to_include and course_overview.location.org not in orgs_to_include: + continue + + # Conversely, filter out any enrollments with courses attributed to current ORG. + elif course_overview.location.org in orgs_to_exclude: + continue + + # Else, include the enrollment. + else: + yield enrollment + + +def _cert_info(user, course_overview, cert_status, course_mode): # pylint: disable=unused-argument + """ + Implements the logic for cert_info -- split out for testing. + + Arguments: + user (User): A user. + course_overview (CourseOverview): A course. + course_mode (str): The enrollment mode (honor, verified, audit, etc.) + """ + # simplify the status for the template using this lookup table + template_state = { + CertificateStatuses.generating: 'generating', + CertificateStatuses.downloadable: 'ready', + CertificateStatuses.notpassing: 'notpassing', + CertificateStatuses.restricted: 'restricted', + CertificateStatuses.auditing: 'auditing', + CertificateStatuses.audit_passing: 'auditing', + CertificateStatuses.audit_notpassing: 'auditing', + CertificateStatuses.unverified: 'unverified', + } + + default_status = 'processing' + + default_info = { + 'status': default_status, + 'show_disabled_download_button': False, + 'show_download_url': False, + 'show_survey_button': False, + 'can_unenroll': True, + } + + if cert_status is None: + return default_info + + is_hidden_status = cert_status['status'] in ('unavailable', 'processing', 'generating', 'notpassing', 'auditing') + + if course_overview.certificates_display_behavior == 'early_no_info' and is_hidden_status: + return {} + + status = template_state.get(cert_status['status'], default_status) + + status_dict = { + 'status': status, + 'show_download_url': status == 'ready', + 'show_disabled_download_button': status == 'generating', + 'mode': cert_status.get('mode', None), + 'linked_in_url': None, + 'can_unenroll': status not in DISABLE_UNENROLL_CERT_STATES, + } + + if (status in ('generating', 'ready', 'notpassing', 'restricted', 'auditing', 'unverified') and + course_overview.end_of_course_survey_url is not None): + status_dict.update({ + 'show_survey_button': True, + 'survey_url': process_survey_link(course_overview.end_of_course_survey_url, user)}) + else: + status_dict['show_survey_button'] = False + + if status == 'ready': + # showing the certificate web view button if certificate is ready state and feature flags are enabled. + if has_html_certificates_enabled(course_overview.id, course_overview): + if course_overview.has_any_active_web_certificate: + status_dict.update({ + 'show_cert_web_view': True, + 'cert_web_view_url': get_certificate_url(course_id=course_overview.id, uuid=cert_status['uuid']) + }) + else: + # don't show download certificate button if we don't have an active certificate for course + status_dict['show_download_url'] = False + elif 'download_url' not in cert_status: + log.warning( + u"User %s has a downloadable cert for %s, but no download url", + user.username, + course_overview.id + ) + return default_info + else: + status_dict['download_url'] = cert_status['download_url'] + + # If enabled, show the LinkedIn "add to profile" button + # Clicking this button sends the user to LinkedIn where they + # can add the certificate information to their profile. + linkedin_config = LinkedInAddToProfileConfiguration.current() + + # posting certificates to LinkedIn is not currently + # supported in White Labels + if linkedin_config.enabled and not theming_helpers.is_request_in_themed_site(): + status_dict['linked_in_url'] = linkedin_config.add_to_profile_url( + course_overview.id, + course_overview.display_name, + cert_status.get('mode'), + cert_status['download_url'] + ) + + if status in {'generating', 'ready', 'notpassing', 'restricted', 'auditing', 'unverified'}: + persisted_grade = CourseGradeFactory().read(user, course=course_overview) + if persisted_grade is not None: + status_dict['grade'] = unicode(persisted_grade.percent) + elif 'grade' in cert_status: + status_dict['grade'] = cert_status['grade'] + else: + # Note: as of 11/20/2012, we know there are students in this state-- cs169.1x, + # who need to be regraded (we weren't tracking 'notpassing' at first). + # We can add a log.warning here once we think it shouldn't happen. + return default_info + + return status_dict + + +@ensure_csrf_cookie +def signin_user(request): + """Deprecated. To be replaced by :class:`student_account.views.login_and_registration_form`.""" + external_auth_response = external_auth_login(request) + if external_auth_response is not None: + return external_auth_response + # Determine the URL to redirect to following login: + redirect_to = get_next_url_for_login_page(request) + if request.user.is_authenticated(): + return redirect(redirect_to) + + third_party_auth_error = None + for msg in messages.get_messages(request): + if msg.extra_tags.split()[0] == "social-auth": + # msg may or may not be translated. Try translating [again] in case we are able to: + third_party_auth_error = _(unicode(msg)) # pylint: disable=translation-of-non-string + break + + context = { + 'login_redirect_url': redirect_to, # This gets added to the query string of the "Sign In" button in the header + # Bool injected into JS to submit form if we're inside a running third- + # party auth pipeline; distinct from the actual instance of the running + # pipeline, if any. + 'pipeline_running': 'true' if pipeline.running(request) else 'false', + 'pipeline_url': auth_pipeline_urls(pipeline.AUTH_ENTRY_LOGIN, redirect_url=redirect_to), + 'platform_name': configuration_helpers.get_value( + 'platform_name', + settings.PLATFORM_NAME + ), + 'third_party_auth_error': third_party_auth_error + } + + return render_to_response('login.html', context) + + +@ensure_csrf_cookie +def register_user(request, extra_context=None): + """Deprecated. To be replaced by :class:`student_account.views.login_and_registration_form`.""" + # Determine the URL to redirect to following login: + redirect_to = get_next_url_for_login_page(request) + if request.user.is_authenticated(): + return redirect(redirect_to) + + external_auth_response = external_auth_register(request) + if external_auth_response is not None: + return external_auth_response + + context = { + 'login_redirect_url': redirect_to, # This gets added to the query string of the "Sign In" button in the header + 'email': '', + 'name': '', + 'running_pipeline': None, + 'pipeline_urls': auth_pipeline_urls(pipeline.AUTH_ENTRY_REGISTER, redirect_url=redirect_to), + 'platform_name': configuration_helpers.get_value( + 'platform_name', + settings.PLATFORM_NAME + ), + 'selected_provider': '', + 'username': '', + } + + if extra_context is not None: + context.update(extra_context) + + if context.get("extauth_domain", '').startswith( + openedx.core.djangoapps.external_auth.views.SHIBBOLETH_DOMAIN_PREFIX + ): + return render_to_response('register-shib.html', context) + + # If third-party auth is enabled, prepopulate the form with data from the + # selected provider. + if third_party_auth.is_enabled() and pipeline.running(request): + running_pipeline = pipeline.get(request) + current_provider = provider.Registry.get_from_pipeline(running_pipeline) + if current_provider is not None: + overrides = current_provider.get_register_form_data(running_pipeline.get('kwargs')) + overrides['running_pipeline'] = running_pipeline + overrides['selected_provider'] = current_provider.name + context.update(overrides) + + return render_to_response('register.html', context) + + +def complete_course_mode_info(course_id, enrollment, modes=None): + """ + We would like to compute some more information from the given course modes + and the user's current enrollment + + Returns the given information: + - whether to show the course upsell information + - numbers of days until they can't upsell anymore + """ + if modes is None: + modes = CourseMode.modes_for_course_dict(course_id) + + mode_info = {'show_upsell': False, 'days_for_upsell': None} + # we want to know if the user is already enrolled as verified or credit and + # if verified is an option. + if CourseMode.VERIFIED in modes and enrollment.mode in CourseMode.UPSELL_TO_VERIFIED_MODES: + mode_info['show_upsell'] = True + mode_info['verified_sku'] = modes['verified'].sku + mode_info['verified_bulk_sku'] = modes['verified'].bulk_sku + # if there is an expiration date, find out how long from now it is + if modes['verified'].expiration_datetime: + today = datetime.datetime.now(UTC).date() + mode_info['days_for_upsell'] = (modes['verified'].expiration_datetime.date() - today).days + + return mode_info + + +def is_course_blocked(request, redeemed_registration_codes, course_key): + """Checking either registration is blocked or not .""" + blocked = False + for redeemed_registration in redeemed_registration_codes: + # registration codes may be generated via Bulk Purchase Scenario + # we have to check only for the invoice generated registration codes + # that their invoice is valid or not + if redeemed_registration.invoice_item: + if not redeemed_registration.invoice_item.invoice.is_valid: + blocked = True + # disabling email notifications for unpaid registration courses + Optout.objects.get_or_create(user=request.user, course_id=course_key) + log.info( + u"User %s (%s) opted out of receiving emails from course %s", + request.user.username, + request.user.email, + course_key, + ) + track.views.server_track( + request, + "change-email1-settings", + {"receive_emails": "no", "course": course_key.to_deprecated_string()}, + page='dashboard', + ) + break + + return blocked + + +def compose_and_send_activation_email(user, profile, user_registration=None): + """ + Construct all the required params and send the activation email + through celery task + + Arguments: + user: current logged-in user + profile: profile object of the current logged-in user + user_registration: registration of the current logged-in user + """ + dest_addr = user.email + if user_registration is None: + user_registration = Registration.objects.get(user=user) + context = { + 'name': profile.name, + 'key': user_registration.activation_key, + } + subject = render_to_string('emails/activation_email_subject.txt', context) + # Email subject *must not* contain newlines + subject = ''.join(subject.splitlines()) + message_for_activation = render_to_string('emails/activation_email.txt', context) + from_address = configuration_helpers.get_value( + 'email_from_address', + settings.DEFAULT_FROM_EMAIL + ) + if settings.FEATURES.get('REROUTE_ACTIVATION_EMAIL'): + dest_addr = settings.FEATURES['REROUTE_ACTIVATION_EMAIL'] + message_for_activation = ("Activation for %s (%s): %s\n" % (user, user.email, profile.name) + + '-' * 80 + '\n\n' + message_for_activation) + send_activation_email.delay(subject, message_for_activation, from_address, dest_addr) + + +@login_required +@ensure_csrf_cookie +def dashboard(request): + """ + Provides the LMS dashboard view + + TODO: This is lms specific and does not belong in common code. + + Arguments: + request: The request object. + + Returns: + The dashboard response. + + """ + user = request.user + if not UserProfile.objects.filter(user=user).exists(): + return redirect(reverse('account_settings')) + + platform_name = configuration_helpers.get_value("platform_name", settings.PLATFORM_NAME) + enable_verified_certificates = configuration_helpers.get_value( + 'ENABLE_VERIFIED_CERTIFICATES', + settings.FEATURES.get('ENABLE_VERIFIED_CERTIFICATES') + ) + display_course_modes_on_dashboard = configuration_helpers.get_value( + 'DISPLAY_COURSE_MODES_ON_DASHBOARD', + settings.FEATURES.get('DISPLAY_COURSE_MODES_ON_DASHBOARD', True) + ) + + # Let's filter out any courses in an "org" that has been declared to be + # in a configuration + org_filter_out_set = configuration_helpers.get_all_orgs() + + # Remove current site orgs from the "filter out" list, if applicable. + # We want to filter and only show enrollments for courses within + # the organizations defined in configuration for the current site. + course_org_filter = configuration_helpers.get_current_site_orgs() + if course_org_filter: + org_filter_out_set = org_filter_out_set - set(course_org_filter) + + # Build our (course, enrollment) list for the user, but ignore any courses that no + # longer exist (because the course IDs have changed). Still, we don't delete those + # enrollments, because it could have been a data push snafu. + course_enrollments = list(get_course_enrollments(user, course_org_filter, org_filter_out_set)) + + # Record how many courses there are so that we can get a better + # understanding of usage patterns on prod. + monitoring_utils.accumulate('num_courses', len(course_enrollments)) + + # sort the enrollment pairs by the enrollment date + course_enrollments.sort(key=lambda x: x.created, reverse=True) + + # Retrieve the course modes for each course + enrolled_course_ids = [enrollment.course_id for enrollment in course_enrollments] + __, unexpired_course_modes = CourseMode.all_and_unexpired_modes_for_courses(enrolled_course_ids) + course_modes_by_course = { + course_id: { + mode.slug: mode + for mode in modes + } + for course_id, modes in unexpired_course_modes.iteritems() + } + + # Check to see if the student has recently enrolled in a course. + # If so, display a notification message confirming the enrollment. + enrollment_message = _create_recent_enrollment_message( + course_enrollments, course_modes_by_course + ) + + course_optouts = Optout.objects.filter(user=user).values_list('course_id', flat=True) + + message = "" + if not user.is_active: + message = render_to_string( + 'registration/activate_account_notice.html', + {'email': user.email, 'platform_name': platform_name} + ) + + enterprise_message = get_dashboard_consent_notification(request, user, course_enrollments) + + # Global staff can see what courses errored on their dashboard + staff_access = False + errored_courses = {} + if has_access(user, 'staff', 'global'): + # Show any courses that errored on load + staff_access = True + errored_courses = modulestore().get_errored_courses() + + show_courseware_links_for = frozenset( + enrollment.course_id for enrollment in course_enrollments + if has_access(request.user, 'load', enrollment.course_overview) + and has_access(request.user, 'view_courseware_with_prerequisites', enrollment.course_overview) + ) + + # Find programs associated with course runs being displayed. This information + # is passed in the template context to allow rendering of program-related + # information on the dashboard. + meter = ProgramProgressMeter(user, enrollments=course_enrollments) + inverted_programs = meter.invert_programs() + + # Construct a dictionary of course mode information + # used to render the course list. We re-use the course modes dict + # we loaded earlier to avoid hitting the database. + course_mode_info = { + enrollment.course_id: complete_course_mode_info( + enrollment.course_id, enrollment, + modes=course_modes_by_course[enrollment.course_id] + ) + for enrollment in course_enrollments + } + + # Determine the per-course verification status + # This is a dictionary in which the keys are course locators + # and the values are one of: + # + # VERIFY_STATUS_NEED_TO_VERIFY + # VERIFY_STATUS_SUBMITTED + # VERIFY_STATUS_APPROVED + # VERIFY_STATUS_MISSED_DEADLINE + # + # Each of which correspond to a particular message to display + # next to the course on the dashboard. + # + # If a course is not included in this dictionary, + # there is no verification messaging to display. + verify_status_by_course = check_verify_status_by_course(user, course_enrollments) + cert_statuses = { + enrollment.course_id: cert_info(request.user, enrollment.course_overview, enrollment.mode) + for enrollment in course_enrollments + } + + # only show email settings for Mongo course and when bulk email is turned on + show_email_settings_for = frozenset( + enrollment.course_id for enrollment in course_enrollments if ( + BulkEmailFlag.feature_enabled(enrollment.course_id) + ) + ) + + # Verification Attempts + # Used to generate the "you must reverify for course x" banner + verification_status, verification_msg = SoftwareSecurePhotoVerification.user_status(user) + + # Gets data for midcourse reverifications, if any are necessary or have failed + statuses = ["approved", "denied", "pending", "must_reverify"] + reverifications = reverification_info(statuses) + + user_already_has_certs_for = GeneratedCertificate.course_ids_with_certs_for_user(request.user) + show_refund_option_for = frozenset( + enrollment.course_id for enrollment in course_enrollments + if enrollment.refundable( + user_already_has_certs_for=user_already_has_certs_for + ) + ) + + block_courses = frozenset( + enrollment.course_id for enrollment in course_enrollments + if is_course_blocked( + request, + CourseRegistrationCode.objects.filter( + course_id=enrollment.course_id, + registrationcoderedemption__redeemed_by=request.user + ), + enrollment.course_id + ) + ) + + enrolled_courses_either_paid = frozenset( + enrollment.course_id for enrollment in course_enrollments + if enrollment.is_paid_course() + ) + + # If there are *any* denied reverifications that have not been toggled off, + # we'll display the banner + denied_banner = any(item.display for item in reverifications["denied"]) + + # Populate the Order History for the side-bar. + order_history_list = order_history(user, course_org_filter=course_org_filter, org_filter_out_set=org_filter_out_set) + + # get list of courses having pre-requisites yet to be completed + courses_having_prerequisites = frozenset( + enrollment.course_id for enrollment in course_enrollments + if enrollment.course_overview.pre_requisite_courses + ) + courses_requirements_not_met = get_pre_requisite_courses_not_completed(user, courses_having_prerequisites) + + if 'notlive' in request.GET: + redirect_message = _("The course you are looking for does not start until {date}.").format( + date=request.GET['notlive'] + ) + elif 'course_closed' in request.GET: + redirect_message = _("The course you are looking for is closed for enrollment as of {date}.").format( + date=request.GET['course_closed'] + ) + else: + redirect_message = '' + + valid_verification_statuses = ['approved', 'must_reverify', 'pending', 'expired'] + display_sidebar_on_dashboard = len(order_history_list) or verification_status in valid_verification_statuses + + context = { + 'enterprise_message': enterprise_message, + 'enrollment_message': enrollment_message, + 'redirect_message': redirect_message, + 'course_enrollments': course_enrollments, + 'course_optouts': course_optouts, + 'message': message, + 'staff_access': staff_access, + 'errored_courses': errored_courses, + 'show_courseware_links_for': show_courseware_links_for, + 'all_course_modes': course_mode_info, + 'cert_statuses': cert_statuses, + 'credit_statuses': _credit_statuses(user, course_enrollments), + 'show_email_settings_for': show_email_settings_for, + 'reverifications': reverifications, + 'verification_status': verification_status, + 'verification_status_by_course': verify_status_by_course, + 'verification_msg': verification_msg, + 'show_refund_option_for': show_refund_option_for, + 'block_courses': block_courses, + 'denied_banner': denied_banner, + 'billing_email': settings.PAYMENT_SUPPORT_EMAIL, + 'user': user, + 'logout_url': reverse('logout'), + 'platform_name': platform_name, + 'enrolled_courses_either_paid': enrolled_courses_either_paid, + 'provider_states': [], + 'order_history_list': order_history_list, + 'courses_requirements_not_met': courses_requirements_not_met, + 'nav_hidden': True, + 'inverted_programs': inverted_programs, + 'show_program_listing': ProgramsApiConfig.is_enabled(), + 'disable_courseware_js': True, + 'display_course_modes_on_dashboard': enable_verified_certificates and display_course_modes_on_dashboard, + 'display_sidebar_on_dashboard': display_sidebar_on_dashboard, + } + + ecommerce_service = EcommerceService() + if ecommerce_service.is_enabled(request.user): + context.update({ + 'use_ecommerce_payment_flow': True, + 'ecommerce_payment_page': ecommerce_service.payment_page_url(), + }) + + response = render_to_response('dashboard.html', context) + set_user_info_cookie(response, request) + return response + + +def _create_recent_enrollment_message(course_enrollments, course_modes): # pylint: disable=invalid-name + """ + Builds a recent course enrollment message. + + Constructs a new message template based on any recent course enrollments + for the student. + + Args: + course_enrollments (list[CourseEnrollment]): a list of course enrollments. + course_modes (dict): Mapping of course ID's to course mode dictionaries. + + Returns: + A string representing the HTML message output from the message template. + None if there are no recently enrolled courses. + + """ + recently_enrolled_courses = _get_recently_enrolled_courses(course_enrollments) + + if recently_enrolled_courses: + enroll_messages = [ + { + "course_id": enrollment.course_overview.id, + "course_name": enrollment.course_overview.display_name, + "allow_donation": _allow_donation(course_modes, enrollment.course_overview.id, enrollment) + } + for enrollment in recently_enrolled_courses + ] + + platform_name = configuration_helpers.get_value('platform_name', settings.PLATFORM_NAME) + + return render_to_string( + 'enrollment/course_enrollment_message.html', + {'course_enrollment_messages': enroll_messages, 'platform_name': platform_name} + ) + + +def _get_recently_enrolled_courses(course_enrollments): + """ + Given a list of enrollments, filter out all but recent enrollments. + + Args: + course_enrollments (list[CourseEnrollment]): A list of course enrollments. + + Returns: + list[CourseEnrollment]: A list of recent course enrollments. + """ + seconds = DashboardConfiguration.current().recent_enrollment_time_delta + time_delta = (datetime.datetime.now(UTC) - datetime.timedelta(seconds=seconds)) + return [ + enrollment for enrollment in course_enrollments + # If the enrollment has no created date, we are explicitly excluding the course + # from the list of recent enrollments. + if enrollment.is_active and enrollment.created > time_delta + ] + + +def _allow_donation(course_modes, course_id, enrollment): + """Determines if the dashboard will request donations for the given course. + + Check if donations are configured for the platform, and if the current course is accepting donations. + + Args: + course_modes (dict): Mapping of course ID's to course mode dictionaries. + course_id (str): The unique identifier for the course. + enrollment(CourseEnrollment): The enrollment object in which the user is enrolled + + Returns: + True if the course is allowing donations. + + """ + if course_id not in course_modes: + flat_unexpired_modes = { + unicode(course_id): [mode for mode in modes] + for course_id, modes in course_modes.iteritems() + } + flat_all_modes = { + unicode(course_id): [mode.slug for mode in modes] + for course_id, modes in CourseMode.all_modes_for_courses([course_id]).iteritems() + } + log.error( + u'Can not find `%s` in course modes.`%s`. All modes: `%s`', + course_id, + flat_unexpired_modes, + flat_all_modes + ) + donations_enabled = configuration_helpers.get_value( + 'ENABLE_DONATIONS', + DonationConfiguration.current().enabled + ) + return ( + donations_enabled and + enrollment.mode in course_modes[course_id] and + course_modes[course_id][enrollment.mode].min_price == 0 + ) + + +def _update_email_opt_in(request, org): + """Helper function used to hit the profile API if email opt-in is enabled.""" + + email_opt_in = request.POST.get('email_opt_in') + if email_opt_in is not None: + email_opt_in_boolean = email_opt_in == 'true' + preferences_api.update_email_opt_in(request.user, org, email_opt_in_boolean) + + +def _credit_statuses(user, course_enrollments): + """ + Retrieve the status for credit courses. + + A credit course is a course for which a user can purchased + college credit. The current flow is: + + 1. User becomes eligible for credit (submits verifications, passes the course, etc.) + 2. User purchases credit from a particular credit provider. + 3. User requests credit from the provider, usually creating an account on the provider's site. + 4. The credit provider notifies us whether the user's request for credit has been accepted or rejected. + + The dashboard is responsible for communicating the user's state in this flow. + + Arguments: + user (User): The currently logged-in user. + course_enrollments (list[CourseEnrollment]): List of enrollments for the + user. + + Returns: dict + + The returned dictionary has keys that are `CourseKey`s and values that + are dictionaries with: + + * eligible (bool): True if the user is eligible for credit in this course. + * deadline (datetime): The deadline for purchasing and requesting credit for this course. + * purchased (bool): Whether the user has purchased credit for this course. + * provider_name (string): The display name of the credit provider. + * provider_status_url (string): A URL the user can visit to check on their credit request status. + * request_status (string): Either "pending", "approved", or "rejected" + * error (bool): If true, an unexpected error occurred when retrieving the credit status, + so the user should contact the support team. + + Example: + >>> _credit_statuses(user, course_enrollments) + { + CourseKey.from_string("edX/DemoX/Demo_Course"): { + "course_key": "edX/DemoX/Demo_Course", + "eligible": True, + "deadline": 2015-11-23 00:00:00 UTC, + "purchased": True, + "provider_name": "Hogwarts", + "provider_status_url": "http://example.com/status", + "request_status": "pending", + "error": False + } + } + + """ + from openedx.core.djangoapps.credit import api as credit_api + + # Feature flag off + if not settings.FEATURES.get("ENABLE_CREDIT_ELIGIBILITY"): + return {} + + request_status_by_course = { + request["course_key"]: request["status"] + for request in credit_api.get_credit_requests_for_user(user.username) + } + + credit_enrollments = { + enrollment.course_id: enrollment + for enrollment in course_enrollments + if enrollment.mode == "credit" + } + + # When a user purchases credit in a course, the user's enrollment + # mode is set to "credit" and an enrollment attribute is set + # with the ID of the credit provider. We retrieve *all* such attributes + # here to minimize the number of database queries. + purchased_credit_providers = { + attribute.enrollment.course_id: attribute.value + for attribute in CourseEnrollmentAttribute.objects.filter( + namespace="credit", + name="provider_id", + enrollment__in=credit_enrollments.values() + ).select_related("enrollment") + } + + provider_info_by_id = { + provider["id"]: provider + for provider in credit_api.get_credit_providers() + } + + statuses = {} + for eligibility in credit_api.get_eligibilities_for_user(user.username): + course_key = CourseKey.from_string(unicode(eligibility["course_key"])) + providers_names = get_credit_provider_display_names(course_key) + status = { + "course_key": unicode(course_key), + "eligible": True, + "deadline": eligibility["deadline"], + "purchased": course_key in credit_enrollments, + "provider_name": make_providers_strings(providers_names), + "provider_status_url": None, + "provider_id": None, + "request_status": request_status_by_course.get(course_key), + "error": False, + } + + # If the user has purchased credit, then include information about the credit + # provider from which the user purchased credit. + # We retrieve the provider's ID from the an "enrollment attribute" set on the user's + # enrollment when the user's order for credit is fulfilled by the E-Commerce service. + if status["purchased"]: + provider_id = purchased_credit_providers.get(course_key) + if provider_id is None: + status["error"] = True + log.error( + u"Could not find credit provider associated with credit enrollment " + u"for user %s in course %s. The user will not be able to see his or her " + u"credit request status on the student dashboard. This attribute should " + u"have been set when the user purchased credit in the course.", + user.id, course_key + ) + else: + provider_info = provider_info_by_id.get(provider_id, {}) + status["provider_name"] = provider_info.get("display_name") + status["provider_status_url"] = provider_info.get("status_url") + status["provider_id"] = provider_id + + statuses[course_key] = status + + return statuses + + +@transaction.non_atomic_requests +@require_POST +@outer_atomic(read_committed=True) +def change_enrollment(request, check_access=True): + """ + Modify the enrollment status for the logged-in user. + + TODO: This is lms specific and does not belong in common code. + + The request parameter must be a POST request (other methods return 405) + that specifies course_id and enrollment_action parameters. If course_id or + enrollment_action is not specified, if course_id is not valid, if + enrollment_action is something other than "enroll" or "unenroll", if + enrollment_action is "enroll" and enrollment is closed for the course, or + if enrollment_action is "unenroll" and the user is not enrolled in the + course, a 400 error will be returned. If the user is not logged in, 403 + will be returned; it is important that only this case return 403 so the + front end can redirect the user to a registration or login page when this + happens. This function should only be called from an AJAX request, so + the error messages in the responses should never actually be user-visible. + + Args: + request (`Request`): The Django request object + + Keyword Args: + check_access (boolean): If True, we check that an accessible course actually + exists for the given course_key before we enroll the student. + The default is set to False to avoid breaking legacy code or + code with non-standard flows (ex. beta tester invitations), but + for any standard enrollment flow you probably want this to be True. + + Returns: + Response + + """ + # Get the user + user = request.user + + # Ensure the user is authenticated + if not user.is_authenticated(): + return HttpResponseForbidden() + + # Ensure we received a course_id + action = request.POST.get("enrollment_action") + if 'course_id' not in request.POST: + return HttpResponseBadRequest(_("Course id not specified")) + + try: + course_id = SlashSeparatedCourseKey.from_deprecated_string(request.POST.get("course_id")) + except InvalidKeyError: + log.warning( + u"User %s tried to %s with invalid course id: %s", + user.username, + action, + request.POST.get("course_id"), + ) + return HttpResponseBadRequest(_("Invalid course id")) + + if action == "enroll": + # Make sure the course exists + # We don't do this check on unenroll, or a bad course id can't be unenrolled from + if not modulestore().has_course(course_id): + log.warning( + u"User %s tried to enroll in non-existent course %s", + user.username, + course_id + ) + return HttpResponseBadRequest(_("Course id is invalid")) + + # Record the user's email opt-in preference + if settings.FEATURES.get('ENABLE_MKTG_EMAIL_OPT_IN'): + _update_email_opt_in(request, course_id.org) + + available_modes = CourseMode.modes_for_course_dict(course_id) + + # Check whether the user is blocked from enrolling in this course + # This can occur if the user's IP is on a global blacklist + # or if the user is enrolling in a country in which the course + # is not available. + redirect_url = embargo_api.redirect_if_blocked( + course_id, user=user, ip_address=get_ip(request), + url=request.path + ) + if redirect_url: + return HttpResponse(redirect_url) + + # Check that auto enrollment is allowed for this course + # (= the course is NOT behind a paywall) + if CourseMode.can_auto_enroll(course_id): + # Enroll the user using the default mode (audit) + # We're assuming that users of the course enrollment table + # will NOT try to look up the course enrollment model + # by its slug. If they do, it's possible (based on the state of the database) + # for no such model to exist, even though we've set the enrollment type + # to "audit". + try: + enroll_mode = CourseMode.auto_enroll_mode(course_id, available_modes) + if enroll_mode: + CourseEnrollment.enroll(user, course_id, check_access=check_access, mode=enroll_mode) + except Exception: # pylint: disable=broad-except + return HttpResponseBadRequest(_("Could not enroll")) + + # If we have more than one course mode or professional ed is enabled, + # then send the user to the choose your track page. + # (In the case of no-id-professional/professional ed, this will redirect to a page that + # funnels users directly into the verification / payment flow) + if CourseMode.has_verified_mode(available_modes) or CourseMode.has_professional_mode(available_modes): + return HttpResponse( + reverse("course_modes_choose", kwargs={'course_id': unicode(course_id)}) + ) + + # Otherwise, there is only one mode available (the default) + return HttpResponse() + elif action == "unenroll": + enrollment = CourseEnrollment.get_enrollment(user, course_id) + if not enrollment: + return HttpResponseBadRequest(_("You are not enrolled in this course")) + + certificate_info = cert_info(user, enrollment.course_overview, enrollment.mode) + if certificate_info.get('status') in DISABLE_UNENROLL_CERT_STATES: + return HttpResponseBadRequest(_("Your certificate prevents you from unenrolling from this course")) + + CourseEnrollment.unenroll(user, course_id) + return HttpResponse() + else: + return HttpResponseBadRequest(_("Enrollment action is invalid")) + + +# Need different levels of logging +@ensure_csrf_cookie +def login_user(request, error=""): # pylint: disable=too-many-statements,unused-argument + """AJAX request to log in the user.""" + + backend_name = None + email = None + password = None + redirect_url = None + response = None + running_pipeline = None + third_party_auth_requested = third_party_auth.is_enabled() and pipeline.running(request) + third_party_auth_successful = False + trumped_by_first_party_auth = bool(request.POST.get('email')) or bool(request.POST.get('password')) + user = None + platform_name = configuration_helpers.get_value("platform_name", settings.PLATFORM_NAME) + + if third_party_auth_requested and not trumped_by_first_party_auth: + # The user has already authenticated via third-party auth and has not + # asked to do first party auth by supplying a username or password. We + # now want to put them through the same logging and cookie calculation + # logic as with first-party auth. + running_pipeline = pipeline.get(request) + username = running_pipeline['kwargs'].get('username') + backend_name = running_pipeline['backend'] + third_party_uid = running_pipeline['kwargs']['uid'] + requested_provider = provider.Registry.get_from_pipeline(running_pipeline) + + try: + user = pipeline.get_authenticated_user(requested_provider, username, third_party_uid) + third_party_auth_successful = True + except User.DoesNotExist: + AUDIT_LOG.warning( + u"Login failed - user with username {username} has no social auth " + "with backend_name {backend_name}".format( + username=username, backend_name=backend_name) + ) + message = _( + "You've successfully logged into your {provider_name} account, " + "but this account isn't linked with an {platform_name} account yet." + ).format( + platform_name=platform_name, + provider_name=requested_provider.name, + ) + message += "

" + message += _( + "Use your {platform_name} username and password to log into {platform_name} below, " + "and then link your {platform_name} account with {provider_name} from your dashboard." + ).format( + platform_name=platform_name, + provider_name=requested_provider.name, + ) + message += "

" + message += _( + "If you don't have an {platform_name} account yet, " + "click Register at the top of the page." + ).format( + platform_name=platform_name + ) + + return HttpResponse(message, content_type="text/plain", status=403) + + else: + + if 'email' not in request.POST or 'password' not in request.POST: + return JsonResponse({ + "success": False, + # TODO: User error message + "value": _('There was an error receiving your login information. Please email us.'), + }) # TODO: this should be status code 400 + + email = request.POST['email'] + password = request.POST['password'] + try: + user = User.objects.get(email=email) + except User.DoesNotExist: + if settings.FEATURES['SQUELCH_PII_IN_LOGS']: + AUDIT_LOG.warning(u"Login failed - Unknown user email") + else: + AUDIT_LOG.warning(u"Login failed - Unknown user email: {0}".format(email)) + + # check if the user has a linked shibboleth account, if so, redirect the user to shib-login + # This behavior is pretty much like what gmail does for shibboleth. Try entering some @stanford.edu + # address into the Gmail login. + if settings.FEATURES.get('AUTH_USE_SHIB') and user: + try: + eamap = ExternalAuthMap.objects.get(user=user) + if eamap.external_domain.startswith(openedx.core.djangoapps.external_auth.views.SHIBBOLETH_DOMAIN_PREFIX): + return JsonResponse({ + "success": False, + "redirect": reverse('shib-login'), + }) # TODO: this should be status code 301 # pylint: disable=fixme + except ExternalAuthMap.DoesNotExist: + # This is actually the common case, logging in user without external linked login + AUDIT_LOG.info(u"User %s w/o external auth attempting login", user) + + # see if account has been locked out due to excessive login failures + user_found_by_email_lookup = user + if user_found_by_email_lookup and LoginFailures.is_feature_enabled(): + if LoginFailures.is_user_locked_out(user_found_by_email_lookup): + lockout_message = _('This account has been temporarily locked due ' + 'to excessive login failures. Try again later.') + return JsonResponse({ + "success": False, + "value": lockout_message, + }) # TODO: this should be status code 429 # pylint: disable=fixme + + # see if the user must reset his/her password due to any policy settings + if user_found_by_email_lookup and PasswordHistory.should_user_reset_password_now(user_found_by_email_lookup): + return JsonResponse({ + "success": False, + "value": _('Your password has expired due to password policy on this account. You must ' + 'reset your password before you can log in again. Please click the ' + '"Forgot Password" link on this page to reset your password before logging in again.'), + }) # TODO: this should be status code 403 # pylint: disable=fixme + + # if the user doesn't exist, we want to set the username to an invalid + # username so that authentication is guaranteed to fail and we can take + # advantage of the ratelimited backend + username = user.username if user else "" + + if not third_party_auth_successful: + try: + user = authenticate(username=username, password=password, request=request) + # this occurs when there are too many attempts from the same IP address + except RateLimitException: + return JsonResponse({ + "success": False, + "value": _('Too many failed login attempts. Try again later.'), + }) # TODO: this should be status code 429 # pylint: disable=fixme + + if user is None: + # tick the failed login counters if the user exists in the database + if user_found_by_email_lookup and LoginFailures.is_feature_enabled(): + LoginFailures.increment_lockout_counter(user_found_by_email_lookup) + + # if we didn't find this username earlier, the account for this email + # doesn't exist, and doesn't have a corresponding password + if username != "": + if settings.FEATURES['SQUELCH_PII_IN_LOGS']: + loggable_id = user_found_by_email_lookup.id if user_found_by_email_lookup else "" + AUDIT_LOG.warning(u"Login failed - password for user.id: {0} is invalid".format(loggable_id)) + else: + AUDIT_LOG.warning(u"Login failed - password for {0} is invalid".format(email)) + return JsonResponse({ + "success": False, + "value": _('Email or password is incorrect.'), + }) # TODO: this should be status code 400 # pylint: disable=fixme + + # successful login, clear failed login attempts counters, if applicable + if LoginFailures.is_feature_enabled(): + LoginFailures.clear_lockout_counter(user) + + # Track the user's sign in + if hasattr(settings, 'LMS_SEGMENT_KEY') and settings.LMS_SEGMENT_KEY: + tracking_context = tracker.get_tracker().resolve_context() + analytics.identify( + user.id, + { + 'email': email, + 'username': username + }, + { + # Disable MailChimp because we don't want to update the user's email + # and username in MailChimp on every page load. We only need to capture + # this data on registration/activation. + 'MailChimp': False + } + ) + + analytics.track( + user.id, + "edx.bi.user.account.authenticated", + { + 'category': "conversion", + 'label': request.POST.get('course_id'), + 'provider': None + }, + context={ + 'ip': tracking_context.get('ip'), + 'Google Analytics': { + 'clientId': tracking_context.get('client_id') + } + } + ) + if user is not None and user.is_active: + try: + # We do not log here, because we have a handler registered + # to perform logging on successful logins. + login(request, user) + if request.POST.get('remember') == 'true': + request.session.set_expiry(604800) + log.debug("Setting user session to never expire") + else: + request.session.set_expiry(0) + except Exception as exc: # pylint: disable=broad-except + AUDIT_LOG.critical("Login failed - Could not create session. Is memcached running?") + log.critical("Login failed - Could not create session. Is memcached running?") + log.exception(exc) + raise + + redirect_url = None # The AJAX method calling should know the default destination upon success + if third_party_auth_successful: + redirect_url = pipeline.get_complete_url(backend_name) + + response = JsonResponse({ + "success": True, + "redirect_url": redirect_url, + }) + + # Ensure that the external marketing site can + # detect that the user is logged in. + return set_logged_in_cookies(request, response, user) + + if settings.FEATURES['SQUELCH_PII_IN_LOGS']: + AUDIT_LOG.warning(u"Login failed - Account not active for user.id: {0}, resending activation".format(user.id)) + else: + AUDIT_LOG.warning(u"Login failed - Account not active for user {0}, resending activation".format(username)) + + reactivation_email_for_user(user) + not_activated_msg = _("Before you sign in, you need to activate your account. We have sent you an " + "email message with instructions for activating your account.") + return JsonResponse({ + "success": False, + "value": not_activated_msg, + }) # TODO: this should be status code 400 # pylint: disable=fixme + + +@csrf_exempt +@require_POST +@social_utils.strategy("social:complete") +def login_oauth_token(request, backend): + """ + Authenticate the client using an OAuth access token by using the token to + retrieve information from a third party and matching that information to an + existing user. + """ + warnings.warn("Please use AccessTokenExchangeView instead.", DeprecationWarning) + + backend = request.backend + if isinstance(backend, social_oauth.BaseOAuth1) or isinstance(backend, social_oauth.BaseOAuth2): + if "access_token" in request.POST: + # Tell third party auth pipeline that this is an API call + request.session[pipeline.AUTH_ENTRY_KEY] = pipeline.AUTH_ENTRY_LOGIN_API + user = None + try: + user = backend.do_auth(request.POST["access_token"]) + except (HTTPError, AuthException): + pass + # do_auth can return a non-User object if it fails + if user and isinstance(user, User): + login(request, user) + return JsonResponse(status=204) + else: + # Ensure user does not re-enter the pipeline + request.social_strategy.clean_partial_pipeline() + return JsonResponse({"error": "invalid_token"}, status=401) + else: + return JsonResponse({"error": "invalid_request"}, status=400) + raise Http404 + + +@require_GET +@login_required +@ensure_csrf_cookie +def manage_user_standing(request): + """ + Renders the view used to manage user standing. Also displays a table + of user accounts that have been disabled and who disabled them. + """ + if not request.user.is_staff: + raise Http404 + all_disabled_accounts = UserStanding.objects.filter( + account_status=UserStanding.ACCOUNT_DISABLED + ) + + all_disabled_users = [standing.user for standing in all_disabled_accounts] + + headers = ['username', 'account_changed_by'] + rows = [] + for user in all_disabled_users: + row = [user.username, user.standing.changed_by] + rows.append(row) + + context = {'headers': headers, 'rows': rows} + + return render_to_response("manage_user_standing.html", context) + + +@require_POST +@login_required +@ensure_csrf_cookie +def disable_account_ajax(request): + """ + Ajax call to change user standing. Endpoint of the form + in manage_user_standing.html + """ + if not request.user.is_staff: + raise Http404 + username = request.POST.get('username') + context = {} + if username is None or username.strip() == '': + context['message'] = _('Please enter a username') + return JsonResponse(context, status=400) + + account_action = request.POST.get('account_action') + if account_action is None: + context['message'] = _('Please choose an option') + return JsonResponse(context, status=400) + + username = username.strip() + try: + user = User.objects.get(username=username) + except User.DoesNotExist: + context['message'] = _("User with username {} does not exist").format(username) + return JsonResponse(context, status=400) + else: + user_account, _success = UserStanding.objects.get_or_create( + user=user, defaults={'changed_by': request.user}, + ) + if account_action == 'disable': + user_account.account_status = UserStanding.ACCOUNT_DISABLED + context['message'] = _("Successfully disabled {}'s account").format(username) + log.info(u"%s disabled %s's account", request.user, username) + elif account_action == 'reenable': + user_account.account_status = UserStanding.ACCOUNT_ENABLED + context['message'] = _("Successfully reenabled {}'s account").format(username) + log.info(u"%s reenabled %s's account", request.user, username) + else: + context['message'] = _("Unexpected account status") + return JsonResponse(context, status=400) + user_account.changed_by = request.user + user_account.standing_last_changed_at = datetime.datetime.now(UTC) + user_account.save() + + return JsonResponse(context) + + +@login_required +@ensure_csrf_cookie +def change_setting(request): + """JSON call to change a profile setting: Right now, location""" + # TODO (vshnayder): location is no longer used + u_prof = UserProfile.objects.get(user=request.user) # request.user.profile_cache + if 'location' in request.POST: + u_prof.location = request.POST['location'] + u_prof.save() + + return JsonResponse({ + "success": True, + "location": u_prof.location, + }) + + +class AccountValidationError(Exception): + def __init__(self, message, field): + super(AccountValidationError, self).__init__(message) + self.field = field + + +@receiver(post_save, sender=User) +def user_signup_handler(sender, **kwargs): # pylint: disable=unused-argument + """ + handler that saves the user Signup Source + when the user is created + """ + if 'created' in kwargs and kwargs['created']: + site = configuration_helpers.get_value('SITE_NAME') + if site: + user_signup_source = UserSignupSource(user=kwargs['instance'], site=site) + user_signup_source.save() + log.info(u'user {} originated from a white labeled "Microsite"'.format(kwargs['instance'].id)) + + +def _do_create_account(form, custom_form=None, site=None): + """ + Given cleaned post variables, create the User and UserProfile objects, as well as the + registration for this user. + + Returns a tuple (User, UserProfile, Registration). + + Note: this function is also used for creating test users. + """ + # Check if ALLOW_PUBLIC_ACCOUNT_CREATION flag turned off to restrict user account creation + if not configuration_helpers.get_value( + 'ALLOW_PUBLIC_ACCOUNT_CREATION', + settings.FEATURES.get('ALLOW_PUBLIC_ACCOUNT_CREATION', True) + ): + raise PermissionDenied() + + errors = {} + errors.update(form.errors) + if custom_form: + errors.update(custom_form.errors) + + if errors: + raise ValidationError(errors) + + user = User( + username=form.cleaned_data["username"], + email=form.cleaned_data["email"], + is_active=False + ) + user.set_password(form.cleaned_data["password"]) + registration = Registration() + + # TODO: Rearrange so that if part of the process fails, the whole process fails. + # Right now, we can have e.g. no registration e-mail sent out and a zombie account + try: + with transaction.atomic(): + user.save() + if custom_form: + custom_model = custom_form.save(commit=False) + custom_model.user = user + custom_model.save() + + if site: + # Set UserAttribute indicating the site the user account was created on. + UserAttribute.set_user_attribute(user, 'created_on_site', site.domain) + except IntegrityError: + # Figure out the cause of the integrity error + if len(User.objects.filter(username=user.username)) > 0: + raise AccountValidationError( + _("An account with the Public Username '{username}' already exists.").format(username=user.username), + field="username" + ) + elif len(User.objects.filter(email=user.email)) > 0: + raise AccountValidationError( + _("An account with the Email '{email}' already exists.").format(email=user.email), + field="email" + ) + else: + raise + + # add this account creation to password history + # NOTE, this will be a NOP unless the feature has been turned on in configuration + password_history_entry = PasswordHistory() + password_history_entry.create(user) + + registration.register(user) + + profile_fields = [ + "name", "level_of_education", "gender", "mailing_address", "city", "country", "goals", + "year_of_birth" + ] + profile = UserProfile( + user=user, + **{key: form.cleaned_data.get(key) for key in profile_fields} + ) + extended_profile = form.cleaned_extended_profile + if extended_profile: + profile.meta = json.dumps(extended_profile) + try: + profile.save() + except Exception: # pylint: disable=broad-except + log.exception("UserProfile creation failed for user {id}.".format(id=user.id)) + raise + + return (user, profile, registration) + + +def create_account_with_params(request, params): + """ + Given a request and a dict of parameters (which may or may not have come + from the request), create an account for the requesting user, including + creating a comments service user object and sending an activation email. + This also takes external/third-party auth into account, updates that as + necessary, and authenticates the user for the request's session. + + Does not return anything. + + Raises AccountValidationError if an account with the username or email + specified by params already exists, or ValidationError if any of the given + parameters is invalid for any other reason. + + Issues with this code: + * It is not transactional. If there is a failure part-way, an incomplete + account will be created and left in the database. + * Third-party auth passwords are not verified. There is a comment that + they are unused, but it would be helpful to have a sanity check that + they are sane. + * It is over 300 lines long (!) and includes disprate functionality, from + registration e-mails to all sorts of other things. It should be broken + up into semantically meaningful functions. + * The user-facing text is rather unfriendly (e.g. "Username must be a + minimum of two characters long" rather than "Please use a username of + at least two characters"). + """ + # Copy params so we can modify it; we can't just do dict(params) because if + # params is request.POST, that results in a dict containing lists of values + params = dict(params.items()) + + # allow to define custom set of required/optional/hidden fields via configuration + extra_fields = configuration_helpers.get_value( + 'REGISTRATION_EXTRA_FIELDS', + getattr(settings, 'REGISTRATION_EXTRA_FIELDS', {}) + ) + # registration via third party (Google, Facebook) using mobile application + # doesn't use social auth pipeline (no redirect uri(s) etc involved). + # In this case all related info (required for account linking) + # is sent in params. + # `third_party_auth_credentials_in_api` essentially means 'request + # is made from mobile application' + third_party_auth_credentials_in_api = 'provider' in params + + is_third_party_auth_enabled = third_party_auth.is_enabled() + + if is_third_party_auth_enabled and (pipeline.running(request) or third_party_auth_credentials_in_api): + params["password"] = pipeline.make_random_password() + + # in case user is registering via third party (Google, Facebook) and pipeline has expired, show appropriate + # error message + if is_third_party_auth_enabled and ('social_auth_provider' in params and not pipeline.running(request)): + raise ValidationError( + {'session_expired': [ + _(u"Registration using {provider} has timed out.").format( + provider=params.get('social_auth_provider')) + ]} + ) + + # if doing signup for an external authorization, then get email, password, name from the eamap + # don't use the ones from the form, since the user could have hacked those + # unless originally we didn't get a valid email or name from the external auth + # TODO: We do not check whether these values meet all necessary criteria, such as email length + do_external_auth = 'ExternalAuthMap' in request.session + if do_external_auth: + eamap = request.session['ExternalAuthMap'] + try: + validate_email(eamap.external_email) + params["email"] = eamap.external_email + except ValidationError: + pass + if eamap.external_name.strip() != '': + params["name"] = eamap.external_name + params["password"] = eamap.internal_password + log.debug(u'In create_account with external_auth: user = %s, email=%s', params["name"], params["email"]) + + extended_profile_fields = configuration_helpers.get_value('extended_profile_fields', []) + enforce_password_policy = ( + settings.FEATURES.get("ENFORCE_PASSWORD_POLICY", False) and + not do_external_auth + ) + # Can't have terms of service for certain SHIB users, like at Stanford + registration_fields = getattr(settings, 'REGISTRATION_EXTRA_FIELDS', {}) + tos_required = ( + registration_fields.get('terms_of_service') != 'hidden' or + registration_fields.get('honor_code') != 'hidden' + ) and ( + not settings.FEATURES.get("AUTH_USE_SHIB") or + not settings.FEATURES.get("SHIB_DISABLE_TOS") or + not do_external_auth or + not eamap.external_domain.startswith(openedx.core.djangoapps.external_auth.views.SHIBBOLETH_DOMAIN_PREFIX) + ) + + form = AccountCreationForm( + data=params, + extra_fields=extra_fields, + extended_profile_fields=extended_profile_fields, + enforce_username_neq_password=True, + enforce_password_policy=enforce_password_policy, + tos_required=tos_required, + ) + custom_form = get_registration_extension_form(data=params) + + # Perform operations within a transaction that are critical to account creation + with transaction.atomic(): + # first, create the account + (user, profile, registration) = _do_create_account(form, custom_form, site=request.site) + + # If a 3rd party auth provider and credentials were provided in the API, link the account with social auth + # (If the user is using the normal register page, the social auth pipeline does the linking, not this code) + + # Note: this is orthogonal to the 3rd party authentication pipeline that occurs + # when the account is created via the browser and redirect URLs. + + if is_third_party_auth_enabled and third_party_auth_credentials_in_api: + backend_name = params['provider'] + request.social_strategy = social_utils.load_strategy(request) + redirect_uri = reverse('social:complete', args=(backend_name, )) + request.backend = social_utils.load_backend(request.social_strategy, backend_name, redirect_uri) + social_access_token = params.get('access_token') + if not social_access_token: + raise ValidationError({ + 'access_token': [ + _("An access_token is required when passing value ({}) for provider.").format( + params['provider'] + ) + ] + }) + request.session[pipeline.AUTH_ENTRY_KEY] = pipeline.AUTH_ENTRY_REGISTER_API + pipeline_user = None + error_message = "" + try: + pipeline_user = request.backend.do_auth(social_access_token, user=user) + except AuthAlreadyAssociated: + error_message = _("The provided access_token is already associated with another user.") + except (HTTPError, AuthException): + error_message = _("The provided access_token is not valid.") + if not pipeline_user or not isinstance(pipeline_user, User): + # Ensure user does not re-enter the pipeline + request.social_strategy.clean_partial_pipeline() + raise ValidationError({'access_token': [error_message]}) + + # Perform operations that are non-critical parts of account creation + preferences_api.set_user_preference(user, LANGUAGE_KEY, get_language()) + + if settings.FEATURES.get('ENABLE_DISCUSSION_EMAIL_DIGEST'): + try: + enable_notifications(user) + except Exception: # pylint: disable=broad-except + log.exception("Enable discussion notifications failed for user {id}.".format(id=user.id)) + + dog_stats_api.increment("common.student.account_created") + + # If the user is registering via 3rd party auth, track which provider they use + third_party_provider = None + running_pipeline = None + if is_third_party_auth_enabled and pipeline.running(request): + running_pipeline = pipeline.get(request) + third_party_provider = provider.Registry.get_from_pipeline(running_pipeline) + + # Track the user's registration + if hasattr(settings, 'LMS_SEGMENT_KEY') and settings.LMS_SEGMENT_KEY: + tracking_context = tracker.get_tracker().resolve_context() + identity_args = [ + user.id, # pylint: disable=no-member + { + 'email': user.email, + 'username': user.username, + 'name': profile.name, + # Mailchimp requires the age & yearOfBirth to be integers, we send a sane integer default if falsey. + 'age': profile.age or -1, + 'yearOfBirth': profile.year_of_birth or datetime.datetime.now(UTC).year, + 'education': profile.level_of_education_display, + 'address': profile.mailing_address, + 'gender': profile.gender_display, + 'country': unicode(profile.country), + } + ] + + if hasattr(settings, 'MAILCHIMP_NEW_USER_LIST_ID'): + identity_args.append({ + "MailChimp": { + "listId": settings.MAILCHIMP_NEW_USER_LIST_ID + } + }) + + analytics.identify(*identity_args) + + analytics.track( + user.id, + "edx.bi.user.account.registered", + { + 'category': 'conversion', + 'label': params.get('course_id'), + 'provider': third_party_provider.name if third_party_provider else None + }, + context={ + 'ip': tracking_context.get('ip'), + 'Google Analytics': { + 'clientId': tracking_context.get('client_id') + } + } + ) + + # Announce registration + REGISTER_USER.send(sender=None, user=user, profile=profile) + + create_comments_service_user(user) + + # Don't send email if we are: + # + # 1. Doing load testing. + # 2. Random user generation for other forms of testing. + # 3. External auth bypassing activation. + # 4. Have the platform configured to not require e-mail activation. + # 5. Registering a new user using a trusted third party provider (with skip_email_verification=True) + # + # Note that this feature is only tested as a flag set one way or + # the other for *new* systems. we need to be careful about + # changing settings on a running system to make sure no users are + # left in an inconsistent state (or doing a migration if they are). + send_email = ( + not settings.FEATURES.get('SKIP_EMAIL_VALIDATION', None) and + not settings.FEATURES.get('AUTOMATIC_AUTH_FOR_TESTING') and + not (do_external_auth and settings.FEATURES.get('BYPASS_ACTIVATION_EMAIL_FOR_EXTAUTH')) and + not ( + third_party_provider and third_party_provider.skip_email_verification and + user.email == running_pipeline['kwargs'].get('details', {}).get('email') + ) + ) + if send_email: + compose_and_send_activation_email(user, profile, registration) + else: + registration.activate() + _enroll_user_in_pending_courses(user) # Enroll student in any pending courses + + # Immediately after a user creates an account, we log them in. They are only + # logged in until they close the browser. They can't log in again until they click + # the activation link from the email. + new_user = authenticate(username=user.username, password=params['password']) + login(request, new_user) + request.session.set_expiry(0) + + try: + record_registration_attributions(request, new_user) + # Don't prevent a user from registering due to attribution errors. + except Exception: # pylint: disable=broad-except + log.exception('Error while attributing cookies to user registration.') + + # TODO: there is no error checking here to see that the user actually logged in successfully, + # and is not yet an active user. + if new_user is not None: + AUDIT_LOG.info(u"Login success on new account creation - {0}".format(new_user.username)) + + if do_external_auth: + eamap.user = new_user + eamap.dtsignup = datetime.datetime.now(UTC) + eamap.save() + AUDIT_LOG.info(u"User registered with external_auth %s", new_user.username) + AUDIT_LOG.info(u'Updated ExternalAuthMap for %s to be %s', new_user.username, eamap) + + if settings.FEATURES.get('BYPASS_ACTIVATION_EMAIL_FOR_EXTAUTH'): + log.info('bypassing activation email') + new_user.is_active = True + new_user.save() + AUDIT_LOG.info(u"Login activated on extauth account - {0} ({1})".format(new_user.username, new_user.email)) + + return new_user + + +def _enroll_user_in_pending_courses(student): + """ + Enroll student in any pending courses he/she may have. + """ + ceas = CourseEnrollmentAllowed.objects.filter(email=student.email) + for cea in ceas: + if cea.auto_enroll: + enrollment = CourseEnrollment.enroll(student, cea.course_id) + manual_enrollment_audit = ManualEnrollmentAudit.get_manual_enrollment_by_email(student.email) + if manual_enrollment_audit is not None: + # get the enrolled by user and reason from the ManualEnrollmentAudit table. + # then create a new ManualEnrollmentAudit table entry for the same email + # different transition state. + ManualEnrollmentAudit.create_manual_enrollment_audit( + manual_enrollment_audit.enrolled_by, student.email, ALLOWEDTOENROLL_TO_ENROLLED, + manual_enrollment_audit.reason, enrollment + ) + + +def record_affiliate_registration_attribution(request, user): + """ + Attribute this user's registration to the referring affiliate, if + applicable. + """ + affiliate_id = request.COOKIES.get(settings.AFFILIATE_COOKIE_NAME) + if user and affiliate_id: + UserAttribute.set_user_attribute(user, REGISTRATION_AFFILIATE_ID, affiliate_id) + + +def record_utm_registration_attribution(request, user): + """ + Attribute this user's registration to the latest UTM referrer, if + applicable. + """ + utm_cookie_name = RegistrationCookieConfiguration.current().utm_cookie_name + utm_cookie = request.COOKIES.get(utm_cookie_name) + if user and utm_cookie: + utm = json.loads(utm_cookie) + for utm_parameter_name in REGISTRATION_UTM_PARAMETERS: + utm_parameter = utm.get(utm_parameter_name) + if utm_parameter: + UserAttribute.set_user_attribute( + user, + REGISTRATION_UTM_PARAMETERS.get(utm_parameter_name), + utm_parameter + ) + created_at_unixtime = utm.get('created_at') + if created_at_unixtime: + # We divide by 1000 here because the javascript timestamp generated is in milliseconds not seconds. + # PYTHON: time.time() => 1475590280.823698 + # JS: new Date().getTime() => 1475590280823 + created_at_datetime = datetime.datetime.fromtimestamp(int(created_at_unixtime) / float(1000), tz=UTC) + UserAttribute.set_user_attribute( + user, + REGISTRATION_UTM_CREATED_AT, + created_at_datetime + ) + + +def record_registration_attributions(request, user): + """ + Attribute this user's registration based on referrer cookies. + """ + record_affiliate_registration_attribution(request, user) + record_utm_registration_attribution(request, user) + + +@csrf_exempt +def create_account(request, post_override=None): + """ + JSON call to create new edX account. + Used by form in signup_modal.html, which is included into navigation.html + """ + # Check if ALLOW_PUBLIC_ACCOUNT_CREATION flag turned off to restrict user account creation + if not configuration_helpers.get_value( + 'ALLOW_PUBLIC_ACCOUNT_CREATION', + settings.FEATURES.get('ALLOW_PUBLIC_ACCOUNT_CREATION', True) + ): + return HttpResponseForbidden(_("Account creation not allowed.")) + + warnings.warn("Please use RegistrationView instead.", DeprecationWarning) + + try: + user = create_account_with_params(request, post_override or request.POST) + except AccountValidationError as exc: + return JsonResponse({'success': False, 'value': exc.message, 'field': exc.field}, status=400) + except ValidationError as exc: + field, error_list = next(exc.message_dict.iteritems()) + return JsonResponse( + { + "success": False, + "field": field, + "value": error_list[0], + }, + status=400 + ) + + redirect_url = None # The AJAX method calling should know the default destination upon success + + # Resume the third-party-auth pipeline if necessary. + if third_party_auth.is_enabled() and pipeline.running(request): + running_pipeline = pipeline.get(request) + redirect_url = pipeline.get_complete_url(running_pipeline['backend']) + + response = JsonResponse({ + 'success': True, + 'redirect_url': redirect_url, + }) + set_logged_in_cookies(request, response, user) + return response + + +def auto_auth(request): + """ + Create or configure a user account, then log in as that user. + + Enabled only when + settings.FEATURES['AUTOMATIC_AUTH_FOR_TESTING'] is true. + + Accepts the following querystring parameters: + * `username`, `email`, and `password` for the user account + * `full_name` for the user profile (the user's full name; defaults to the username) + * `staff`: Set to "true" to make the user global staff. + * `course_id`: Enroll the student in the course with `course_id` + * `roles`: Comma-separated list of roles to grant the student in the course with `course_id` + * `no_login`: Define this to create the user but not login + * `redirect`: Set to "true" will redirect to the `redirect_to` value if set, or + course home page if course_id is defined, otherwise it will redirect to dashboard + * `redirect_to`: will redirect to to this url + * `is_active` : make/update account with status provided as 'is_active' + If username, email, or password are not provided, use + randomly generated credentials. + """ + + # Generate a unique name to use if none provided + unique_name = uuid.uuid4().hex[0:30] + + # Use the params from the request, otherwise use these defaults + username = request.GET.get('username', unique_name) + password = request.GET.get('password', unique_name) + email = request.GET.get('email', unique_name + "@example.com") + full_name = request.GET.get('full_name', username) + is_staff = request.GET.get('staff', None) + is_superuser = request.GET.get('superuser', None) + course_id = request.GET.get('course_id', None) + redirect_to = request.GET.get('redirect_to', None) + active_status = request.GET.get('is_active') + + # mode has to be one of 'honor'/'professional'/'verified'/'audit'/'no-id-professional'/'credit' + enrollment_mode = request.GET.get('enrollment_mode', 'honor') + + active_status = (not active_status or active_status == 'true') + + course_key = None + if course_id: + course_key = CourseLocator.from_string(course_id) + role_names = [v.strip() for v in request.GET.get('roles', '').split(',') if v.strip()] + redirect_when_done = request.GET.get('redirect', '').lower() == 'true' or redirect_to + login_when_done = 'no_login' not in request.GET + + form = AccountCreationForm( + data={ + 'username': username, + 'email': email, + 'password': password, + 'name': full_name, + }, + tos_required=False + ) + + # Attempt to create the account. + # If successful, this will return a tuple containing + # the new user object. + try: + user, profile, reg = _do_create_account(form, site=request.site) + except (AccountValidationError, ValidationError): + # Attempt to retrieve the existing user. + user = User.objects.get(username=username) + user.email = email + user.set_password(password) + user.is_active = active_status + user.save() + profile = UserProfile.objects.get(user=user) + reg = Registration.objects.get(user=user) + except PermissionDenied: + return HttpResponseForbidden(_("Account creation not allowed.")) + + # Set the user's global staff bit + if is_staff is not None: + user.is_staff = (is_staff == "true") + user.save() + + if is_superuser is not None: + user.is_superuser = (is_superuser == "true") + user.save() + + if active_status: + reg.activate() + reg.save() + + # ensure parental consent threshold is met + year = datetime.date.today().year + age_limit = settings.PARENTAL_CONSENT_AGE_LIMIT + profile.year_of_birth = (year - age_limit) - 1 + profile.save() + + # Enroll the user in a course + if course_key is not None: + CourseEnrollment.enroll(user, course_key, mode=enrollment_mode) + + # Apply the roles + for role_name in role_names: + role = Role.objects.get(name=role_name, course_id=course_key) + user.roles.add(role) + + # Log in as the user + if login_when_done: + user = authenticate(username=username, password=password) + login(request, user) + + create_comments_service_user(user) + + # Provide the user with a valid CSRF token + # then return a 200 response unless redirect is true + if redirect_when_done: + # Redirect to specific page if specified + if redirect_to: + redirect_url = redirect_to + # Redirect to course info page if course_id is known + elif course_id: + try: + # redirect to course info page in LMS + redirect_url = reverse( + 'info', + kwargs={'course_id': course_id} + ) + except NoReverseMatch: + # redirect to course outline page in Studio + redirect_url = reverse( + 'course_handler', + kwargs={'course_key_string': course_id} + ) + else: + try: + # redirect to dashboard for LMS + redirect_url = reverse('dashboard') + except NoReverseMatch: + # redirect to home for Studio + redirect_url = reverse('home') + + return redirect(redirect_url) + elif request.META.get('HTTP_ACCEPT') == 'application/json': + response = JsonResponse({ + 'created_status': u"Logged in" if login_when_done else "Created", + 'username': username, + 'email': email, + 'password': password, + 'user_id': user.id, # pylint: disable=no-member + 'anonymous_id': anonymous_id_for_user(user, None), + }) + else: + success_msg = u"{} user {} ({}) with password {} and user_id {}".format( + u"Logged in" if login_when_done else "Created", + username, email, password, user.id # pylint: disable=no-member + ) + response = HttpResponse(success_msg) + response.set_cookie('csrftoken', csrf(request)['csrf_token']) + return response + + +@ensure_csrf_cookie +def activate_account(request, key): + """When link in activation e-mail is clicked""" + regs = Registration.objects.filter(activation_key=key) + if len(regs) == 1: + user_logged_in = request.user.is_authenticated() + already_active = True + if not regs[0].user.is_active: + regs[0].activate() + already_active = False + + # Enroll student in any pending courses he/she may have if auto_enroll flag is set + _enroll_user_in_pending_courses(regs[0].user) + + resp = render_to_response( + "registration/activation_complete.html", + { + 'user_logged_in': user_logged_in, + 'already_active': already_active + } + ) + return resp + if len(regs) == 0: + return render_to_response( + "registration/activation_invalid.html", + {'csrf': csrf(request)['csrf_token']} + ) + return HttpResponseServerError(_("Unknown error. Please e-mail us to let us know how it happened.")) + + +@csrf_exempt +@require_POST +def password_reset(request): + """ Attempts to send a password reset e-mail. """ + # Add some rate limiting here by re-using the RateLimitMixin as a helper class + limiter = BadRequestRateLimiter() + if limiter.is_rate_limit_exceeded(request): + AUDIT_LOG.warning("Rate limit exceeded in password_reset") + return HttpResponseForbidden() + + form = PasswordResetFormNoActive(request.POST) + if form.is_valid(): + form.save(use_https=request.is_secure(), + from_email=configuration_helpers.get_value('email_from_address', settings.DEFAULT_FROM_EMAIL), + request=request, + domain_override=request.get_host()) + # When password change is complete, a "edx.user.settings.changed" event will be emitted. + # But because changing the password is multi-step, we also emit an event here so that we can + # track where the request was initiated. + tracker.emit( + SETTING_CHANGE_INITIATED, + { + "setting": "password", + "old": None, + "new": None, + "user_id": request.user.id, + } + ) + destroy_oauth_tokens(request.user) + else: + # bad user? tick the rate limiter counter + AUDIT_LOG.info("Bad password_reset user passed in.") + limiter.tick_bad_request_counter(request) + + return JsonResponse({ + 'success': True, + 'value': render_to_string('registration/password_reset_done.html', {}), + }) + + +def uidb36_to_uidb64(uidb36): + """ + Needed to support old password reset URLs that use base36-encoded user IDs + https://github.com/django/django/commit/1184d077893ff1bc947e45b00a4d565f3df81776#diff-c571286052438b2e3190f8db8331a92bR231 + Args: + uidb36: base36-encoded user ID + + Returns: base64-encoded user ID. Otherwise returns a dummy, invalid ID + """ + try: + uidb64 = force_text(urlsafe_base64_encode(force_bytes(base36_to_int(uidb36)))) + except ValueError: + uidb64 = '1' # dummy invalid ID (incorrect padding for base64) + return uidb64 + + +def validate_password(user, password): + """ + Tie in password policy enforcement as an optional level of + security protection + + Args: + user: the user object whose password we're checking. + password: the user's proposed new password. + + Returns: + is_valid_password: a boolean indicating if the new password + passes the validation. + err_msg: an error message if there's a violation of one of the password + checks. Otherwise, `None`. + """ + err_msg = None + + if settings.FEATURES.get('ENFORCE_PASSWORD_POLICY', False): + try: + validate_password_strength(password) + except ValidationError as err: + err_msg = _('Password: ') + '; '.join(err.messages) + + # also, check the password reuse policy + if not PasswordHistory.is_allowable_password_reuse(user, password): + if user.is_staff: + num_distinct = settings.ADVANCED_SECURITY_CONFIG['MIN_DIFFERENT_STAFF_PASSWORDS_BEFORE_REUSE'] + else: + num_distinct = settings.ADVANCED_SECURITY_CONFIG['MIN_DIFFERENT_STUDENT_PASSWORDS_BEFORE_REUSE'] + # Because of how ngettext is, splitting the following into shorter lines would be ugly. + # pylint: disable=line-too-long + err_msg = ungettext( + "You are re-using a password that you have used recently. You must have {num} distinct password before reusing a previous password.", + "You are re-using a password that you have used recently. You must have {num} distinct passwords before reusing a previous password.", + num_distinct + ).format(num=num_distinct) + + # also, check to see if passwords are getting reset too frequent + if PasswordHistory.is_password_reset_too_soon(user): + num_days = settings.ADVANCED_SECURITY_CONFIG['MIN_TIME_IN_DAYS_BETWEEN_ALLOWED_RESETS'] + # Because of how ngettext is, splitting the following into shorter lines would be ugly. + # pylint: disable=line-too-long + err_msg = ungettext( + "You are resetting passwords too frequently. Due to security policies, {num} day must elapse between password resets.", + "You are resetting passwords too frequently. Due to security policies, {num} days must elapse between password resets.", + num_days + ).format(num=num_days) + + is_password_valid = err_msg is None + + return is_password_valid, err_msg + + +def password_reset_confirm_wrapper(request, uidb36=None, token=None): + """ + A wrapper around django.contrib.auth.views.password_reset_confirm. + Needed because we want to set the user as active at this step. + We also optionally do some additional password policy checks. + """ + # convert old-style base36-encoded user id to base64 + uidb64 = uidb36_to_uidb64(uidb36) + platform_name = { + "platform_name": configuration_helpers.get_value('platform_name', settings.PLATFORM_NAME) + } + try: + uid_int = base36_to_int(uidb36) + user = User.objects.get(id=uid_int) + except (ValueError, User.DoesNotExist): + # if there's any error getting a user, just let django's + # password_reset_confirm function handle it. + return password_reset_confirm( + request, uidb64=uidb64, token=token, extra_context=platform_name + ) + + if request.method == 'POST': + password = request.POST['new_password1'] + is_password_valid, password_err_msg = validate_password(user, password) + if not is_password_valid: + # We have a password reset attempt which violates some security + # policy. Use the existing Django template to communicate that + # back to the user. + context = { + 'validlink': False, + 'form': None, + 'title': _('Password reset unsuccessful'), + 'err_msg': password_err_msg, + } + context.update(platform_name) + return TemplateResponse( + request, 'registration/password_reset_confirm.html', context + ) + + # remember what the old password hash is before we call down + old_password_hash = user.password + + response = password_reset_confirm( + request, uidb64=uidb64, token=token, extra_context=platform_name + ) + + # If password reset was unsuccessful a template response is returned (status_code 200). + # Check if form is invalid then show an error to the user. + # Note if password reset was successful we get response redirect (status_code 302). + if response.status_code == 200 and not response.context_data['form'].is_valid(): + response.context_data['err_msg'] = _('Error in resetting your password. Please try again.') + return response + + # get the updated user + updated_user = User.objects.get(id=uid_int) + + # did the password hash change, if so record it in the PasswordHistory + if updated_user.password != old_password_hash: + entry = PasswordHistory() + entry.create(updated_user) + + else: + response = password_reset_confirm( + request, uidb64=uidb64, token=token, extra_context=platform_name + ) + + response_was_successful = response.context_data.get('validlink') + if response_was_successful and not user.is_active: + user.is_active = True + user.save() + + return response + + +def reactivation_email_for_user(user): + try: + reg = Registration.objects.get(user=user) + except Registration.DoesNotExist: + return JsonResponse({ + "success": False, + "error": _('No inactive user with this e-mail exists'), + }) # TODO: this should be status code 400 # pylint: disable=fixme + + try: + context = { + 'name': user.profile.name, + 'key': reg.activation_key, + } + except ObjectDoesNotExist: + log.error( + u'Unable to send reactivation email due to unavailable profile for the user "%s"', + user.username, + exc_info=True + ) + return JsonResponse({ + "success": False, + "error": _('Unable to send reactivation email') + }) + + subject = render_to_string('emails/activation_email_subject.txt', context) + subject = ''.join(subject.splitlines()) + message = render_to_string('emails/activation_email.txt', context) + from_address = configuration_helpers.get_value('email_from_address', settings.DEFAULT_FROM_EMAIL) + + try: + user.email_user(subject, message, from_address) + except Exception: # pylint: disable=broad-except + log.error( + u'Unable to send reactivation email from "%s" to "%s"', + from_address, + user.email, + exc_info=True + ) + return JsonResponse({ + "success": False, + "error": _('Unable to send reactivation email') + }) # TODO: this should be status code 500 # pylint: disable=fixme + + return JsonResponse({"success": True}) + + +def validate_new_email(user, new_email): + """ + Given a new email for a user, does some basic verification of the new address If any issues are encountered + with verification a ValueError will be thrown. + """ + try: + validate_email(new_email) + except ValidationError: + raise ValueError(_('Valid e-mail address required.')) + + if new_email == user.email: + raise ValueError(_('Old email is the same as the new email.')) + + if User.objects.filter(email=new_email).count() != 0: + raise ValueError(_('An account with this e-mail already exists.')) + + +def do_email_change_request(user, new_email, activation_key=None): + """ + Given a new email for a user, does some basic verification of the new address and sends an activation message + to the new address. If any issues are encountered with verification or sending the message, a ValueError will + be thrown. + """ + pec_list = PendingEmailChange.objects.filter(user=user) + if len(pec_list) == 0: + pec = PendingEmailChange() + pec.user = user + else: + pec = pec_list[0] + + # if activation_key is not passing as an argument, generate a random key + if not activation_key: + activation_key = uuid.uuid4().hex + + pec.new_email = new_email + pec.activation_key = activation_key + pec.save() + + context = { + 'key': pec.activation_key, + 'old_email': user.email, + 'new_email': pec.new_email + } + + subject = render_to_string('emails/email_change_subject.txt', context) + subject = ''.join(subject.splitlines()) + + message = render_to_string('emails/email_change.txt', context) + + from_address = configuration_helpers.get_value( + 'email_from_address', + settings.DEFAULT_FROM_EMAIL + ) + try: + mail.send_mail(subject, message, from_address, [pec.new_email]) + except Exception: # pylint: disable=broad-except + log.error(u'Unable to send email activation link to user from "%s"', from_address, exc_info=True) + raise ValueError(_('Unable to send email activation link. Please try again later.')) + + # When the email address change is complete, a "edx.user.settings.changed" event will be emitted. + # But because changing the email address is multi-step, we also emit an event here so that we can + # track where the request was initiated. + tracker.emit( + SETTING_CHANGE_INITIATED, + { + "setting": "email", + "old": context['old_email'], + "new": context['new_email'], + "user_id": user.id, + } + ) + + +@ensure_csrf_cookie +def confirm_email_change(request, key): # pylint: disable=unused-argument + """ + User requested a new e-mail. This is called when the activation + link is clicked. We confirm with the old e-mail, and update + """ + with transaction.atomic(): + try: + pec = PendingEmailChange.objects.get(activation_key=key) + except PendingEmailChange.DoesNotExist: + response = render_to_response("invalid_email_key.html", {}) + transaction.set_rollback(True) + return response + + user = pec.user + address_context = { + 'old_email': user.email, + 'new_email': pec.new_email + } + + if len(User.objects.filter(email=pec.new_email)) != 0: + response = render_to_response("email_exists.html", {}) + transaction.set_rollback(True) + return response + + subject = render_to_string('emails/email_change_subject.txt', address_context) + subject = ''.join(subject.splitlines()) + message = render_to_string('emails/confirm_email_change.txt', address_context) + u_prof = UserProfile.objects.get(user=user) + meta = u_prof.get_meta() + if 'old_emails' not in meta: + meta['old_emails'] = [] + meta['old_emails'].append([user.email, datetime.datetime.now(UTC).isoformat()]) + u_prof.set_meta(meta) + u_prof.save() + # Send it to the old email... + try: + user.email_user( + subject, + message, + configuration_helpers.get_value('email_from_address', settings.DEFAULT_FROM_EMAIL) + ) + except Exception: # pylint: disable=broad-except + log.warning('Unable to send confirmation email to old address', exc_info=True) + response = render_to_response("email_change_failed.html", {'email': user.email}) + transaction.set_rollback(True) + return response + + user.email = pec.new_email + user.save() + pec.delete() + # And send it to the new email... + try: + user.email_user( + subject, + message, + configuration_helpers.get_value('email_from_address', settings.DEFAULT_FROM_EMAIL) + ) + except Exception: # pylint: disable=broad-except + log.warning('Unable to send confirmation email to new address', exc_info=True) + response = render_to_response("email_change_failed.html", {'email': pec.new_email}) + transaction.set_rollback(True) + return response + + response = render_to_response("email_change_successful.html", address_context) + return response + + +@require_POST +@login_required +@ensure_csrf_cookie +def change_email_settings(request): + """Modify logged-in user's setting for receiving emails from a course.""" + user = request.user + + course_id = request.POST.get("course_id") + course_key = SlashSeparatedCourseKey.from_deprecated_string(course_id) + receive_emails = request.POST.get("receive_emails") + if receive_emails: + optout_object = Optout.objects.filter(user=user, course_id=course_key) + if optout_object: + optout_object.delete() + log.info( + u"User %s (%s) opted in to receive emails from course %s", + user.username, + user.email, + course_id, + ) + track.views.server_track( + request, + "change-email-settings", + {"receive_emails": "yes", "course": course_id}, + page='dashboard', + ) + else: + Optout.objects.get_or_create(user=user, course_id=course_key) + log.info( + u"User %s (%s) opted out of receiving emails from course %s", + user.username, + user.email, + course_id, + ) + track.views.server_track( + request, + "change-email-settings", + {"receive_emails": "no", "course": course_id}, + page='dashboard', + ) + + return JsonResponse({"success": True}) + + +class LogoutView(TemplateView): + """ + Logs out user and redirects. + + The template should load iframes to log the user out of OpenID Connect services. + See http://openid.net/specs/openid-connect-logout-1_0.html. + """ + oauth_client_ids = [] + template_name = 'logout.html' + + # Keep track of the page to which the user should ultimately be redirected. + default_target = reverse_lazy('cas-logout') if settings.FEATURES.get('AUTH_USE_CAS') else '/' + + @property + def target(self): + """ + If a redirect_url is specified in the querystring for this request, and the value is a url + with the same host, the view will redirect to this page after rendering the template. + If it is not specified, we will use the default target url. + """ + target_url = self.request.GET.get('redirect_url') + + if target_url and is_safe_url(target_url, self.request.META.get('HTTP_HOST')): + return target_url + else: + return self.default_target + + def dispatch(self, request, *args, **kwargs): # pylint: disable=missing-docstring + # We do not log here, because we have a handler registered to perform logging on successful logouts. + request.is_from_logout = True + + # Get the list of authorized clients before we clear the session. + self.oauth_client_ids = request.session.get(edx_oauth2_provider.constants.AUTHORIZED_CLIENTS_SESSION_KEY, []) + + logout(request) + + # If we don't need to deal with OIDC logouts, just redirect the user. + if LogoutViewConfiguration.current().enabled and self.oauth_client_ids: + response = super(LogoutView, self).dispatch(request, *args, **kwargs) + else: + response = redirect(self.target) + + # Clear the cookie used by the edx.org marketing site + delete_logged_in_cookies(response) + + return response + + def _build_logout_url(self, url): + """ + Builds a logout URL with the `no_redirect` query string parameter. + + Args: + url (str): IDA logout URL + + Returns: + str + """ + scheme, netloc, path, query_string, fragment = urlsplit(url) + query_params = parse_qs(query_string) + query_params['no_redirect'] = 1 + new_query_string = urlencode(query_params, doseq=True) + return urlunsplit((scheme, netloc, path, new_query_string, fragment)) + + def get_context_data(self, **kwargs): + context = super(LogoutView, self).get_context_data(**kwargs) + + # Create a list of URIs that must be called to log the user out of all of the IDAs. + uris = Client.objects.filter(client_id__in=self.oauth_client_ids, + logout_uri__isnull=False).values_list('logout_uri', flat=True) + + referrer = self.request.META.get('HTTP_REFERER', '').strip('/') + logout_uris = [] + + for uri in uris: + if not referrer or (referrer and not uri.startswith(referrer)): + logout_uris.append(self._build_logout_url(uri)) + + context.update({ + 'target': self.target, + 'logout_uris': logout_uris, + }) + + return context diff --git a/modified/lms.djangoapps.courseware.views.views.py b/modified/lms.djangoapps.courseware.views.views.py new file mode 100644 index 0000000..009499e --- /dev/null +++ b/modified/lms.djangoapps.courseware.views.views.py @@ -0,0 +1,1632 @@ +""" +Courseware views functions +""" +import json +import logging +import urllib +from collections import OrderedDict, namedtuple +from datetime import datetime + +import analytics +from django.conf import settings +from django.contrib.auth.decorators import login_required +from django.contrib.auth.models import User, AnonymousUser +from django.core.exceptions import PermissionDenied + +from django.core.urlresolvers import reverse +from django.core.context_processors import csrf +from django.db import transaction +from django.db.models import Q +from django.http import ( + Http404, + HttpResponse, + HttpResponseBadRequest, + HttpResponseForbidden, + QueryDict, +) +from django.shortcuts import redirect +from django.utils.decorators import method_decorator +from django.utils.timezone import UTC +from django.utils.translation import ugettext as _ +from django.views.decorators.cache import cache_control +from django.views.decorators.csrf import ensure_csrf_cookie +from django.views.decorators.http import require_GET, require_POST, require_http_methods +from django.views.generic import View +from eventtracking import tracker +from ipware.ip import get_ip +from markupsafe import escape +from opaque_keys import InvalidKeyError +from opaque_keys.edx.keys import CourseKey, UsageKey +from rest_framework import status +from lms.djangoapps.ccx.custom_exception import CCXLocatorValidationException +from lms.djangoapps.ccx.utils import prep_course_for_grading +from lms.djangoapps.courseware.exceptions import CourseAccessRedirect, Redirect +from lms.djangoapps.grades.new.course_grade_factory import CourseGradeFactory +from lms.djangoapps.instructor.enrollment import uses_shib +from lms.djangoapps.instructor.views.api import require_global_staff +from lms.djangoapps.verify_student.models import SoftwareSecurePhotoVerification + +import shoppingcart +import survey.utils +import survey.views +from certificates import api as certs_api +from certificates.models import CertificateStatuses +from openedx.core.djangoapps.models.course_details import CourseDetails +from openedx.core.djangoapps.plugin_api.views import EdxFragmentView +from commerce.utils import EcommerceService +from enrollment.api import add_enrollment +from course_modes.models import CourseMode +from courseware.access import has_access, has_ccx_coach_role, _adjust_start_date_for_beta_testers +from courseware.access_response import StartDateError +from courseware.access_utils import in_preview_mode +from courseware.courses import ( + get_courses, + get_course, + get_course_by_id, + get_course_overview_with_access, + get_course_with_access, + get_last_accessed_courseware, + get_permission_for_course_about, + get_studio_url, + sort_by_announcement, + sort_by_start_date, +) +from courseware.date_summary import VerifiedUpgradeDeadlineDate +from courseware.masquerade import setup_masquerade +from courseware.model_data import FieldDataCache +from courseware.models import StudentModule, BaseStudentModuleHistory +from courseware.url_helpers import get_redirect_url +from courseware.user_state_client import DjangoXBlockUserStateClient +from edxmako.shortcuts import render_to_response, render_to_string, marketing_link +from openedx.core.djangoapps.catalog.utils import get_programs, get_programs_with_type +from openedx.core.djangoapps.content.course_overviews.models import CourseOverview +from openedx.core.djangoapps.coursetalk.helpers import inject_coursetalk_keys_into_context +from openedx.core.djangoapps.credit.api import ( + get_credit_requirement_status, + is_user_eligible_for_credit, + is_credit_course +) +from openedx.core.djangoapps.programs.utils import ProgramMarketingDataExtender +from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers +from openedx.core.djangoapps.self_paced.models import SelfPacedConfiguration +from openedx.core.djangoapps.monitoring_utils import set_custom_metrics_for_course_key +from openedx.features.enterprise_support.api import data_sharing_consent_required +from shoppingcart.models import CourseRegistrationCode +from shoppingcart.utils import is_shopping_cart_enabled +from student.models import UserTestGroup, CourseEnrollment +from student.roles import GlobalStaff +from survey.utils import must_answer_survey +from util.cache import cache, cache_if_anonymous +from util.date_utils import strftime_localized +from util.db import outer_atomic +from util.milestones_helpers import get_prerequisite_courses_display +from util.views import _record_feedback_in_zendesk +from util.views import ensure_valid_course_key, ensure_valid_usage_key +from xmodule.modulestore.django import modulestore +from xmodule.modulestore.exceptions import ItemNotFoundError, NoPathToItem +from xmodule.tabs import CourseTabList +from xmodule.x_module import STUDENT_VIEW +from web_fragments.fragment import Fragment + +from ..entrance_exams import user_can_skip_entrance_exam +from ..module_render import get_module_for_descriptor, get_module, get_module_by_usage_id + +log = logging.getLogger("edx.courseware") + + +# Only display the requirements on learner dashboard for +# credit and verified modes. +REQUIREMENTS_DISPLAY_MODES = CourseMode.CREDIT_MODES + [CourseMode.VERIFIED] + +CertData = namedtuple("CertData", ["cert_status", "title", "msg", "download_url", "cert_web_view_url"]) + + +def user_groups(user): + """ + TODO (vshnayder): This is not used. When we have a new plan for groups, adjust appropriately. + """ + if not user.is_authenticated(): + return [] + + # TODO: Rewrite in Django + key = 'user_group_names_{user.id}'.format(user=user) + cache_expiration = 60 * 60 # one hour + + # Kill caching on dev machines -- we switch groups a lot + group_names = cache.get(key) # pylint: disable=no-member + if settings.DEBUG: + group_names = None + + if group_names is None: + group_names = [u.name for u in UserTestGroup.objects.filter(users=user)] + cache.set(key, group_names, cache_expiration) # pylint: disable=no-member + + return group_names + + +@ensure_csrf_cookie +@cache_if_anonymous() +def courses(request): + """ + Render "find courses" page. The course selection work is done in courseware.courses. + """ + courses_list = [] + programs_list = [] + course_discovery_meanings = getattr(settings, 'COURSE_DISCOVERY_MEANINGS', {}) + if not settings.FEATURES.get('ENABLE_COURSE_DISCOVERY'): + courses_list = get_courses(request.user) + + if configuration_helpers.get_value("ENABLE_COURSE_SORTING_BY_START_DATE", + settings.FEATURES["ENABLE_COURSE_SORTING_BY_START_DATE"]): + courses_list = sort_by_start_date(courses_list) + else: + courses_list = sort_by_announcement(courses_list) + + # Get the active programs of the type configured for the current site from the catalog service. The programs_list + # is being added to the context but it's not being used currently in courseware/courses.html. To use this list, + # you need to create a custom theme that overrides courses.html. The modifications to courses.html to display the + # programs will be done after the support for edx-pattern-library is added. + program_types = configuration_helpers.get_value('ENABLED_PROGRAM_TYPES') + + # Do not add programs to the context if there are no program types enabled for the site. + if program_types: + programs_list = get_programs_with_type(program_types) + + return render_to_response( + "courseware/courses.html", + { + 'courses': courses_list, + 'course_discovery_meanings': course_discovery_meanings, + 'programs_list': programs_list + } + ) + + +@ensure_csrf_cookie +@ensure_valid_course_key +def jump_to_id(request, course_id, module_id): + """ + This entry point allows for a shorter version of a jump to where just the id of the element is + passed in. This assumes that id is unique within the course_id namespace + """ + course_key = CourseKey.from_string(course_id) + items = modulestore().get_items(course_key, qualifiers={'name': module_id}) + + if len(items) == 0: + raise Http404( + u"Could not find id: {0} in course_id: {1}. Referer: {2}".format( + module_id, course_id, request.META.get("HTTP_REFERER", "") + )) + if len(items) > 1: + log.warning( + u"Multiple items found with id: %s in course_id: %s. Referer: %s. Using first: %s", + module_id, + course_id, + request.META.get("HTTP_REFERER", ""), + items[0].location.to_deprecated_string() + ) + + return jump_to(request, course_id, items[0].location.to_deprecated_string()) + + +@ensure_csrf_cookie +def jump_to(_request, course_id, location): + """ + Show the page that contains a specific location. + + If the location is invalid or not in any class, return a 404. + + Otherwise, delegates to the index view to figure out whether this user + has access, and what they should see. + """ + try: + course_key = CourseKey.from_string(course_id) + usage_key = UsageKey.from_string(location).replace(course_key=course_key) + except InvalidKeyError: + raise Http404(u"Invalid course_key or usage_key") + try: + redirect_url = get_redirect_url(course_key, usage_key) + except ItemNotFoundError: + raise Http404(u"No data at this location: {0}".format(usage_key)) + except NoPathToItem: + raise Http404(u"This location is not in any class: {0}".format(usage_key)) + + return redirect(redirect_url) + + +@ensure_csrf_cookie +@ensure_valid_course_key +@data_sharing_consent_required +def course_info(request, course_id): + """ + Display the course's info.html, or 404 if there is no such course. + + Assumes the course_id is in a valid format. + """ + course_key = CourseKey.from_string(course_id) + with modulestore().bulk_operations(course_key): + course = get_course_by_id(course_key, depth=2) + access_response = has_access(request.user, 'load', course, course_key) + + if not access_response: + + # The user doesn't have access to the course. If they're + # denied permission due to the course not being live yet, + # redirect to the dashboard page. + if isinstance(access_response, StartDateError): + start_date = strftime_localized(course.start, 'SHORT_DATE') + params = QueryDict(mutable=True) + params['notlive'] = start_date + return redirect('{dashboard_url}?{params}'.format( + dashboard_url=reverse('dashboard'), + params=params.urlencode() + )) + # Otherwise, give a 404 to avoid leaking info about access + # control. + raise Http404("Course not found.") + + staff_access = has_access(request.user, 'staff', course) + masquerade, user = setup_masquerade(request, course_key, staff_access, reset_masquerade_data=True) + + # if user is not enrolled in a course then app will show enroll/get register link inside course info page. + show_enroll_banner = request.user.is_authenticated() and not CourseEnrollment.is_enrolled(user, course.id) + if show_enroll_banner and hasattr(course_key, 'ccx'): + # if course is CCX and user is not enrolled/registered then do not let him open course direct via link for + # self registration. Because only CCX coach can register/enroll a student. If un-enrolled user try + # to access CCX redirect him to dashboard. + return redirect(reverse('dashboard')) + + # Redirect the user if they are not yet allowed to view this course + check_access_to_course(request, course) + + # If the user needs to take an entrance exam to access this course, then we'll need + # to send them to that specific course module before allowing them into other areas + if not user_can_skip_entrance_exam(user, course): + return redirect(reverse('courseware', args=[unicode(course.id)])) + + # If the user is coming from the dashboard and bypass_home setting is set, + # redirect them straight to the courseware page. + is_from_dashboard = reverse('dashboard') in request.META.get('HTTP_REFERER', []) + if course.bypass_home and is_from_dashboard: + return redirect(reverse('courseware', args=[course_id])) + + # link to where the student should go to enroll in the course: + # about page if there is not marketing site, SITE_NAME if there is + url_to_enroll = reverse(course_about, args=[course_id]) + if settings.FEATURES.get('ENABLE_MKTG_SITE'): + url_to_enroll = marketing_link('COURSES') + + store_upgrade_cookie = False + upgrade_cookie_name = 'show_upgrade_notification' + upgrade_link = None + if request.user.is_authenticated(): + show_upgrade_notification = False + if request.GET.get('upgrade', 'false') == 'true': + store_upgrade_cookie = True + show_upgrade_notification = True + elif upgrade_cookie_name in request.COOKIES and course_id in request.COOKIES[upgrade_cookie_name]: + show_upgrade_notification = True + + if show_upgrade_notification: + upgrade_data = VerifiedUpgradeDeadlineDate(course, user) + if upgrade_data.is_enabled: + upgrade_link = upgrade_data.link + else: + # The upgrade is not enabled so the cookie does not need to be stored + store_upgrade_cookie = False + + context = { + 'request': request, + 'masquerade_user': user, + 'course_id': course_key.to_deprecated_string(), + 'cache': None, + 'course': course, + 'staff_access': staff_access, + 'masquerade': masquerade, + 'supports_preview_menu': True, + 'studio_url': get_studio_url(course, 'course_info'), + 'show_enroll_banner': show_enroll_banner, + 'url_to_enroll': url_to_enroll, + 'upgrade_link': upgrade_link, + } + + # Get the URL of the user's last position in order to display the 'where you were last' message + context['last_accessed_courseware_url'] = None + if SelfPacedConfiguration.current().enable_course_home_improvements: + context['last_accessed_courseware_url'], _ = get_last_accessed_courseware(course, request, user) + + now = datetime.now(UTC()) + effective_start = _adjust_start_date_for_beta_testers(user, course, course_key) + if not in_preview_mode() and staff_access and now < effective_start: + # Disable student view button if user is staff and + # course is not yet visible to students. + context['disable_student_access'] = True + + if CourseEnrollment.is_enrolled(request.user, course.id): + inject_coursetalk_keys_into_context(context, course_key) + + response = render_to_response('courseware/info.html', context) + if store_upgrade_cookie: + if upgrade_cookie_name in request.COOKIES and str(course_id) not in request.COOKIES[upgrade_cookie_name]: + cookie_value = '%s,%s' % (course_id, request.COOKIES[upgrade_cookie_name]) + elif upgrade_cookie_name in request.COOKIES and str(course_id) in request.COOKIES[upgrade_cookie_name]: + cookie_value = request.COOKIES[upgrade_cookie_name] + else: + cookie_value = course_id + + if cookie_value is not None: + response.set_cookie( + upgrade_cookie_name, + cookie_value, + max_age=10 * 24 * 60 * 60, # set for 10 days + domain=settings.SESSION_COOKIE_DOMAIN, + httponly=True # no use case for accessing from JavaScript + ) + + return response + + +class StaticCourseTabView(EdxFragmentView): + """ + View that displays a static course tab with a given name. + """ + @method_decorator(ensure_csrf_cookie) + @method_decorator(ensure_valid_course_key) + def get(self, request, course_id, tab_slug, **kwargs): + """ + Displays a static course tab page with a given name + """ + course_key = CourseKey.from_string(course_id) + course = get_course_with_access(request.user, 'load', course_key) + tab = CourseTabList.get_tab_by_slug(course.tabs, tab_slug) + if tab is None: + raise Http404 + return super(StaticCourseTabView, self).get(request, course=course, tab=tab, **kwargs) + + def render_to_fragment(self, request, course=None, tab=None, **kwargs): + """ + Renders the static tab to a fragment. + """ + return get_static_tab_fragment(request, course, tab) + + def render_to_standalone_html(self, request, fragment, course=None, tab=None, **kwargs): + """ + Renders this static tab's fragment to HTML for a standalone page. + """ + return render_to_response('courseware/static_tab.html', { + 'course': course, + 'active_page': 'static_tab_{0}'.format(tab['url_slug']), + 'tab': tab, + 'fragment': fragment, + 'uses_pattern_library': False, + 'disable_courseware_js': True, + }) + + +class CourseTabView(EdxFragmentView): + """ + View that displays a course tab page. + """ + @method_decorator(ensure_csrf_cookie) + @method_decorator(ensure_valid_course_key) + @method_decorator(data_sharing_consent_required) + def get(self, request, course_id, tab_type, **kwargs): + """ + Displays a course tab page that contains a web fragment. + """ + course_key = CourseKey.from_string(course_id) + with modulestore().bulk_operations(course_key): + course = get_course_with_access(request.user, 'load', course_key) + try: + # Verify that the user has access to the course + check_access_to_course(request, course) + + # Render the page + tab = CourseTabList.get_tab_by_type(course.tabs, tab_type) + page_context = self.create_page_context(request, course=course, tab=tab, **kwargs) + set_custom_metrics_for_course_key(course_key) + return super(CourseTabView, self).get(request, course=course, page_context=page_context, **kwargs) + except Exception as exception: # pylint: disable=broad-except + return CourseTabView.handle_exceptions(request, course, exception) + + @staticmethod + def handle_exceptions(request, course, exception): + """ + Handle exceptions raised when rendering a view. + """ + if isinstance(exception, Redirect) or isinstance(exception, Http404): + raise + if isinstance(exception, UnicodeEncodeError): + raise Http404("URL contains Unicode characters") + if settings.DEBUG: + raise + user = request.user + log.exception( + u"Error in %s: user=%s, effective_user=%s, course=%s", + request.path, + getattr(user, 'real_user', user), + user, + unicode(course.id), + ) + try: + return render_to_response( + 'courseware/courseware-error.html', + { + 'staff_access': has_access(user, 'staff', course), + 'course': course, + }, + status=500, + ) + except: + # Let the exception propagate, relying on global config to + # at least return a nice error message + log.exception("Error while rendering courseware-error page") + raise + + def create_page_context(self, request, course=None, tab=None, **kwargs): + """ + Creates the context for the fragment's template. + """ + staff_access = has_access(request.user, 'staff', course) + supports_preview_menu = tab.get('supports_preview_menu', False) + if supports_preview_menu: + masquerade, masquerade_user = setup_masquerade(request, course.id, staff_access, reset_masquerade_data=True) + request.user = masquerade_user + else: + masquerade = None + return { + 'course': course, + 'tab': tab, + 'active_page': tab.get('type', None), + 'staff_access': staff_access, + 'masquerade': masquerade, + 'supports_preview_menu': supports_preview_menu, + 'uses_pattern_library': True, + 'disable_courseware_js': True, + } + + def render_to_fragment(self, request, course=None, page_context=None, **kwargs): + """ + Renders the course tab to a fragment. + """ + tab = page_context['tab'] + return tab.render_to_fragment(request, course, **kwargs) + + def render_to_standalone_html(self, request, fragment, course=None, tab=None, page_context=None, **kwargs): + """ + Renders this course tab's fragment to HTML for a standalone page. + """ + if not page_context: + page_context = self.create_page_context(request, course=course, tab=tab, **kwargs) + page_context['fragment'] = fragment + return render_to_string('courseware/tab-view.html', page_context) + + +@ensure_csrf_cookie +@ensure_valid_course_key +def syllabus(request, course_id): + """ + Display the course's syllabus.html, or 404 if there is no such course. + + Assumes the course_id is in a valid format. + """ + + course_key = CourseKey.from_string(course_id) + + course = get_course_with_access(request.user, 'load', course_key) + staff_access = bool(has_access(request.user, 'staff', course)) + + return render_to_response('courseware/syllabus.html', { + 'course': course, + 'staff_access': staff_access, + }) + + +def registered_for_course(course, user): + """ + Return True if user is registered for course, else False + """ + if user is None: + return False + if user.is_authenticated(): + return CourseEnrollment.is_enrolled(user, course.id) + else: + return False + + +def get_cosmetic_display_price(course, registration_price): + """ + Return Course Price as a string preceded by correct currency, or 'Free' + """ + currency_symbol = settings.PAID_COURSE_REGISTRATION_CURRENCY[1] + + price = course.cosmetic_display_price + if registration_price > 0: + price = registration_price + + if price: + # Translators: This will look like '$50', where {currency_symbol} is a symbol such as '$' and {price} is a + # numerical amount in that currency. Adjust this display as needed for your language. + return _("{currency_symbol}{price}").format(currency_symbol=currency_symbol, price=price) + else: + # Translators: This refers to the cost of the course. In this case, the course costs nothing so it is free. + return _('Free') + + +class EnrollStaffView(View): + """ + Displays view for registering in the course to a global staff user. + + User can either choose to 'Enroll' or 'Don't Enroll' in the course. + Enroll: Enrolls user in course and redirects to the courseware. + Don't Enroll: Redirects user to course about page. + + Arguments: + - request : HTTP request + - course_id : course id + + Returns: + - RedirectResponse + """ + template_name = 'enroll_staff.html' + + @method_decorator(require_global_staff) + @method_decorator(ensure_valid_course_key) + def get(self, request, course_id): + """ + Display enroll staff view to global staff user with `Enroll` and `Don't Enroll` options. + """ + user = request.user + course_key = CourseKey.from_string(course_id) + with modulestore().bulk_operations(course_key): + course = get_course_with_access(user, 'load', course_key) + if not registered_for_course(course, user): + context = { + 'course': course, + 'csrftoken': csrf(request)["csrf_token"] + } + return render_to_response(self.template_name, context) + + @method_decorator(require_global_staff) + @method_decorator(ensure_valid_course_key) + def post(self, request, course_id): + """ + Either enrolls the user in course or redirects user to course about page + depending upon the option (Enroll, Don't Enroll) chosen by the user. + """ + _next = urllib.quote_plus(request.GET.get('next', 'info'), safe='/:?=') + course_key = CourseKey.from_string(course_id) + enroll = 'enroll' in request.POST + if enroll: + add_enrollment(request.user.username, course_id) + log.info( + u"User %s enrolled in %s via `enroll_staff` view", + request.user.username, + course_id + ) + return redirect(_next) + + # In any other case redirect to the course about page. + return redirect(reverse('about_course', args=[unicode(course_key)])) + + +@ensure_csrf_cookie +@ensure_valid_course_key +@cache_if_anonymous() +def course_about(request, course_id): + """ + Display the course's about page. + """ + + course_key = CourseKey.from_string(course_id) + + if hasattr(course_key, 'ccx'): + # if un-enrolled/non-registered user try to access CCX (direct for registration) + # then do not show him about page to avoid self registration. + # Note: About page will only be shown to user who is not register. So that he can register. But for + # CCX only CCX coach can enroll students. + return redirect(reverse('dashboard')) + + with modulestore().bulk_operations(course_key): + permission = get_permission_for_course_about() + course = get_course_with_access(request.user, permission, course_key) + course_details = CourseDetails.populate(course) + modes = CourseMode.modes_for_course_dict(course_key) + + if configuration_helpers.get_value('ENABLE_MKTG_SITE', settings.FEATURES.get('ENABLE_MKTG_SITE', False)): + return redirect(reverse('info', args=[course.id.to_deprecated_string()])) + + registered = registered_for_course(course, request.user) + + staff_access = bool(has_access(request.user, 'staff', course)) + studio_url = get_studio_url(course, 'settings/details') + + if has_access(request.user, 'load', course): + course_target = reverse('info', args=[course.id.to_deprecated_string()]) + else: + course_target = reverse('about_course', args=[course.id.to_deprecated_string()]) + + show_courseware_link = bool( + ( + has_access(request.user, 'load', course) and + has_access(request.user, 'view_courseware_with_prerequisites', course) + ) or settings.FEATURES.get('ENABLE_LMS_MIGRATION') + ) + + # Note: this is a flow for payment for course registration, not the Verified Certificate flow. + in_cart = False + reg_then_add_to_cart_link = "" + + _is_shopping_cart_enabled = is_shopping_cart_enabled() + if _is_shopping_cart_enabled: + if request.user.is_authenticated(): + cart = shoppingcart.models.Order.get_cart_for_user(request.user) + in_cart = shoppingcart.models.PaidCourseRegistration.contained_in_order(cart, course_key) or \ + shoppingcart.models.CourseRegCodeItem.contained_in_order(cart, course_key) + + reg_then_add_to_cart_link = "{reg_url}?course_id={course_id}&enrollment_action=add_to_cart".format( + reg_url=reverse('register_user'), course_id=urllib.quote(str(course_id)) + ) + + # If the ecommerce checkout flow is enabled and the mode of the course is + # professional or no id professional, we construct links for the enrollment + # button to add the course to the ecommerce basket. + ecomm_service = EcommerceService() + ecommerce_checkout = ecomm_service.is_enabled(request.user) + ecommerce_checkout_link = '' + ecommerce_bulk_checkout_link = '' + professional_mode = None + is_professional_mode = CourseMode.PROFESSIONAL in modes or CourseMode.NO_ID_PROFESSIONAL_MODE in modes + if ecommerce_checkout and is_professional_mode: + professional_mode = modes.get(CourseMode.PROFESSIONAL, '') or \ + modes.get(CourseMode.NO_ID_PROFESSIONAL_MODE, '') + if professional_mode.sku: + ecommerce_checkout_link = ecomm_service.checkout_page_url(professional_mode.sku) + if professional_mode.bulk_sku: + ecommerce_bulk_checkout_link = ecomm_service.checkout_page_url(professional_mode.bulk_sku) + + # Find the minimum price for the course across all course modes + registration_price = CourseMode.min_course_price_for_currency( + course_key, + settings.PAID_COURSE_REGISTRATION_CURRENCY[0] + ) + course_price = get_cosmetic_display_price(course, registration_price) + + # Determine which checkout workflow to use -- LMS shoppingcart or Otto basket + can_add_course_to_cart = _is_shopping_cart_enabled and registration_price and not ecommerce_checkout_link + + # Used to provide context to message to student if enrollment not allowed + can_enroll = bool(has_access(request.user, 'enroll', course)) + invitation_only = course.invitation_only + is_course_full = CourseEnrollment.objects.is_course_full(course) + + # Register button should be disabled if one of the following is true: + # - Student is already registered for course + # - Course is already full + # - Student cannot enroll in course + active_reg_button = not(registered or is_course_full or not can_enroll) + + is_shib_course = uses_shib(course) + + # get prerequisite courses display names + pre_requisite_courses = get_prerequisite_courses_display(course) + + # Overview + overview = CourseOverview.get_from_id(course.id) + + context = { + 'course': course, + 'course_details': course_details, + 'staff_access': staff_access, + 'studio_url': studio_url, + 'registered': registered, + 'course_target': course_target, + 'is_cosmetic_price_enabled': settings.FEATURES.get('ENABLE_COSMETIC_DISPLAY_PRICE'), + 'course_price': course_price, + 'in_cart': in_cart, + 'ecommerce_checkout': ecommerce_checkout, + 'ecommerce_checkout_link': ecommerce_checkout_link, + 'ecommerce_bulk_checkout_link': ecommerce_bulk_checkout_link, + 'professional_mode': professional_mode, + 'reg_then_add_to_cart_link': reg_then_add_to_cart_link, + 'show_courseware_link': show_courseware_link, + 'is_course_full': is_course_full, + 'can_enroll': can_enroll, + 'invitation_only': invitation_only, + 'active_reg_button': active_reg_button, + 'is_shib_course': is_shib_course, + # We do not want to display the internal courseware header, which is used when the course is found in the + # context. This value is therefor explicitly set to render the appropriate header. + 'disable_courseware_header': True, + 'can_add_course_to_cart': can_add_course_to_cart, + 'cart_link': reverse('shoppingcart.views.show_cart'), + 'pre_requisite_courses': pre_requisite_courses, + 'course_image_urls': overview.image_urls, + } + inject_coursetalk_keys_into_context(context, course_key) + + return render_to_response('courseware/course_about.html', context) + + +@ensure_csrf_cookie +@cache_if_anonymous() +def program_marketing(request, program_uuid): + """ + Display the program marketing page. + """ + program_data = get_programs(uuid=program_uuid) + + if not program_data: + raise Http404 + + return render_to_response('courseware/program_marketing.html', { + 'program': ProgramMarketingDataExtender(program_data, request.user).extend() + }) + + +@transaction.non_atomic_requests +@login_required +@cache_control(no_cache=True, no_store=True, must_revalidate=True) +@ensure_valid_course_key +@data_sharing_consent_required +def progress(request, course_id, student_id=None): + """ Display the progress page. """ + course_key = CourseKey.from_string(course_id) + + with modulestore().bulk_operations(course_key): + return _progress(request, course_key, student_id) + + +def _progress(request, course_key, student_id): + """ + Unwrapped version of "progress". + + User progress. We show the grade bar and every problem score. + + Course staff are allowed to see the progress of students in their class. + """ + + if student_id is not None: + try: + student_id = int(student_id) + # Check for ValueError if 'student_id' cannot be converted to integer. + except ValueError: + raise Http404 + + course = get_course_with_access(request.user, 'load', course_key) + + staff_access = bool(has_access(request.user, 'staff', course)) + + masquerade = None + if student_id is None or student_id == request.user.id: + # This will be a no-op for non-staff users, returning request.user + masquerade, student = setup_masquerade(request, course_key, staff_access, reset_masquerade_data=True) + else: + try: + coach_access = has_ccx_coach_role(request.user, course_key) + except CCXLocatorValidationException: + coach_access = False + + has_access_on_students_profiles = staff_access or coach_access + # Requesting access to a different student's profile + if not has_access_on_students_profiles: + raise Http404 + try: + student = User.objects.get(id=student_id) + except User.DoesNotExist: + raise Http404 + + # NOTE: To make sure impersonation by instructor works, use + # student instead of request.user in the rest of the function. + + # Redirect the user if they are not yet allowed to view this course + check_access_to_course(request, course) + + # The pre-fetching of groups is done to make auth checks not require an + # additional DB lookup (this kills the Progress page in particular). + student = User.objects.prefetch_related("groups").get(id=student.id) + if request.user.id != student.id: + # refetch the course as the assumed student + course = get_course_with_access(student, 'load', course_key, check_if_enrolled=True) + prep_course_for_grading(course, request) + + # NOTE: To make sure impersonation by instructor works, use + # student instead of request.user in the rest of the function. + + course_grade = CourseGradeFactory().create(student, course) + courseware_summary = course_grade.chapter_grades.values() + grade_summary = course_grade.summary + + studio_url = get_studio_url(course, 'settings/grading') + + # checking certificate generation configuration + enrollment_mode, is_active = CourseEnrollment.enrollment_mode_for_user(student, course_key) + + context = { + 'course': course, + 'courseware_summary': courseware_summary, + 'studio_url': studio_url, + 'grade_summary': grade_summary, + 'staff_access': staff_access, + 'masquerade': masquerade, + 'supports_preview_menu': True, + 'student': student, + 'passed': is_course_passed(course, grade_summary), + 'credit_course_requirements': _credit_course_requirements(course_key, student), + 'certificate_data': _get_cert_data(student, course, course_key, is_active, enrollment_mode), + } + + with outer_atomic(): + response = render_to_response('courseware/progress.html', context) + + return response + + +def _get_cert_data(student, course, course_key, is_active, enrollment_mode): + """Returns students course certificate related data. + + Arguments: + student (User): Student for whom certificate to retrieve. + course (Course): Course object for which certificate data to retrieve. + course_key (CourseKey): Course identifier for course. + is_active (Bool): Boolean value to check if course is active. + enrollment_mode (String): Course mode in which student is enrolled. + + Returns: + returns dict if course certificate is available else None. + """ + + if enrollment_mode == CourseMode.AUDIT: + return CertData( + CertificateStatuses.audit_passing, + _('Your enrollment: Audit track'), + _('You are enrolled in the audit track for this course. The audit track does not include a certificate.'), + download_url=None, + cert_web_view_url=None + ) + + show_generate_cert_btn = ( + is_active and CourseMode.is_eligible_for_certificate(enrollment_mode) + and certs_api.cert_generation_enabled(course_key) + ) + + if not show_generate_cert_btn: + return None + + if certs_api.is_certificate_invalid(student, course_key): + return CertData( + CertificateStatuses.invalidated, + _('Your certificate has been invalidated'), + _('Please contact your course team if you have any questions.'), + download_url=None, + cert_web_view_url=None + ) + + cert_downloadable_status = certs_api.certificate_downloadable_status(student, course_key) + + if cert_downloadable_status['is_downloadable']: + cert_status = CertificateStatuses.downloadable + title = _('Your certificate is available') + msg = _('You can keep working for a higher grade, or request your certificate now.') + if certs_api.has_html_certificates_enabled(course_key, course): + if certs_api.get_active_web_certificate(course) is not None: + cert_web_view_url = certs_api.get_certificate_url( + course_id=course_key, uuid=cert_downloadable_status['uuid'] + ) + return CertData(cert_status, title, msg, download_url=None, cert_web_view_url=cert_web_view_url) + else: + return CertData( + CertificateStatuses.generating, + _("We're working on it..."), + _( + "We're creating your certificate. You can keep working in your courses and a link " + "to it will appear here and on your Dashboard when it is ready." + ), + download_url=None, + cert_web_view_url=None + ) + + return CertData( + cert_status, title, msg, download_url=cert_downloadable_status['download_url'], cert_web_view_url=None + ) + + if cert_downloadable_status['is_generating']: + return CertData( + CertificateStatuses.generating, + _("We're working on it..."), + _( + "We're creating your certificate. You can keep working in your courses and a link to " + "it will appear here and on your Dashboard when it is ready." + ), + download_url=None, + cert_web_view_url=None + ) + + # If the learner is in verified modes and the student did not have + # their ID verified, we need to show message to ask learner to verify their ID first + missing_required_verification = enrollment_mode in CourseMode.VERIFIED_MODES and \ + not SoftwareSecurePhotoVerification.user_is_verified(student) + + if missing_required_verification or cert_downloadable_status['is_unverified']: + platform_name = configuration_helpers.get_value('PLATFORM_NAME', settings.PLATFORM_NAME) + return CertData( + CertificateStatuses.unverified, + _('Certificate unavailable'), + _( + 'You have not received a certificate because you do not have a current {platform_name} ' + 'verified identity.' + ).format(platform_name=platform_name), + download_url=None, + cert_web_view_url=None + ) + + return CertData( + CertificateStatuses.requesting, + _('Congratulations, you qualified for a certificate!'), + _('You can keep working for a higher grade, or request your certificate now.'), + download_url=None, + cert_web_view_url=None + ) + + +def _credit_course_requirements(course_key, student): + """Return information about which credit requirements a user has satisfied. + + Arguments: + course_key (CourseKey): Identifier for the course. + student (User): Currently logged in user. + + Returns: dict if the credit eligibility enabled and it is a credit course + and the user is enrolled in either verified or credit mode, and None otherwise. + + """ + # If credit eligibility is not enabled or this is not a credit course, + # short-circuit and return `None`. This indicates that credit requirements + # should NOT be displayed on the progress page. + if not (settings.FEATURES.get("ENABLE_CREDIT_ELIGIBILITY", False) and is_credit_course(course_key)): + return None + + # This indicates that credit requirements should NOT be displayed on the progress page. + enrollment = CourseEnrollment.get_enrollment(student, course_key) + if enrollment and enrollment.mode not in REQUIREMENTS_DISPLAY_MODES: + return None + + # Credit requirement statuses for which user does not remain eligible to get credit. + non_eligible_statuses = ['failed', 'declined'] + + # Retrieve the status of the user for each eligibility requirement in the course. + # For each requirement, the user's status is either "satisfied", "failed", or None. + # In this context, `None` means that we don't know the user's status, either because + # the user hasn't done something (for example, submitting photos for verification) + # or we're waiting on more information (for example, a response from the photo + # verification service). + requirement_statuses = get_credit_requirement_status(course_key, student.username) + + # If the user has been marked as "eligible", then they are *always* eligible + # unless someone manually intervenes. This could lead to some strange behavior + # if the requirements change post-launch. For example, if the user was marked as eligible + # for credit, then a new requirement was added, the user will see that they're eligible + # AND that one of the requirements is still pending. + # We're assuming here that (a) we can mitigate this by properly training course teams, + # and (b) it's a better user experience to allow students who were at one time + # marked as eligible to continue to be eligible. + # If we need to, we can always manually move students back to ineligible by + # deleting CreditEligibility records in the database. + if is_user_eligible_for_credit(student.username, course_key): + eligibility_status = "eligible" + + # If the user has *failed* any requirements (for example, if a photo verification is denied), + # then the user is NOT eligible for credit. + elif any(requirement['status'] in non_eligible_statuses for requirement in requirement_statuses): + eligibility_status = "not_eligible" + + # Otherwise, the user may be eligible for credit, but the user has not + # yet completed all the requirements. + else: + eligibility_status = "partial_eligible" + + return { + 'eligibility_status': eligibility_status, + 'requirements': requirement_statuses, + } + + +@login_required +@ensure_valid_course_key +def submission_history(request, course_id, student_username, location): + """Render an HTML fragment (meant for inclusion elsewhere) that renders a + history of all state changes made by this user for this problem location. + Right now this only works for problems because that's all + StudentModuleHistory records. + """ + + course_key = CourseKey.from_string(course_id) + + try: + usage_key = course_key.make_usage_key_from_deprecated_string(location) + except (InvalidKeyError, AssertionError): + return HttpResponse(escape(_(u'Invalid location.'))) + + course = get_course_overview_with_access(request.user, 'load', course_key) + staff_access = bool(has_access(request.user, 'staff', course)) + + # Permission Denied if they don't have staff access and are trying to see + # somebody else's submission history. + if (student_username != request.user.username) and (not staff_access): + raise PermissionDenied + + user_state_client = DjangoXBlockUserStateClient() + try: + history_entries = list(user_state_client.get_history(student_username, usage_key)) + except DjangoXBlockUserStateClient.DoesNotExist: + return HttpResponse(escape(_(u'User {username} has never accessed problem {location}').format( + username=student_username, + location=location + ))) + + # This is ugly, but until we have a proper submissions API that we can use to provide + # the scores instead, it will have to do. + csm = StudentModule.objects.filter( + module_state_key=usage_key, + student__username=student_username, + course_id=course_key) + + scores = BaseStudentModuleHistory.get_history(csm) + + if len(scores) != len(history_entries): + log.warning( + "Mismatch when fetching scores for student " + "history for course %s, user %s, xblock %s. " + "%d scores were found, and %d history entries were found. " + "Matching scores to history entries by date for display.", + course_id, + student_username, + location, + len(scores), + len(history_entries), + ) + scores_by_date = { + score.created: score + for score in scores + } + scores = [ + scores_by_date[history.updated] + for history in history_entries + ] + + context = { + 'history_entries': history_entries, + 'scores': scores, + 'username': student_username, + 'location': location, + 'course_id': course_key.to_deprecated_string() + } + + return render_to_response('courseware/submission_history.html', context) + + +def get_static_tab_fragment(request, course, tab): + """ + Returns the fragment for the given static tab + """ + loc = course.id.make_usage_key( + tab.type, + tab.url_slug, + ) + field_data_cache = FieldDataCache.cache_for_descriptor_descendents( + course.id, request.user, modulestore().get_item(loc), depth=0 + ) + tab_module = get_module( + request.user, request, loc, field_data_cache, static_asset_path=course.static_asset_path, course=course + ) + + logging.debug('course_module = %s', tab_module) + + fragment = Fragment() + if tab_module is not None: + try: + fragment = tab_module.render(STUDENT_VIEW, {}) + except Exception: # pylint: disable=broad-except + fragment.content = render_to_string('courseware/error-message.html', None) + log.exception( + u"Error rendering course=%s, tab=%s", course, tab['url_slug'] + ) + + return fragment + + +@require_GET +@ensure_valid_course_key +def get_course_lti_endpoints(request, course_id): + """ + View that, given a course_id, returns the a JSON object that enumerates all of the LTI endpoints for that course. + + The LTI 2.0 result service spec at + http://www.imsglobal.org/lti/ltiv2p0/uml/purl.imsglobal.org/vocab/lis/v2/outcomes/Result/service.html + says "This specification document does not prescribe a method for discovering the endpoint URLs." This view + function implements one way of discovering these endpoints, returning a JSON array when accessed. + + Arguments: + request (django request object): the HTTP request object that triggered this view function + course_id (unicode): id associated with the course + + Returns: + (django response object): HTTP response. 404 if course is not found, otherwise 200 with JSON body. + """ + + course_key = CourseKey.from_string(course_id) + + try: + course = get_course(course_key, depth=2) + except ValueError: + return HttpResponse(status=404) + + anonymous_user = AnonymousUser() + anonymous_user.known = False # make these "noauth" requests like module_render.handle_xblock_callback_noauth + lti_descriptors = modulestore().get_items(course.id, qualifiers={'category': 'lti'}) + + lti_noauth_modules = [ + get_module_for_descriptor( + anonymous_user, + request, + descriptor, + FieldDataCache.cache_for_descriptor_descendents( + course_key, + anonymous_user, + descriptor + ), + course_key, + course=course + ) + for descriptor in lti_descriptors + ] + + endpoints = [ + { + 'display_name': module.display_name, + 'lti_2_0_result_service_json_endpoint': module.get_outcome_service_url( + service_name='lti_2_0_result_rest_handler') + "/user/{anon_user_id}", + 'lti_1_1_result_service_xml_endpoint': module.get_outcome_service_url( + service_name='grade_handler'), + } + for module in lti_noauth_modules + ] + + return HttpResponse(json.dumps(endpoints), content_type='application/json') + + +@login_required +def course_survey(request, course_id): + """ + URL endpoint to present a survey that is associated with a course_id + Note that the actual implementation of course survey is handled in the + views.py file in the Survey Djangoapp + """ + + course_key = CourseKey.from_string(course_id) + course = get_course_with_access(request.user, 'load', course_key) + + redirect_url = reverse('info', args=[course_id]) + + # if there is no Survey associated with this course, + # then redirect to the course instead + if not course.course_survey_name: + return redirect(redirect_url) + + return survey.views.view_student_survey( + request.user, + course.course_survey_name, + course=course, + redirect_url=redirect_url, + is_required=course.course_survey_required, + ) + + +def is_course_passed(course, grade_summary=None, student=None, request=None): + """ + check user's course passing status. return True if passed + + Arguments: + course : course object + grade_summary (dict) : contains student grade details. + student : user object + request (HttpRequest) + + Returns: + returns bool value + """ + nonzero_cutoffs = [cutoff for cutoff in course.grade_cutoffs.values() if cutoff > 0] + success_cutoff = min(nonzero_cutoffs) if nonzero_cutoffs else None + + if grade_summary is None: + grade_summary = CourseGradeFactory().create(student, course).summary + + return success_cutoff and grade_summary['percent'] >= success_cutoff + + +# Grades can potentially be written - if so, let grading manage the transaction. +@transaction.non_atomic_requests +@require_POST +def generate_user_cert(request, course_id): + """Start generating a new certificate for the user. + + Certificate generation is allowed if: + * The user has passed the course, and + * The user does not already have a pending/completed certificate. + + Note that if an error occurs during certificate generation + (for example, if the queue is down), then we simply mark the + certificate generation task status as "error" and re-run + the task with a management command. To students, the certificate + will appear to be "generating" until it is re-run. + + Args: + request (HttpRequest): The POST request to this view. + course_id (unicode): The identifier for the course. + + Returns: + HttpResponse: 200 on success, 400 if a new certificate cannot be generated. + + """ + + if not request.user.is_authenticated(): + log.info(u"Anon user trying to generate certificate for %s", course_id) + return HttpResponseBadRequest( + _('You must be signed in to {platform_name} to create a certificate.').format( + platform_name=configuration_helpers.get_value('PLATFORM_NAME', settings.PLATFORM_NAME) + ) + ) + + student = request.user + course_key = CourseKey.from_string(course_id) + + course = modulestore().get_course(course_key, depth=2) + if not course: + return HttpResponseBadRequest(_("Course is not valid")) + + if not is_course_passed(course, None, student, request): + return HttpResponseBadRequest(_("Your certificate will be available when you pass the course.")) + + certificate_status = certs_api.certificate_downloadable_status(student, course.id) + + if certificate_status["is_downloadable"]: + return HttpResponseBadRequest(_("Certificate has already been created.")) + elif certificate_status["is_generating"]: + return HttpResponseBadRequest(_("Certificate is being created.")) + else: + # If the certificate is not already in-process or completed, + # then create a new certificate generation task. + # If the certificate cannot be added to the queue, this will + # mark the certificate with "error" status, so it can be re-run + # with a management command. From the user's perspective, + # it will appear that the certificate task was submitted successfully. + certs_api.generate_user_certificates(student, course.id, course=course, generation_mode='self') + _track_successful_certificate_generation(student.id, course.id) + return HttpResponse() + + +def _track_successful_certificate_generation(user_id, course_id): # pylint: disable=invalid-name + """ + Track a successful certificate generation event. + + Arguments: + user_id (str): The ID of the user generting the certificate. + course_id (CourseKey): Identifier for the course. + Returns: + None + + """ + if settings.LMS_SEGMENT_KEY: + event_name = 'edx.bi.user.certificate.generate' + tracking_context = tracker.get_tracker().resolve_context() + + analytics.track( + user_id, + event_name, + { + 'category': 'certificates', + 'label': unicode(course_id) + }, + context={ + 'ip': tracking_context.get('ip'), + 'Google Analytics': { + 'clientId': tracking_context.get('client_id') + } + } + ) + + +@require_http_methods(["GET", "POST"]) +@ensure_valid_usage_key +def render_xblock(request, usage_key_string, check_if_enrolled=True): + """ + Returns an HttpResponse with HTML content for the xBlock with the given usage_key. + The returned HTML is a chromeless rendering of the xBlock (excluding content of the containing courseware). + """ + usage_key = UsageKey.from_string(usage_key_string) + + usage_key = usage_key.replace(course_key=modulestore().fill_in_run(usage_key.course_key)) + course_key = usage_key.course_key + + requested_view = request.GET.get('view', 'student_view') + if requested_view != 'student_view': + return HttpResponseBadRequest("Rendering of the xblock view '{}' is not supported.".format(requested_view)) + + with modulestore().bulk_operations(course_key): + # verify the user has access to the course, including enrollment check + try: + course = get_course_with_access(request.user, 'load', course_key, check_if_enrolled=check_if_enrolled) + except CourseAccessRedirect: + raise Http404("Course not found.") + + # get the block, which verifies whether the user has access to the block. + block, _ = get_module_by_usage_id( + request, unicode(course_key), unicode(usage_key), disable_staff_debug_info=True, course=course + ) + + student_view_context = request.GET.dict() + student_view_context['show_bookmark_button'] = False + + context = { + 'fragment': block.render('student_view', context=student_view_context), + 'course': course, + 'disable_accordion': True, + 'allow_iframing': True, + 'disable_header': True, + 'disable_footer': True, + 'disable_window_wrap': True, + 'staff_access': bool(has_access(request.user, 'staff', course)), + 'xqa_server': settings.FEATURES.get('XQA_SERVER', 'http://your_xqa_server.com'), + } + return render_to_response('courseware/courseware-chromeless.html', context) + + +# Translators: "percent_sign" is the symbol "%". "platform_name" is a +# string identifying the name of this installation, such as "edX". +FINANCIAL_ASSISTANCE_HEADER = _( + '{platform_name} now offers financial assistance for learners who want to earn Verified Certificates but' + ' who may not be able to pay the Verified Certificate fee. Eligible learners may receive up to 90{percent_sign} off' + ' the Verified Certificate fee for a course.\nTo apply for financial assistance, enroll in the' + ' audit track for a course that offers Verified Certificates, and then complete this application.' + ' Note that you must complete a separate application for each course you take.\n We plan to use this' + ' information to evaluate your application for financial assistance and to further develop our' + ' financial assistance program.' +).format( + percent_sign="%", + platform_name=configuration_helpers.get_value('PLATFORM_NAME', settings.PLATFORM_NAME) +).split('\n') + + +FA_INCOME_LABEL = _('Annual Household Income') +FA_REASON_FOR_APPLYING_LABEL = _( + 'Tell us about your current financial situation. Why do you need assistance?' +) +FA_GOALS_LABEL = _( + 'Tell us about your learning or professional goals. How will a Verified Certificate in' + ' this course help you achieve these goals?' +) +FA_EFFORT_LABEL = _( + 'Tell us about your plans for this course. What steps will you take to help you complete' + ' the course work and receive a certificate?' +) +FA_SHORT_ANSWER_INSTRUCTIONS = _('Use between 250 and 500 words or so in your response.') + + +@login_required +def financial_assistance(_request): + """Render the initial financial assistance page.""" + return render_to_response('financial-assistance/financial-assistance.html', { + 'header_text': FINANCIAL_ASSISTANCE_HEADER + }) + + +@login_required +@require_POST +def financial_assistance_request(request): + """Submit a request for financial assistance to Zendesk.""" + try: + data = json.loads(request.body) + # Simple sanity check that the session belongs to the user + # submitting an FA request + username = data['username'] + if request.user.username != username: + return HttpResponseForbidden() + + course_id = data['course'] + course = modulestore().get_course(CourseKey.from_string(course_id)) + legal_name = data['name'] + email = data['email'] + country = data['country'] + income = data['income'] + reason_for_applying = data['reason_for_applying'] + goals = data['goals'] + effort = data['effort'] + marketing_permission = data['mktg-permission'] + ip_address = get_ip(request) + except ValueError: + # Thrown if JSON parsing fails + return HttpResponseBadRequest(u'Could not parse request JSON.') + except InvalidKeyError: + # Thrown if course key parsing fails + return HttpResponseBadRequest(u'Could not parse request course key.') + except KeyError as err: + # Thrown if fields are missing + return HttpResponseBadRequest(u'The field {} is required.'.format(err.message)) + + zendesk_submitted = _record_feedback_in_zendesk( + legal_name, + email, + u'Financial assistance request for learner {username} in course {course_name}'.format( + username=username, + course_name=course.display_name + ), + u'Financial Assistance Request', + {'course_id': course_id}, + # Send the application as additional info on the ticket so + # that it is not shown when support replies. This uses + # OrderedDict so that information is presented in the right + # order. + OrderedDict(( + ('Username', username), + ('Full Name', legal_name), + ('Course ID', course_id), + ('Annual Household Income', income), + ('Country', country), + ('Allowed for marketing purposes', 'Yes' if marketing_permission else 'No'), + (FA_REASON_FOR_APPLYING_LABEL, '\n' + reason_for_applying + '\n\n'), + (FA_GOALS_LABEL, '\n' + goals + '\n\n'), + (FA_EFFORT_LABEL, '\n' + effort + '\n\n'), + ('Client IP', ip_address), + )), + group_name='Financial Assistance', + require_update=True + ) + + if not zendesk_submitted: + # The call to Zendesk failed. The frontend will display a + # message to the user. + return HttpResponse(status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + return HttpResponse(status=status.HTTP_204_NO_CONTENT) + + +@login_required +def financial_assistance_form(request): + """Render the financial assistance application form page.""" + user = request.user + enrolled_courses = get_financial_aid_courses(user) + incomes = ['Less than $5,000', '$5,000 - $10,000', '$10,000 - $15,000', '$15,000 - $20,000', '$20,000 - $25,000'] + annual_incomes = [ + {'name': _(income), 'value': income} for income in incomes # pylint: disable=translation-of-non-string + ] + return render_to_response('financial-assistance/apply.html', { + 'header_text': FINANCIAL_ASSISTANCE_HEADER, + 'student_faq_url': marketing_link('FAQ'), + 'dashboard_url': reverse('dashboard'), + 'account_settings_url': reverse('account_settings'), + 'platform_name': configuration_helpers.get_value('PLATFORM_NAME', settings.PLATFORM_NAME), + 'user_details': { + 'email': user.email, + 'username': user.username, + 'name': user.profile.name, + 'country': str(user.profile.country.name), + }, + 'submit_url': reverse('submit_financial_assistance_request'), + 'fields': [ + { + 'name': 'course', + 'type': 'select', + 'label': _('Course'), + 'placeholder': '', + 'defaultValue': '', + 'required': True, + 'options': enrolled_courses, + 'instructions': _( + 'Select the course for which you want to earn a verified certificate. If' + ' the course does not appear in the list, make sure that you have enrolled' + ' in the audit track for the course.' + ) + }, + { + 'name': 'income', + 'type': 'select', + 'label': FA_INCOME_LABEL, + 'placeholder': '', + 'defaultValue': '', + 'required': True, + 'options': annual_incomes, + 'instructions': _('Specify your annual household income in US Dollars.') + }, + { + 'name': 'reason_for_applying', + 'type': 'textarea', + 'label': FA_REASON_FOR_APPLYING_LABEL, + 'placeholder': '', + 'defaultValue': '', + 'required': True, + 'restrictions': { + 'min_length': settings.FINANCIAL_ASSISTANCE_MIN_LENGTH, + 'max_length': settings.FINANCIAL_ASSISTANCE_MAX_LENGTH + }, + 'instructions': FA_SHORT_ANSWER_INSTRUCTIONS + }, + { + 'name': 'goals', + 'type': 'textarea', + 'label': FA_GOALS_LABEL, + 'placeholder': '', + 'defaultValue': '', + 'required': True, + 'restrictions': { + 'min_length': settings.FINANCIAL_ASSISTANCE_MIN_LENGTH, + 'max_length': settings.FINANCIAL_ASSISTANCE_MAX_LENGTH + }, + 'instructions': FA_SHORT_ANSWER_INSTRUCTIONS + }, + { + 'name': 'effort', + 'type': 'textarea', + 'label': FA_EFFORT_LABEL, + 'placeholder': '', + 'defaultValue': '', + 'required': True, + 'restrictions': { + 'min_length': settings.FINANCIAL_ASSISTANCE_MIN_LENGTH, + 'max_length': settings.FINANCIAL_ASSISTANCE_MAX_LENGTH + }, + 'instructions': FA_SHORT_ANSWER_INSTRUCTIONS + }, + { + 'placeholder': '', + 'name': 'mktg-permission', + 'label': _( + 'I allow edX to use the information provided in this application ' + '(except for financial information) for edX marketing purposes.' + ), + 'defaultValue': '', + 'type': 'checkbox', + 'required': False, + 'instructions': '', + 'restrictions': {} + } + ], + }) + + +def get_financial_aid_courses(user): + """ Retrieve the courses eligible for financial assistance. """ + financial_aid_courses = [] + for enrollment in CourseEnrollment.enrollments_for_user(user).order_by('-created'): + + if enrollment.mode != CourseMode.VERIFIED and \ + enrollment.course_overview.eligible_for_financial_aid and \ + CourseMode.objects.filter( + Q(_expiration_datetime__isnull=True) | Q(_expiration_datetime__gt=datetime.now(UTC())), + course_id=enrollment.course_id, + mode_slug=CourseMode.VERIFIED).exists(): + + financial_aid_courses.append( + { + 'name': enrollment.course_overview.display_name, + 'value': unicode(enrollment.course_id) + } + ) + + return financial_aid_courses + + +def check_access_to_course(request, course): + """ + Raises Redirect exceptions if the user does not have course access. + """ + # Redirect to the dashboard if not all prerequisites have been met + if not has_access(request.user, 'view_courseware_with_prerequisites', course): + log.info( + u'User %d tried to view course %s ' + u'without fulfilling prerequisites', + request.user.id, unicode(course.id)) + raise CourseAccessRedirect(reverse('dashboard')) + + # Redirect if the user must answer a survey before entering the course. + if must_answer_survey(course, request.user): + raise CourseAccessRedirect(reverse('course_survey', args=[unicode(course.id)])) diff --git a/modified/lms.templates.courseware.course_about.html b/modified/lms.templates.courseware.course_about.html new file mode 100644 index 0000000..8d6b0ae --- /dev/null +++ b/modified/lms.templates.courseware.course_about.html @@ -0,0 +1,348 @@ +<%namespace name='static' file='../static_content.html'/> +<%! +from django.utils.translation import ugettext as _ +from django.core.urlresolvers import reverse +from courseware.courses import get_course_about_section +from django.conf import settings +from edxmako.shortcuts import marketing_link +from openedx.core.lib.courses import course_image_url +%> + +<%inherit file="../main.html" /> +<%block name="headextra"> + ## OG (Open Graph) title and description added below to give social media info to display + ## (https://developers.facebook.com/docs/opengraph/howtos/maximizing-distribution-media-content#tags) + + + + +<%block name="js_extra"> + ## CourseTalk widget js script + % if show_coursetalk_widget: + + % endif + + + + + +<%block name="pagetitle">${course.display_name_with_default_escaped} + +
+
+
+
+
+
+

+ ${course.display_name_with_default_escaped} + +

+
+ +
+ %if user.is_authenticated() and registered: + %if show_courseware_link: + + %endif + + ${_("You are enrolled in this course")} + + %if show_courseware_link: + ${_("View Course")} + + %endif + + %elif in_cart: + + ${_('This course is in your cart.').format(cart_link=cart_link)} + + % elif is_course_full: + + ${_("Course is full")} + + % elif invitation_only and not can_enroll: + ${_("Enrollment in this course is by invitation only")} + ## Shib courses need the enrollment button to be displayed even when can_enroll is False, + ## because AnonymousUsers cause can_enroll for shib courses to be False, but we need them to be able to click + ## so that they can register and become a real user that can enroll. + % elif not is_shib_course and not can_enroll: + ${_("Enrollment is Closed")} + %elif can_add_course_to_cart: + <% + if user.is_authenticated(): + reg_href = "#" + reg_element_id = "add_to_cart_post" + else: + reg_href = reg_then_add_to_cart_link + reg_element_id = "reg_then_add_to_cart" + %> + <% if ecommerce_checkout: + reg_href = ecommerce_checkout_link + reg_element_id = "" + %> + + ${_("Add {course_name} to Cart ({price} USD)")\ + .format(course_name=course.display_number_with_default, price=course_price)} + +
+ %else: + <% + if ecommerce_checkout: + reg_href = ecommerce_checkout_link + else: + reg_href="#" + if professional_mode: + href_class = "add-to-cart" + else: + href_class = "register" + %> + + ${_("Enroll in {course_name}").format(course_name=course.display_number_with_default) | h} + +
+ %endif +
+ +
+ % if get_course_about_section(request, course, "video"): + +
+ +
+
+
+ %else: +
+
+ +
+
+ % endif +
+
+
+ +
+
+ % if staff_access and studio_url is not None: + + % endif + +
+ ${get_course_about_section(request, course, "overview")} +
+
+ +
+
+ + <%include file="course_about_sidebar_header.html" /> + +
    +
  1. ${_("Course Number")}

    ${course.display_number_with_default | h}
  2. + % if not course.start_date_is_still_default: + <% + course_start_date = course.start + %> +
  3. + +

    ${_("Classes Start")}

    + % if isinstance(course_start_date, str): + ${course_start_date} + % else: + <% + course_date_string = course_start_date.strftime('%Y-%m-%dT%H:%M:%S%z') + %> + + % endif +
  4. + % endif + ## We plan to ditch end_date (which is not stored in course metadata), + ## but for backwards compatibility, show about/end_date blob if it exists. + % if get_course_about_section(request, course, "end_date") or course.end: + <% + course_end_date = course.end + %> + +
  5. + +

    ${_("Classes End")}

    + % if isinstance(course_end_date, str): + ${course_end_date} + % else: + <% + course_date_string = course_end_date.strftime('%Y-%m-%dT%H:%M:%S%z') + %> + + % endif +
  6. + % endif + + % if get_course_about_section(request, course, "effort"): +
  7. ${_("Estimated Effort")}

    ${get_course_about_section(request, course, "effort")}
  8. + % endif + + ##
  9. ${_('Course Length')}

    ${_('{number} weeks').format(number=15)}
  10. + + %if course_price and (can_add_course_to_cart or is_cosmetic_price_enabled): +
  11. + +

    ${_("Price")}

    + ${course_price} +
  12. + %endif + + % if pre_requisite_courses: + <% prc_target = reverse('about_course', args=[unicode(pre_requisite_courses[0]['key'])]) %> +
  13. + +

    ${_("Prerequisites")}

    + ## Multiple pre-requisite courses are not supported on frontend that's why we are pulling first element + ${pre_requisite_courses[0]['display']} +

    + ${_("You must successfully complete {link_start}{prc_display}{link_end} before you begin this course.").format( + link_start=''.format(prc_target), + link_end='', + prc_display=pre_requisite_courses[0]['display'], + )} +

    +
  14. + % endif + % if get_course_about_section(request, course, "prerequisites"): +
  15. ${_("Requirements")}

    ${get_course_about_section(request, course, "prerequisites")}
  16. + % endif +
+
+ + ## CourseTalk widget + % if show_coursetalk_widget: +
+
+
+ % endif + + ## For now, ocw links are the only thing that goes in additional resources + % if get_course_about_section(request, course, "ocw_links"): +
+
+

${_("Additional Resources")}

+
+ +
+ ## "MITOpenCourseware" should *not* be translated +

MITOpenCourseware

+ ${get_course_about_section(request, course, "ocw_links")} +
+
+ %endif + +
+ +
+ + +## Need to put this hidden form on the page so that the registration button works. +## Since it's no harm to display a hidden form, we display it with the most permissive conditional +## which is when the student is not registered. +%if active_reg_button or is_shib_course: +
+
+
+ ${_("Enroll")} + + +
+
+ +
+
+
+%endif + +<%include file="../video_modal.html" /> + +<%static:require_module_async module_name="js/dateutil_factory" class_name="DateUtilFactory"> + DateUtilFactory.transform(iterationKey=".localized_datetime"); + From 99b441cdb1acd394a97bb16b86074f37c2d308b8 Mon Sep 17 00:00:00 2001 From: Boris Zimka Date: Fri, 29 Sep 2017 15:18:49 +0000 Subject: [PATCH 22/35] Added student shift enrollment at course enrollment --- __init__.py | 15 ++++++++++--- modified/common.djangoapps.student.views.py | 6 ++++++ .../lms.djangoapps.courseware.views.views.py | 3 +++ ...lms.templates.courseware.course_about.html | 21 +++++++++++++++++++ 4 files changed, 42 insertions(+), 3 deletions(-) diff --git a/__init__.py b/__init__.py index 84e3a26..f606675 100644 --- a/__init__.py +++ b/__init__.py @@ -1,9 +1,11 @@ from django.core.urlresolvers import reverse from django.utils.translation import ugettext as _ -from .models import CourseShiftSettings -from .serializers import CourseShiftSettingsSerializer +from opaque_keys.edx.keys import CourseKey +from .models import CourseShiftSettings +from .serializers import CourseShiftSettingsSerializer, CourseShiftSerializer +from .manager import CourseShiftManager def _section_course_shifts(course, access): @@ -28,4 +30,11 @@ def _section_course_shifts(course, access): 'course_shifts_membership_url':url_membership, 'current_settings': serial_settings.data, } - return section_data \ No newline at end of file + return section_data + + +def get_course_active_shifts_json(course_key): + shift_manager = CourseShiftManager(course_key) + active_shifts = shift_manager.get_active_shifts() + serializer = CourseShiftSerializer(active_shifts, many=True) + return serializer.data diff --git a/modified/common.djangoapps.student.views.py b/modified/common.djangoapps.student.views.py index ccd6503..d0c6c79 100644 --- a/modified/common.djangoapps.student.views.py +++ b/modified/common.djangoapps.student.views.py @@ -132,6 +132,7 @@ from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers from openedx.core.djangoapps.theming import helpers as theming_helpers from openedx.core.djangoapps.user_api.preferences import api as preferences_api +from openedx.core.djangoapps.course_shifts.manager import CourseShiftManager log = logging.getLogger("edx.student") @@ -1145,6 +1146,7 @@ def change_enrollment(request, check_access=True): ) return HttpResponseBadRequest(_("Invalid course id")) + course_shift_name = request.POST.get("course_shift") if action == "enroll": # Make sure the course exists # We don't do this check on unenroll, or a bad course id can't be unenrolled from @@ -1185,6 +1187,10 @@ def change_enrollment(request, check_access=True): try: enroll_mode = CourseMode.auto_enroll_mode(course_id, available_modes) if enroll_mode: + if course_shift_name: + shift_manager = CourseShiftManager(course_id) + shift = shift_manager.get_shift(course_shift_name) + shift_manager.enroll_user(user, shift) CourseEnrollment.enroll(user, course_id, check_access=check_access, mode=enroll_mode) except Exception: # pylint: disable=broad-except return HttpResponseBadRequest(_("Could not enroll")) diff --git a/modified/lms.djangoapps.courseware.views.views.py b/modified/lms.djangoapps.courseware.views.views.py index 009499e..1fa2789 100644 --- a/modified/lms.djangoapps.courseware.views.views.py +++ b/modified/lms.djangoapps.courseware.views.views.py @@ -110,6 +110,7 @@ from ..entrance_exams import user_can_skip_entrance_exam from ..module_render import get_module_for_descriptor, get_module, get_module_by_usage_id +from openedx.core.djangoapps.course_shifts import get_course_active_shifts_json log = logging.getLogger("edx.courseware") @@ -713,8 +714,10 @@ def course_about(request, course_id): # Overview overview = CourseOverview.get_from_id(course.id) + active_shifts_json = get_course_active_shifts_json(course.id) context = { + 'course_shifts': active_shifts_json, 'course': course, 'course_details': course_details, 'staff_access': staff_access, diff --git a/modified/lms.templates.courseware.course_about.html b/modified/lms.templates.courseware.course_about.html index 8d6b0ae..9b492bb 100644 --- a/modified/lms.templates.courseware.course_about.html +++ b/modified/lms.templates.courseware.course_about.html @@ -28,6 +28,15 @@ event.preventDefault(); }); + %if course_shifts: + $(".course-shift-selection").change(function (event) { + var select_value = $(".course-shift-selection").val(); + var hidden_field = $("#class_enroll_form .course-shift"); + hidden_field.attr("value", select_value); + console.log(hidden_field); + console.log(select_value); + }); + %endif % if can_add_course_to_cart: add_course_complete_handler = function(jqXHR, textStatus) { if (jqXHR.status == 200) { @@ -179,6 +188,15 @@

${_("Enroll in {course_name}").format(course_name=course.display_number_with_default) | h} +

${_("Choose course start date:")}

+ % if course_shifts: + + % endif +
%endif @@ -333,6 +351,9 @@

MITOpenCourseware

${_("Enroll")} + %if course_shifts: + + %endif
From 49859b11eb95f1536bd9e64582512c2e2b779c60 Mon Sep 17 00:00:00 2001 From: Boris Zimka Date: Mon, 2 Oct 2017 10:36:36 +0000 Subject: [PATCH 23/35] Fix error messages disappear after view re-rendering --- serializers.py | 1 - static/course-shifts-detail.underscore | 7 +------ static/course_shifts.html | 5 +++++ static/course_shifts.js | 22 ++++++++++++---------- 4 files changed, 18 insertions(+), 17 deletions(-) diff --git a/serializers.py b/serializers.py index 94a5c14..714c94f 100644 --- a/serializers.py +++ b/serializers.py @@ -73,5 +73,4 @@ def error_dict(self): continue message = u";".join(unicode(x) for x in errors[key]) errors_by_key[key] = message - print(errors_by_key) return errors_by_key \ No newline at end of file diff --git a/static/course-shifts-detail.underscore b/static/course-shifts-detail.underscore index 6a56301..c6040df 100644 --- a/static/course-shifts-detail.underscore +++ b/static/course-shifts-detail.underscore @@ -1,4 +1,4 @@ -
+

<%- gettext("Shifts Editing View") %>

@@ -44,8 +44,6 @@ <%- gettext("Delete") %>
-
-

@@ -56,8 +54,5 @@ -
-
-
diff --git a/static/course_shifts.html b/static/course_shifts.html index a9f474f..0cf406c 100644 --- a/static/course_shifts.html +++ b/static/course_shifts.html @@ -53,5 +53,10 @@

${_("Settings Editor")}

data-url-list="${section_data['course_shifts_list_url']}" data-url-detail="${section_data['course_shifts_detail_url']}" data-url-membership="${section_data['course_shifts_membership_url']}"> +
+ +
+
+
diff --git a/static/course_shifts.js b/static/course_shifts.js index e340a10..2df312b 100644 --- a/static/course_shifts.js +++ b/static/course_shifts.js @@ -72,7 +72,8 @@ var rendered_shifts = edx.HtmlUtils.template($('#course-shifts-detail-tpl').text())({ shifts_list: data }); - ext.$course_shifts_view.html(rendered_shifts["text"]); + var template_place = ext.$course_shifts_view.find("#course-shifts-view-template"); + template_place.html(rendered_shifts["text"]); var select_shift = ext.$section.find("#shift-select"); select_shift.change(function () { ext.render_shift(this.value); @@ -99,11 +100,11 @@ url: ext.$course_shifts_view.data('url-detail'), data: data, success: function (data) { - ext.display_response('course-shift-edit-view', data); ext.render_shift_view(); + ext.display_response('course-shifts-view', data); }, error: function (xhr) { - return ext.fail_with_error('course-shift-edit-view', 'Error creating shift', xhr); + return ext.fail_with_error('course-shifts-view', 'Error creating shift', xhr); } }); } @@ -122,11 +123,12 @@ url: ext.$course_shifts_view.data('url-detail'), data: data, success: function (data) { - ext.display_response('course-shift-edit-view', data); ext.render_shift_view(); + ext.display_response('course-shifts-view', data); + }, error: function (xhr) { - return ext.fail_with_error('course-shift-edit-view', 'Error updating shift info', xhr); + return ext.fail_with_error('course-shifts-view', 'Error updating shift info', xhr); } }); } @@ -147,11 +149,11 @@ url: ext.$course_shifts_view.data('url-detail'), data: data, success: function (data) { - ext.display_response('course-shift-edit-view', data); ext.render_shift_view(); + ext.display_response('course-shifts-view', data); }, error: function (xhr) { - return ext.fail_with_error('course-shift-edit-view', 'Error deleting shift', xhr); + return ext.fail_with_error('course-shifts-view', 'Error deleting shift', xhr); } }) }); @@ -175,11 +177,11 @@ url: ext.$course_shifts_view.data('url-membership'), data: data, success: function (data) { - ext.display_response('course-shift-view-user', data); ext.render_shift_view(); + ext.display_response('course-shifts-view', data); }, error: function (xhr) { - return ext.fail_with_error('course-shift-view-user', 'Error adding user', xhr); + return ext.fail_with_error('course-shifts-view', 'Error adding user', xhr); } }) }); @@ -280,7 +282,7 @@ if ($.type(error_message) != 'string'){ error_message = ''; for (var key in data.error){ - error_message += key + ":" +data.error[key] +"
"; + error_message += key + ":" +data.error[key] +";"; } } message += ': ' + error_message; From d7f2a9bf9d430af634ac744c9a741e4086a011da Mon Sep 17 00:00:00 2001 From: Boris Zimka Date: Mon, 2 Oct 2017 12:00:34 +0000 Subject: [PATCH 24/35] README update, shift start_date change bug fix --- README.rst | 14 +++++++++----- models.py | 2 ++ 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/README.rst b/README.rst index e6b1797..ff6f5b2 100644 --- a/README.rst +++ b/README.rst @@ -22,11 +22,12 @@ Every course student is associated with some CourseUserGroup, and provider check Installation ------------ -1. 'course_shifts' should be added to the INSTALLED_APPS variable: +1. 'course_shifts' should be added to the INSTALLED_APPS variable, feature should be enabled: :: INSTALLED_APPS += ('openedx.core.djangoapps.course_shifts',) + FEATURES["ENABLE_COURSE_SHIFTS"] = True 2. course_shifts.provider.CourseShiftOverrideProvider should be added to the FIELD_OVERRIDE_PROVIDERS @@ -38,10 +39,13 @@ Installation Note that if feature INDIVIDUAL_DUE_DATES is also used, than IndividualStudentOverrideProvider must be added before CourseShiftOverrideProvider. -3. Add urls to the urls.py in lms/ and cms/: +3. Run course_shifts migrations :: - urlpatterns += ( - url(r'^course_shifts/', include('openedx.core.djangoapps.course_shifts.urls', app_name="course_shifts", namespace="course_shifts")), - ) + python manage.py lms migrate course_shifts --settings=YOUR_SETTINGS + + +4. Pull `this +`_ +branch from github. Branch is based on edx release 'open-release/ficus.2'. It contains all necessary changes in edx-platform. diff --git a/models.py b/models.py index cb18dae..e970454 100644 --- a/models.py +++ b/models.py @@ -82,6 +82,8 @@ def set_start_date(self, value): same_start_date_shifts = CourseShiftGroup.objects.filter(course_key=self.course_key, start_date=value) if same_start_date_shifts.first(): raise ValueError("Shift with start date {} already exists for {}".format(str(value), str(self.course_key))) + delta_days = (value - self.start_date).days + self.days_shift += delta_days self.start_date = value self.save() From 9ecab23bbe4229ff2b67d1563ad0236d6ee90e0e Mon Sep 17 00:00:00 2001 From: martynovp Date: Tue, 3 Oct 2017 10:41:34 +0300 Subject: [PATCH 25/35] Create setup.py --- setup.py | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 setup.py diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..5c578ce --- /dev/null +++ b/setup.py @@ -0,0 +1,30 @@ +import os +from setuptools import setup + +with open(os.path.join(os.path.dirname(__file__), 'README.rst')) as readme: + README = readme.read() + +# allow setup.py to be run from any path +os.chdir(os.path.normpath(os.path.join(os.path.abspath(__file__), os.pardir))) + +setup( + name='course-shifts', + version='0.1', + packages=['course_shifts'], + include_package_data=True, + description='Course shifts extension for openedx', + long_description=README, + url='https://github.com/miptliot/course_shifts', + author='Boris Zimka', + author_email='zimka@phystech.edu', + classifiers=[ + 'Environment :: Web Environment', + 'Framework :: Django', + 'Intended Audience :: Developers', + 'Operating System :: OS Independent', + 'Programming Language :: Python', + 'Programming Language :: Python :: 2.7', + 'Topic :: Internet :: WWW/HTTP', + 'Topic :: Internet :: WWW/HTTP :: Dynamic Content', + ], +) From e9403e6cc9f22fa23190fb9f97e51440c5777a88 Mon Sep 17 00:00:00 2001 From: Pavel Martynov Date: Tue, 3 Oct 2017 10:48:54 +0300 Subject: [PATCH 26/35] Move app into course_shifts --- __init__.py => course_shifts/__init__.py | 0 api.py => course_shifts/api.py | 0 manager.py => course_shifts/manager.py | 0 models.py => course_shifts/models.py | 0 provider.py => course_shifts/provider.py | 0 serializers.py => course_shifts/serializers.py | 0 {tests => course_shifts/tests}/__init__.py | 0 {tests => course_shifts/tests}/test_shifts.py | 0 urls.py => course_shifts/urls.py | 0 9 files changed, 0 insertions(+), 0 deletions(-) rename __init__.py => course_shifts/__init__.py (100%) rename api.py => course_shifts/api.py (100%) rename manager.py => course_shifts/manager.py (100%) rename models.py => course_shifts/models.py (100%) rename provider.py => course_shifts/provider.py (100%) rename serializers.py => course_shifts/serializers.py (100%) rename {tests => course_shifts/tests}/__init__.py (100%) rename {tests => course_shifts/tests}/test_shifts.py (100%) rename urls.py => course_shifts/urls.py (100%) diff --git a/__init__.py b/course_shifts/__init__.py similarity index 100% rename from __init__.py rename to course_shifts/__init__.py diff --git a/api.py b/course_shifts/api.py similarity index 100% rename from api.py rename to course_shifts/api.py diff --git a/manager.py b/course_shifts/manager.py similarity index 100% rename from manager.py rename to course_shifts/manager.py diff --git a/models.py b/course_shifts/models.py similarity index 100% rename from models.py rename to course_shifts/models.py diff --git a/provider.py b/course_shifts/provider.py similarity index 100% rename from provider.py rename to course_shifts/provider.py diff --git a/serializers.py b/course_shifts/serializers.py similarity index 100% rename from serializers.py rename to course_shifts/serializers.py diff --git a/tests/__init__.py b/course_shifts/tests/__init__.py similarity index 100% rename from tests/__init__.py rename to course_shifts/tests/__init__.py diff --git a/tests/test_shifts.py b/course_shifts/tests/test_shifts.py similarity index 100% rename from tests/test_shifts.py rename to course_shifts/tests/test_shifts.py diff --git a/urls.py b/course_shifts/urls.py similarity index 100% rename from urls.py rename to course_shifts/urls.py From a6342695670c789d0b4a5d7adf24e0a4237352e8 Mon Sep 17 00:00:00 2001 From: Pavel Martynov Date: Tue, 3 Oct 2017 11:09:51 +0300 Subject: [PATCH 27/35] Code style fixes --- course_shifts/__init__.py | 10 +++---- course_shifts/api.py | 8 +++--- course_shifts/manager.py | 5 ++-- course_shifts/models.py | 46 +++++++++++++++--------------- course_shifts/provider.py | 4 +-- course_shifts/serializers.py | 5 ++-- course_shifts/tests/test_shifts.py | 44 ++++++++++++++-------------- 7 files changed, 60 insertions(+), 62 deletions(-) diff --git a/course_shifts/__init__.py b/course_shifts/__init__.py index f606675..4ad9cb5 100644 --- a/course_shifts/__init__.py +++ b/course_shifts/__init__.py @@ -1,17 +1,15 @@ from django.core.urlresolvers import reverse from django.utils.translation import ugettext as _ -from opaque_keys.edx.keys import CourseKey - +from .manager import CourseShiftManager from .models import CourseShiftSettings from .serializers import CourseShiftSettingsSerializer, CourseShiftSerializer -from .manager import CourseShiftManager -def _section_course_shifts(course, access): +def _section_course_shifts(course, access): course_key = course.id course_id = str(course_key) - url_settings = reverse('course_shifts:settings', kwargs={"course_id":course_id}) + url_settings = reverse('course_shifts:settings', kwargs={"course_id": course_id}) url_list = reverse('course_shifts:list', kwargs={"course_id": course_id}) url_detail = reverse('course_shifts:detail', kwargs={"course_id": course_id}) url_membership = reverse('course_shifts:membership', kwargs={"course_id": course_id}) @@ -27,7 +25,7 @@ def _section_course_shifts(course, access): 'course_shifts_settings_url': url_settings, 'course_shifts_list_url': url_list, 'course_shifts_detail_url': url_detail, - 'course_shifts_membership_url':url_membership, + 'course_shifts_membership_url': url_membership, 'current_settings': serial_settings.data, } return section_data diff --git a/course_shifts/api.py b/course_shifts/api.py index f8b2f12..ba00cff 100644 --- a/course_shifts/api.py +++ b/course_shifts/api.py @@ -1,11 +1,11 @@ -from rest_framework import views, permissions, response, status, generics +from django.contrib.auth.models import User from opaque_keys.edx.keys import CourseKey from openedx.core.lib.api.permissions import IsStaffOrOwner +from rest_framework import views, permissions, response, status, generics +from .manager import CourseShiftManager from .models import CourseShiftSettings, CourseShiftGroup from .serializers import CourseShiftSettingsSerializer, CourseShiftSerializer -from .manager import CourseShiftManager -from django.contrib.auth.models import User class CourseShiftSettingsView(views.APIView): @@ -24,7 +24,7 @@ def get(self, request, course_id): def post(self, request, course_id): data = dict(request.data.iteritems()) - data = dict((x,str(data[x])) for x in data) + data = dict((x, str(data[x])) for x in data) data['course_key'] = course_id serial_shift_settings = CourseShiftSettingsSerializer(data=data, partial=True) if serial_shift_settings.is_valid(): diff --git a/course_shifts/manager.py b/course_shifts/manager.py index aa795da..e96f0f3 100644 --- a/course_shifts/manager.py +++ b/course_shifts/manager.py @@ -1,8 +1,7 @@ from datetime import timedelta - from django.utils import timezone -from models import CourseShiftGroup, CourseShiftGroupMembership, CourseShiftSettings +from models import CourseShiftGroup, CourseShiftGroupMembership, CourseShiftSettings date_now = lambda: timezone.now().date() @@ -100,7 +99,7 @@ def enroll_user(self, user, shift, forced=False): return membership user_can_be_enrolled = forced - if not shift: # unenroll is possible at any time + if not shift: # unenroll is possible at any time user_can_be_enrolled = True active_shifts = [] if not user_can_be_enrolled: diff --git a/course_shifts/models.py b/course_shifts/models.py index e970454..396ab4f 100644 --- a/course_shifts/models.py +++ b/course_shifts/models.py @@ -1,18 +1,17 @@ """ This file contains the logic for course shifts. """ -from datetime import timedelta, datetime from logging import getLogger +from datetime import timedelta +from django.contrib.auth.models import User from django.core.exceptions import ValidationError from django.core.validators import MinValueValidator -from django.contrib.auth.models import User from django.db import models, IntegrityError from django.utils import timezone from opaque_keys.edx.keys import CourseKey -from xmodule.modulestore.django import modulestore - from openedx.core.djangoapps.course_groups.models import CourseUserGroup, CourseKeyField +from xmodule.modulestore.django import modulestore log = getLogger(__name__) @@ -40,7 +39,7 @@ class CourseShiftGroup(models.Model): start_date = models.DateField( default=date_now, help_text="Date when this shift starts" - ) + ) days_shift = models.IntegerField( default=0, help_text="Days to add to the block's due" @@ -104,7 +103,7 @@ def get_enrollment_limits(self, shift_settings=None): Return tuple of enrollment start and end dates """ if not shift_settings: - shift_settings = self._shift_settings + shift_settings = self._shift_settings return ( self.start_date - timedelta(days=shift_settings.enroll_before_days), @@ -228,7 +227,7 @@ def transfer_user(cls, user, course_shift_group_from, course_shift_group_to): if str(key_from) != str(key_to): raise ValueError("Course groups have different course_key's: '{}' and '{}'".format( str(key_from), str(key_to) - ) + ) ) current_course_key = key_from or key_to membership = cls.get_user_membership(user, current_course_key) @@ -238,7 +237,7 @@ def transfer_user(cls, user, course_shift_group_from, course_shift_group_to): raise ValueError("User's membership is '{}', not '{}'".format( str(membership_group), str(course_shift_group_from) - ) + ) ) if membership: membership.delete() @@ -334,25 +333,25 @@ class CourseShiftSettings(models.Model): autostart_period_days = models.PositiveIntegerField( default=28, db_column='autostart_period_days', - help_text="Number of days between new automatically generated shifts."\ - "Used only in autostart mode.", + help_text="Number of days between new automatically generated shifts." \ + "Used only in autostart mode.", null=True, validators=[MinValueValidator(0)] - ) + ) enroll_before_days = models.PositiveIntegerField( default=14, - help_text="Days before shift start when student can enroll already."\ - "E.g. if shift starts at 01/20/2020 and value is 5 then shift will be"\ - "available from 01/15/2020.", + help_text="Days before shift start when student can enroll already." \ + "E.g. if shift starts at 01/20/2020 and value is 5 then shift will be" \ + "available from 01/15/2020.", validators=[MinValueValidator(0)] ) enroll_after_days = models.PositiveIntegerField( default=7, help_text="Days after shift start when student still can enroll." \ - "E.g. if shift starts at 01/20/2020 and value is 10 then shift will be" \ - "available till 01/20/2020", + "E.g. if shift starts at 01/20/2020 and value is 10 then shift will be" \ + "available till 01/20/2020", validators=[MinValueValidator(0)] ) @@ -442,12 +441,13 @@ def update_shifts_autostart(self): course_key=self.course_key ) if created: - log.info("Shift {} automatically created, launch date is {}; start date is {}, enroll_before is {}".format( - str(group), - str(launch_date), - str(start_date), - str(self.enroll_before_days) - )) + log.info( + "Shift {} automatically created, launch date is {}; start date is {}, enroll_before is {}".format( + str(group), + str(launch_date), + str(start_date), + str(self.enroll_before_days) + )) start_date = self.get_next_autostart_date() launch_date = self._calculate_launch_date(start_date) @@ -464,4 +464,4 @@ def __unicode__(self): text += u"auto({})".format(self.autostart_period_days) else: text += u"manual" - return text \ No newline at end of file + return text diff --git a/course_shifts/provider.py b/course_shifts/provider.py index a641f87..d3418c6 100644 --- a/course_shifts/provider.py +++ b/course_shifts/provider.py @@ -1,5 +1,5 @@ -from datetime import timedelta from lms.djangoapps.courseware.field_overrides import FieldOverrideProvider + from .manager import CourseShiftManager @@ -78,7 +78,7 @@ def _get_default_scoped_field_value(block, name): # It is slower and stranger than the one with fallback safe_scope_names = ("preferences", "user_info") scope_field_data_dict = block._field_data._scope_mappings - scope_name_dict = dict((x.name,x) for x in scope_field_data_dict.keys()) + scope_name_dict = dict((x.name, x) for x in scope_field_data_dict.keys()) for scope_name in safe_scope_names: scope = scope_name_dict.get(scope_name) diff --git a/course_shifts/serializers.py b/course_shifts/serializers.py index 714c94f..f6da0af 100644 --- a/course_shifts/serializers.py +++ b/course_shifts/serializers.py @@ -1,5 +1,6 @@ -from rest_framework import serializers from openedx.core.lib.api.serializers import CourseKeyField +from rest_framework import serializers + from .models import CourseShiftSettings, CourseShiftGroup @@ -73,4 +74,4 @@ def error_dict(self): continue message = u";".join(unicode(x) for x in errors[key]) errors_by_key[key] = message - return errors_by_key \ No newline at end of file + return errors_by_key diff --git a/course_shifts/tests/test_shifts.py b/course_shifts/tests/test_shifts.py index af7b144..860228e 100644 --- a/course_shifts/tests/test_shifts.py +++ b/course_shifts/tests/test_shifts.py @@ -2,16 +2,16 @@ Tests for course shifts """ # pylint: disable=no-member -from nose.plugins.attrib import attr import datetime - -from opaque_keys.edx.locations import SlashSeparatedCourseKey -from student.tests.factories import UserFactory, RegistrationFactory, UserProfileFactory +from django.db import IntegrityError +from nose.plugins.attrib import attr +from student.tests.factories import UserFactory from xmodule.modulestore.tests.django_utils import TEST_DATA_MIXED_MODULESTORE, ModuleStoreTestCase from xmodule.modulestore.tests.factories import ToyCourseFactory -from django.db import IntegrityError -from ..models import CourseShiftGroup, CourseShiftGroupMembership, CourseUserGroup, CourseShiftSettings + from ..manager import CourseShiftManager +from ..models import CourseShiftGroup, CourseShiftGroupMembership, CourseUserGroup, CourseShiftSettings + def date_shifted(days): return (datetime.datetime.now() + datetime.timedelta(days=days)).date() @@ -78,7 +78,7 @@ def test_creates_cug(self): "Should be only {}, found:{}".format( str(test_shift_group), str(shift_groups) - )) + )) self._delete_all_shifts() @@ -123,8 +123,8 @@ def test_create_same_course_dif_date_ok(self): start_date=date_shifted(1)) groups = CourseShiftGroup.get_course_shifts(self.course_key) correct = test_shift_group2 in groups and \ - test_shift_group in groups and \ - len(groups) == 2 + test_shift_group in groups and \ + len(groups) == 2 self.assertTrue(correct, "Should be test_shift_group and test_shift_group2, found:{}".format( str(groups) @@ -212,7 +212,7 @@ def test_membership_course_user_unique(self): Tests that there can't be two membership for user in same course_key """ group2, created = CourseShiftGroup.create("test_shift_group2", self.course_key, - start_date=date_shifted(1)) + start_date=date_shifted(1)) CourseShiftGroupMembership.transfer_user(self.user, None, self.group) with self.assertRaises(ValueError) as context_manager: CourseShiftGroupMembership.objects.create(user=self.user, course_shift_group=group2) @@ -317,7 +317,7 @@ def test_get_user_membership(self): membership = CourseShiftGroupMembership.get_user_membership(self.user, self.course_key) self.assertTrue(membership.course_shift_group == self.group, "Membershift group:{}".format( str(membership.course_shift_group) - )) + )) class EnrollClsFields(object): @@ -416,7 +416,8 @@ def test_autostart_generation_one(self): self._settings_setup(period=custom_period, autostart=True) course_shifts = CourseShiftGroup.get_course_shifts(self.course_key) shifts_number = self._number_of_shifts(custom_period) - self.assertTrue(len(course_shifts) == shifts_number, "Must be {} shift, found: {}".format(shifts_number, str(course_shifts))) + self.assertTrue(len(course_shifts) == shifts_number, + "Must be {} shift, found: {}".format(shifts_number, str(course_shifts))) def test_autostart_generation_two(self): """ @@ -459,7 +460,6 @@ def test_turn_off_autostart(self): @attr(shard=2) class TestCourseShiftManager(ModuleStoreTestCase, EnrollClsFields): - def setUp(self): super(TestCourseShiftManager, self).setUp() date = datetime.datetime.now() - datetime.timedelta(days=14) @@ -522,7 +522,7 @@ def test_get_user_course_shift(self): test_a_shift_group, created = CourseShiftGroup.create("test_shift_group_t1", self.course_key) CourseShiftGroupMembership.transfer_user(user, None, test_a_shift_group) shift_group = shift_manager.get_user_shift(user) - self.assertTrue(shift_group==test_a_shift_group, "User shift group is {}, should be {}".format( + self.assertTrue(shift_group == test_a_shift_group, "User shift group is {}, should be {}".format( str(shift_group), str(test_a_shift_group) )) @@ -584,7 +584,7 @@ def test_create_shift(self): str(test_group), str(groups) )) - self.assertTrue(test_group_same==test_group, "Groups different: {} and {}".format( + self.assertTrue(test_group_same == test_group, "Groups different: {} and {}".format( str(test_group), str(test_group_same) )) @@ -592,8 +592,8 @@ def test_create_shift(self): test_group_other = shift_manager.create_shift(date_shifted(1)) groups = shift_manager.get_all_shifts() correct = test_group_same in groups \ - and test_group_other in groups \ - and len(groups) == 2 + and test_group_other in groups \ + and len(groups) == 2 self.assertTrue(correct, "Should be {} and {}, found: {}".format( str(test_group), str(test_group_other), @@ -628,7 +628,7 @@ def test_create_shift_different_date_error(self): test_group_error = shift_manager.create_shift(name=name, start_date=date_shifted(1)) test_group2 = shift_manager.create_shift() - self.assertTrue(test_group==test_group2, "Different groups: {} {}".format( + self.assertTrue(test_group == test_group2, "Different groups: {} {}".format( str(test_group), str(test_group2) )) @@ -701,7 +701,7 @@ def test_get_active_groups_future(self): "Start date is {}, must be {}".format( str(group_future.start_date), str(date_shifted(14)) - )) + )) active_groups = shift_manager.get_active_shifts() active_user_groups = shift_manager.get_active_shifts(self.user) @@ -780,7 +780,7 @@ def test_enroll_user_error_inactive(self): group = shift_manager.create_shift(date_shifted(-20)) active_groups = shift_manager.get_active_shifts(self.user) self.assertTrue( - not(group in active_groups), + not (group in active_groups), "Active groups : {}".format(str(active_groups)) ) with self.assertRaises(ValueError): @@ -797,13 +797,13 @@ def test_enroll_user_inactive_forced(self): group = shift_manager.create_shift(date_shifted(-20)) active_groups = shift_manager.get_active_shifts() self.assertTrue( - not(group in active_groups), + not (group in active_groups), "Active groups : {}".format(str(active_groups)) ) shift_manager.enroll_user(self.user, group, forced=True) user_shift = shift_manager.get_user_shift(self.user) self.assertTrue( - user_shift==group, + user_shift == group, "User shift:{}, should be {}".format( str(user_shift), str(group) From 1da79904712330c8719a94c917ed9638c0029eb7 Mon Sep 17 00:00:00 2001 From: Pavel Martynov Date: Tue, 3 Oct 2017 09:55:02 +0000 Subject: [PATCH 28/35] Migrations --- .gitignore | 1 + course_shifts/migrations/0001_initial.py | 53 ++++++++++++++++++++++++ course_shifts/migrations/__init__.py | 0 3 files changed, 54 insertions(+) create mode 100644 course_shifts/migrations/0001_initial.py create mode 100644 course_shifts/migrations/__init__.py diff --git a/.gitignore b/.gitignore index 0d20b64..ea3bf03 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ *.pyc +*.egg-info/ diff --git a/course_shifts/migrations/0001_initial.py b/course_shifts/migrations/0001_initial.py new file mode 100644 index 0000000..17d14ee --- /dev/null +++ b/course_shifts/migrations/0001_initial.py @@ -0,0 +1,53 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations, models +import course_shifts.models +from django.conf import settings +import django.core.validators +import openedx.core.djangoapps.xmodule_django.models + + +class Migration(migrations.Migration): + + dependencies = [ + ('course_groups', '0002_change_inline_default_cohort_value'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='CourseShiftGroup', + fields=[ + ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), + ('course_key', openedx.core.djangoapps.xmodule_django.models.CourseKeyField(help_text=b'Which course is this group associated with', max_length=255, db_index=True)), + ('start_date', models.DateField(default=course_shifts.models.date_now, help_text=b'Date when this shift starts')), + ('days_shift', models.IntegerField(default=0, help_text=b"Days to add to the block's due")), + ('course_user_group', models.OneToOneField(to='course_groups.CourseUserGroup')), + ], + ), + migrations.CreateModel( + name='CourseShiftGroupMembership', + fields=[ + ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), + ('course_shift_group', models.ForeignKey(to='course_shifts.CourseShiftGroup')), + ('user', models.ForeignKey(related_name='shift_membership', to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.CreateModel( + name='CourseShiftSettings', + fields=[ + ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), + ('course_key', openedx.core.djangoapps.xmodule_django.models.CourseKeyField(unique=True, max_length=255, db_index=True)), + ('is_shift_enabled', models.BooleanField(default=False, help_text=b'True value if this feature is enabled for the course run')), + ('is_autostart', models.BooleanField(default=True, help_text=b'Are groups generated automatically with period or according to the manually set plan')), + ('autostart_period_days', models.PositiveIntegerField(default=28, help_text=b'Number of days between new automatically generated shifts.Used only in autostart mode.', null=True, db_column=b'autostart_period_days', validators=[django.core.validators.MinValueValidator(0)])), + ('enroll_before_days', models.PositiveIntegerField(default=14, help_text=b'Days before shift start when student can enroll already.E.g. if shift starts at 01/20/2020 and value is 5 then shift will beavailable from 01/15/2020.', validators=[django.core.validators.MinValueValidator(0)])), + ('enroll_after_days', models.PositiveIntegerField(default=7, help_text=b'Days after shift start when student still can enroll.E.g. if shift starts at 01/20/2020 and value is 10 then shift will beavailable till 01/20/2020', validators=[django.core.validators.MinValueValidator(0)])), + ], + ), + migrations.AlterUniqueTogether( + name='courseshiftgroup', + unique_together=set([('course_key', 'start_date')]), + ), + ] diff --git a/course_shifts/migrations/__init__.py b/course_shifts/migrations/__init__.py new file mode 100644 index 0000000..e69de29 From 0f5ce6e2b69e335627325e090780be26bdf26e47 Mon Sep 17 00:00:00 2001 From: Pavel Martynov Date: Tue, 3 Oct 2017 15:13:22 +0300 Subject: [PATCH 29/35] Instructor dahboard templates --- .gitignore | 1 + course_shifts/__init__.py | 7 +- .../templates/course-shifts-detail.underscore | 58 ++++++++++++++ course_shifts/templates/course_shifts.html | 75 +++++++++++++++++++ 4 files changed, 137 insertions(+), 4 deletions(-) create mode 100644 course_shifts/templates/course-shifts-detail.underscore create mode 100644 course_shifts/templates/course_shifts.html diff --git a/.gitignore b/.gitignore index ea3bf03..c92982b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ *.pyc *.egg-info/ +.idea/ diff --git a/course_shifts/__init__.py b/course_shifts/__init__.py index 4ad9cb5..1e7cab1 100644 --- a/course_shifts/__init__.py +++ b/course_shifts/__init__.py @@ -6,15 +6,14 @@ from .serializers import CourseShiftSettingsSerializer, CourseShiftSerializer -def _section_course_shifts(course, access): - course_key = course.id - course_id = str(course_key) +def section_course_shifts(course, access): + course_id = str(course.id) url_settings = reverse('course_shifts:settings', kwargs={"course_id": course_id}) url_list = reverse('course_shifts:list', kwargs={"course_id": course_id}) url_detail = reverse('course_shifts:detail', kwargs={"course_id": course_id}) url_membership = reverse('course_shifts:membership', kwargs={"course_id": course_id}) - current_settings = CourseShiftSettings.get_course_settings(course_key) + current_settings = CourseShiftSettings.get_course_settings(course.id) if not current_settings.is_shift_enabled: return {} serial_settings = CourseShiftSettingsSerializer(current_settings) diff --git a/course_shifts/templates/course-shifts-detail.underscore b/course_shifts/templates/course-shifts-detail.underscore new file mode 100644 index 0000000..e8dbec0 --- /dev/null +++ b/course_shifts/templates/course-shifts-detail.underscore @@ -0,0 +1,58 @@ +
+

<%- gettext("Shifts Editing View") %>

+
+
+ +
+
+ + +
+ + + + + + + + + + + + + + + +
<%- gettext("Enrollement start")%>
<%- gettext("Enrollement finish")%>
<%- gettext("Users in shift")%>
+
+ + + +
+
+
+
+ +
+ +
+ +
+
diff --git a/course_shifts/templates/course_shifts.html b/course_shifts/templates/course_shifts.html new file mode 100644 index 0000000..ee7e63f --- /dev/null +++ b/course_shifts/templates/course_shifts.html @@ -0,0 +1,75 @@ +<%page args="section_data" expression_filter="h"/> +<%namespace name='static' file='../../static_content.html'/> + +<%! +from django.utils.translation import ugettext as _ +from openedx.core.djangolib.js_utils import dump_js_escaped_json, js_escaped_string +%> + + +
+
+
+

${_("Settings Editor")}

+
+ + + + + + + + + + + + + + + + +
+ True + False + + +
+ + + +
+ + + +
+ + + +
+ + +
+
+
+
+
+
+
+ +
+
+
+
+
From 2fff7571d5d68256fe514de4e90b457c612b7c63 Mon Sep 17 00:00:00 2001 From: Boris Zimka Date: Mon, 9 Oct 2017 08:41:54 +0000 Subject: [PATCH 30/35] Add course field check for feature enabling --- README.rst | 6 +++-- course_shifts/__init__.py | 17 ++++++------ course_shifts/manager.py | 24 +++++++++++++---- course_shifts/models.py | 54 +++++++++++++++++++++++++-------------- 4 files changed, 67 insertions(+), 34 deletions(-) diff --git a/README.rst b/README.rst index ff6f5b2..f808a1b 100644 --- a/README.rst +++ b/README.rst @@ -12,7 +12,7 @@ There are several differences between this app and course rerun/CCX: 2. Forum is shared between all course shifts. This can be useful when there are not so much students in each shift. -3. Students can use this function when they want, and therefore course schedule becomes more flexible. +3. Students are able to change due dates if they need to, and therefore course schedule becomes more flexible. Details ------- @@ -48,4 +48,6 @@ Note that if feature INDIVIDUAL_DUE_DATES is also used, than IndividualStudentOv 4. Pull `this `_ -branch from github. Branch is based on edx release 'open-release/ficus.2'. It contains all necessary changes in edx-platform. +branch from github. Branch is based on edx release 'open-release/ficus.2' (Watch branch `diff +`_ +). It contains all necessary changes in edx-platform. diff --git a/course_shifts/__init__.py b/course_shifts/__init__.py index 1e7cab1..68b5c96 100644 --- a/course_shifts/__init__.py +++ b/course_shifts/__init__.py @@ -1,22 +1,23 @@ from django.core.urlresolvers import reverse from django.utils.translation import ugettext as _ -from .manager import CourseShiftManager from .models import CourseShiftSettings from .serializers import CourseShiftSettingsSerializer, CourseShiftSerializer +from .manager import CourseShiftManager -def section_course_shifts(course, access): - course_id = str(course.id) - url_settings = reverse('course_shifts:settings', kwargs={"course_id": course_id}) +def _section_course_shifts(course, access): + course_key = course.id + course_id = str(course_key) + url_settings = reverse('course_shifts:settings', kwargs={"course_id":course_id}) url_list = reverse('course_shifts:list', kwargs={"course_id": course_id}) url_detail = reverse('course_shifts:detail', kwargs={"course_id": course_id}) url_membership = reverse('course_shifts:membership', kwargs={"course_id": course_id}) - current_settings = CourseShiftSettings.get_course_settings(course.id) - if not current_settings.is_shift_enabled: + shift_manager = CourseShiftManager(course_key) + if not shift_manager.is_enabled: return {} - serial_settings = CourseShiftSettingsSerializer(current_settings) + serial_settings = shift_manager.get_serial_settings() section_data = { 'section_key': 'course_shifts', 'section_display_name': _('Course Shifts'), @@ -24,7 +25,7 @@ def section_course_shifts(course, access): 'course_shifts_settings_url': url_settings, 'course_shifts_list_url': url_list, 'course_shifts_detail_url': url_detail, - 'course_shifts_membership_url': url_membership, + 'course_shifts_membership_url':url_membership, 'current_settings': serial_settings.data, } return section_data diff --git a/course_shifts/manager.py b/course_shifts/manager.py index e96f0f3..194adb5 100644 --- a/course_shifts/manager.py +++ b/course_shifts/manager.py @@ -1,7 +1,9 @@ from datetime import timedelta -from django.utils import timezone -from models import CourseShiftGroup, CourseShiftGroupMembership, CourseShiftSettings +from django.conf import settings +from django.utils import timezone +from .models import CourseShiftGroup, CourseShiftGroupMembership, CourseShiftSettings +from .serializers import CourseShiftSettingsSerializer date_now = lambda: timezone.now().date() @@ -12,6 +14,7 @@ class CourseShiftManager(object): shifts for given course: user transfer between shifts, shift creation, data about available shifts. Supposed to be used outside the app in edx """ + SHIFT_COURSE_FIELD_NAME = "enable_course_shifts" def __init__(self, course_key): self.course_key = course_key @@ -19,13 +22,21 @@ def __init__(self, course_key): @property def is_enabled(self): - return self.settings.is_shift_enabled + course = self.settings.course + if self.settings.is_shift_enabled: + return True + field = self.SHIFT_COURSE_FIELD_NAME + if hasattr(course, field) and getattr(course, field): + self.settings.is_shift_enabled = True + self.settings.save() + return True + return False def get_user_shift(self, user): """ Returns user's shift group for manager's course. """ - if not self.settings.is_shift_enabled: + if not self.is_enabled: return membership = CourseShiftGroupMembership.get_user_membership(user, self.course_key) @@ -99,7 +110,7 @@ def enroll_user(self, user, shift, forced=False): return membership user_can_be_enrolled = forced - if not shift: # unenroll is possible at any time + if not shift: # unenroll is possible at any time user_can_be_enrolled = True active_shifts = [] if not user_can_be_enrolled: @@ -135,3 +146,6 @@ def create_shift(self, start_date=None, name=None): days_shift=days_shift ) return shift + + def get_serial_settings(self): + return CourseShiftSettingsSerializer(self.settings) \ No newline at end of file diff --git a/course_shifts/models.py b/course_shifts/models.py index 396ab4f..f5163d2 100644 --- a/course_shifts/models.py +++ b/course_shifts/models.py @@ -47,6 +47,7 @@ class CourseShiftGroup(models.Model): class Meta: unique_together = ('course_key', 'start_date',) + app_label = 'course_shifts' @property def users(self): @@ -145,7 +146,11 @@ def create(cls, name, course_key, start_date=None, days_shift=None): Creates new CourseShiftGroup. If shift with (name, course_key) combination already exists returns this shift """ - course_user_group, created_group = CourseUserGroup.create(name=name, course_id=course_key) + course_user_group, created_group = CourseUserGroup.create( + name=name, + course_id=course_key, + group_type=CourseUserGroup.SHIFT + ) if not created_group: shift = CourseShiftGroup.objects.get(course_user_group=course_user_group) if shift.name != name: @@ -189,6 +194,9 @@ class CourseShiftGroupMembership(models.Model): user = models.ForeignKey(User, related_name="shift_membership") course_shift_group = models.ForeignKey(CourseShiftGroup) + class Meta: + app_label = 'course_shifts' + @property def course_key(self): return self.course_shift_group.course_key @@ -328,33 +336,37 @@ class CourseShiftSettings(models.Model): is_autostart = models.BooleanField( default=True, help_text="Are groups generated automatically with period " - "or according to the manually set plan") + "or according to the m anually set plan" + ) autostart_period_days = models.PositiveIntegerField( default=28, db_column='autostart_period_days', - help_text="Number of days between new automatically generated shifts." \ - "Used only in autostart mode.", + help_text="Number of days between new automatically generated shifts."\ + "Used only in autostart mode.", null=True, validators=[MinValueValidator(0)] ) enroll_before_days = models.PositiveIntegerField( default=14, - help_text="Days before shift start when student can enroll already." \ - "E.g. if shift starts at 01/20/2020 and value is 5 then shift will be" \ - "available from 01/15/2020.", + help_text="Days before shift start when student can enroll already."\ + "E.g. if shift starts at 01/20/2020 and value is 5 then shift will be"\ + "available from 01/15/2020.", validators=[MinValueValidator(0)] ) enroll_after_days = models.PositiveIntegerField( default=7, help_text="Days after shift start when student still can enroll." \ - "E.g. if shift starts at 01/20/2020 and value is 10 then shift will be" \ - "available till 01/20/2020", + "E.g. if shift starts at 01/20/2020 and value is 10 then shift will be" \ + "available till 01/20/2020", validators=[MinValueValidator(0)] ) + class Meta: + app_label = 'course_shifts' + def __init__(self, *args, **kwargs): super(CourseShiftSettings, self).__init__(*args, **kwargs) @@ -368,10 +380,15 @@ def last_start_date(self): return None return shifts[0].start_date + @property + def course(self): + if not hasattr(self, "_course"): + self._course = modulestore().get_course(self.course_key) + return self._course + @property def course_start_date(self): - course = modulestore().get_course(self.course_key) - return course.start.date() + return self.course.start.date() @classmethod def get_course_settings(cls, course_key): @@ -441,13 +458,12 @@ def update_shifts_autostart(self): course_key=self.course_key ) if created: - log.info( - "Shift {} automatically created, launch date is {}; start date is {}, enroll_before is {}".format( - str(group), - str(launch_date), - str(start_date), - str(self.enroll_before_days) - )) + log.info("Shift {} automatically created, launch date is {}; start date is {}, enroll_before is {}".format( + str(group), + str(launch_date), + str(start_date), + str(self.enroll_before_days) + )) start_date = self.get_next_autostart_date() launch_date = self._calculate_launch_date(start_date) @@ -464,4 +480,4 @@ def __unicode__(self): text += u"auto({})".format(self.autostart_period_days) else: text += u"manual" - return text + return text \ No newline at end of file From c01427b0ad414bd4de6c2ea1a24cce3a3638d6f0 Mon Sep 17 00:00:00 2001 From: Boris Zimka Date: Tue, 10 Oct 2017 16:26:02 +0000 Subject: [PATCH 31/35] Change from djangoapp to package --- modified/common.djangoapps.student.views.py | 2 +- modified/lms.djangoapps.courseware.views.views.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/modified/common.djangoapps.student.views.py b/modified/common.djangoapps.student.views.py index d0c6c79..ebc083c 100644 --- a/modified/common.djangoapps.student.views.py +++ b/modified/common.djangoapps.student.views.py @@ -132,7 +132,7 @@ from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers from openedx.core.djangoapps.theming import helpers as theming_helpers from openedx.core.djangoapps.user_api.preferences import api as preferences_api -from openedx.core.djangoapps.course_shifts.manager import CourseShiftManager +from course_shifts.manager import CourseShiftManager log = logging.getLogger("edx.student") diff --git a/modified/lms.djangoapps.courseware.views.views.py b/modified/lms.djangoapps.courseware.views.views.py index 1fa2789..8e42972 100644 --- a/modified/lms.djangoapps.courseware.views.views.py +++ b/modified/lms.djangoapps.courseware.views.views.py @@ -110,7 +110,7 @@ from ..entrance_exams import user_can_skip_entrance_exam from ..module_render import get_module_for_descriptor, get_module, get_module_by_usage_id -from openedx.core.djangoapps.course_shifts import get_course_active_shifts_json +from course_shifts import get_course_active_shifts_json log = logging.getLogger("edx.courseware") From 2e33fbcefc2aebb466ed6cce8841d1b46fbc59cc Mon Sep 17 00:00:00 2001 From: Boris Zimka Date: Thu, 30 Nov 2017 20:18:16 +0000 Subject: [PATCH 32/35] Api permission change, add 'get user shift' --- course_shifts/api.py | 82 +++++++++++++++++++++++++++++++++++--------- 1 file changed, 66 insertions(+), 16 deletions(-) diff --git a/course_shifts/api.py b/course_shifts/api.py index ba00cff..6e3f9fb 100644 --- a/course_shifts/api.py +++ b/course_shifts/api.py @@ -6,21 +6,32 @@ from .manager import CourseShiftManager from .models import CourseShiftSettings, CourseShiftGroup from .serializers import CourseShiftSettingsSerializer, CourseShiftSerializer +from openedx.core.lib.api.permissions import ApiKeyHeaderPermission + + +class CourseShiftsPermission(permissions.BasePermission): + def has_permission(self, request, view): + return (ApiKeyHeaderPermission().has_permission(request, view) or + (permissions.IsAuthenticated().has_permission(request, view) and IsStaffOrOwner().has_permission(request,view)) + ) class CourseShiftSettingsView(views.APIView): """ Allows instructor to edit course shift settings """ - permission_classes = (permissions.IsAuthenticated, IsStaffOrOwner) + permission_classes = CourseShiftsPermission, def get(self, request, course_id): course_key = CourseKey.from_string(course_id) shift_settings = CourseShiftSettings.get_course_settings(course_key) - serial_shift_settings = CourseShiftSettingsSerializer(shift_settings) - data = serial_shift_settings.data - data.pop('course_key') - return response.Response(data=data) + if shift_settings.is_shift_enabled: + serial_shift_settings = CourseShiftSettingsSerializer(shift_settings) + data = serial_shift_settings.data + data.pop('course_key') + return response.Response(data=data) + else: + return response.Response({}) def post(self, request, course_id): data = dict(request.data.iteritems()) @@ -48,14 +59,27 @@ class CourseShiftListView(generics.ListAPIView): Returns list of shifts for given course """ serializer_class = CourseShiftSerializer + permission_classes = CourseShiftsPermission, - def get_queryset(self): + def old_get_queryset(self): course_id = self.kwargs['course_id'] course_key = CourseKey.from_string(course_id) return CourseShiftGroup.get_course_shifts(course_key) def list(self, request, course_id): - queryset = self.get_queryset() + course_id = self.kwargs['course_id'] + course_key = CourseKey.from_string(course_id) + shift_manager = CourseShiftManager(course_key) + username = request.query_params.get('username', None) + if username: + try: + user = User.objects.get(username=username) + except User.DoesNotExist: + message = "User with username {} not found".format(username) + return response.Response(status=status.HTTP_400_BAD_REQUEST, data={"error": message}) + queryset = shift_manager.get_active_shifts(user) + else: + queryset = shift_manager.get_all_shifts() serializer = CourseShiftSerializer(queryset, many=True) data = serializer.data return response.Response(data=data) @@ -65,7 +89,7 @@ class CourseShiftDetailView(views.APIView): """ Allows instructor to watch, to create, to modify and to delete course shifts """ - permission_classes = (permissions.IsAuthenticated, IsStaffOrOwner) + permission_classes = CourseShiftsPermission, def _get_shift(self, course_id, name): course_key = CourseKey.from_string(course_id) @@ -156,18 +180,17 @@ def post(self, request, course_id): class CourseShiftUserView(views.APIView): """ - Allows instructor to add users to shifts + Allows instructor to add users to shifts and check their + current shift """ - permission_classes = (permissions.IsAuthenticated, IsStaffOrOwner) + permission_classes = CourseShiftsPermission, def post(self, request, course_id): course_key = CourseKey.from_string(course_id) - shift_name = request.data.get("shift_name") shift_manager = CourseShiftManager(course_key) - shift = shift_manager.get_shift(shift_name) - if not shift: - message = "Shift with name {} not found for {}".format(shift_name, course_key) - return response.Response(status=status.HTTP_400_BAD_REQUEST, data={"error": message}) + if not shift_manager.is_enabled: + message = "Shifts are not enabled for course {}".format(course_id) + return response.Response(status=status.HTTP_406_NOT_ACCEPTABLE, data={"error": message}) username = request.data.get("username") try: @@ -175,8 +198,35 @@ def post(self, request, course_id): except User.DoesNotExist: message = "User with username {} not found".format(username) return response.Response(status=status.HTTP_400_BAD_REQUEST, data={"error": message}) + + shift_name = request.data.get("shift_name") + shift = shift_manager.get_shift(shift_name) + if not shift: + message = "Shift with name {} not found for {}".format(shift_name, course_id) + return response.Response(status=status.HTTP_400_BAD_REQUEST, data={"error": message}) + try: shift_manager.enroll_user(user, shift, forced=True) except ValueError as e: return response.Response(status=status.HTTP_400_BAD_REQUEST, data={"error": e.message}) - return response.Response({}) + return response.Response({"message": "Success"}) + + def get(self, request, course_id): + course_key = CourseKey.from_string(course_id) + shift_manager = CourseShiftManager(course_key) + if not shift_manager.is_enabled: + message = "Shifts are not enabled for course {}".format(course_id) + return response.Response(status=status.HTTP_406_NOT_ACCEPTABLE, data={"error": message}) + + username = request.query_params.get("username") + try: + user = User.objects.get(username=username) + except User.DoesNotExist: + message = "User with username {} not found".format(username) + return response.Response(status=status.HTTP_400_BAD_REQUEST, data={"error": message}) + current_shift = shift_manager.get_user_shift(user) + if not current_shift: + return response.Response({}) + else: + data = CourseShiftSerializer(current_shift).data + return response.Response(data) From 0d1f01165f29618061516055a3f40a9976385bf9 Mon Sep 17 00:00:00 2001 From: Boris Zimka Date: Fri, 1 Dec 2017 14:07:32 +0000 Subject: [PATCH 33/35] Fix provider error for FieldData other than Lms --- course_shifts/provider.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/course_shifts/provider.py b/course_shifts/provider.py index d3418c6..122aab4 100644 --- a/course_shifts/provider.py +++ b/course_shifts/provider.py @@ -62,7 +62,10 @@ def get_default_fallback_field_value(block, name): This function returns value of block's field avoiding recursive entering into the shift provider. """ - fallback = block._field_data._authored_data._source.fallback + try: # we have LmsFieldData in block during rendering + fallback = block._field_data._authored_data._source.fallback + except AttributeError: #we have Kvs or InheritingFieldData in block + fallback = block._field_data base_value = None if fallback.has(block, name): base_value = fallback.get(block, name) From 6ac8ac8ab99639db6d6e6f803b692827d508bdef Mon Sep 17 00:00:00 2001 From: Boris Zimka Date: Mon, 4 Dec 2017 15:48:28 +0000 Subject: [PATCH 34/35] Readme, docstrings and tests update --- README.rst | 37 ++++++++++++++++++------------ course_shifts/api.py | 3 +++ course_shifts/models.py | 2 +- course_shifts/tests/test_shifts.py | 17 +++++++++++--- 4 files changed, 40 insertions(+), 19 deletions(-) diff --git a/README.rst b/README.rst index f808a1b..2c495dc 100644 --- a/README.rst +++ b/README.rst @@ -2,13 +2,13 @@ Description ----------- This is django app based on OpenEdx Ficus release `"open-release/ficus.2" `_ -that should provide the way for student to move forward all due dates for given course according to the rules defined by the course staff. +that provides the way for student to move forward all due dates for given course according to the rules defined by the course staff. Similar feature at the Coursera is called "Session Switch": when activated, course has several sessions with the same content but different deadlines and student can switch them at will. This feature can be useful when student have missed some deadlines but still wants to -finish the course and get credit. +finish the course and to get credit. There are several differences between this app and course rerun/CCX: -1. The content of the course is the same in all course shifts. Therefore it should be easier for staff to upgrade such course if necessary. It also doesn't spend additional system resources. +1. The content of the course is exactly the same in all course shifts. Therefore it should be easier for staff to upgrade such course if necessary for all students at the same time. It also doesn't spend additional system resources. 2. Forum is shared between all course shifts. This can be useful when there are not so much students in each shift. @@ -16,38 +16,45 @@ There are several differences between this app and course rerun/CCX: Details ------- +Let **C** be 'Start Date' value for some Course, **S** be 'start date' value for CourseShift in Course, and **D** be 'due date' value for Problem in Course, where **D** is later than **C**. +Then for every Course student enrolled on CourseShift Problem due date would be shifted at (**S** - **C**) value, therefore new 'due date' would be **D** + (**S** - **C**). + Feature is implemented via additional FieldOverrideProvider and CourseUserGroups, similar to the way it's done for 'INDIVIDUAL_DUE_DATES' feature. -Every course student is associated with some CourseUserGroup, and provider checks for membership and shifts due dates accordingly. +Every course student is associated with some CourseUserGroup, and provider checks for membership and shifts due dates in a way described above. + +Currently feature doesn't affect Special Exams. +Currently student can choose shift only at the enrollment, he can't change it later, but staff can do it via instructor dashboard. Installation ------------ 1. 'course_shifts' should be added to the INSTALLED_APPS variable, feature should be enabled: -:: + :: - INSTALLED_APPS += ('openedx.core.djangoapps.course_shifts',) - FEATURES["ENABLE_COURSE_SHIFTS"] = True + INSTALLED_APPS += ('course_shifts',) + FEATURES["ENABLE_COURSE_SHIFTS"] = True 2. course_shifts.provider.CourseShiftOverrideProvider should be added to the FIELD_OVERRIDE_PROVIDERS -:: + :: - FIELD_OVERRIDE_PROVIDERS += ( - 'openedx.core.djangoapps.course_shifts.provider.CourseShiftOverrideProvider', - ) + FIELD_OVERRIDE_PROVIDERS += ( + 'course_shifts.provider.CourseShiftOverrideProvider', + ) -Note that if feature INDIVIDUAL_DUE_DATES is also used, than IndividualStudentOverrideProvider must be added before CourseShiftOverrideProvider. +Note that if feature INDIVIDUAL_DUE_DATES is also used, then IndividualStudentOverrideProvider must be added before CourseShiftOverrideProvider. 3. Run course_shifts migrations -:: + :: - python manage.py lms migrate course_shifts --settings=YOUR_SETTINGS + python manage.py lms migrate course_shifts --settings=YOUR_SETTINGS 4. Pull `this `_ branch from github. Branch is based on edx release 'open-release/ficus.2' (Watch branch `diff `_ -). It contains all necessary changes in edx-platform. +). It contains all necessary changes in edx-platform: it adds new cohort type, new tab for instructor dasboard in LMS, +field in Studio Advanced Settings to enable shifts for course. diff --git a/course_shifts/api.py b/course_shifts/api.py index 6e3f9fb..80a94cf 100644 --- a/course_shifts/api.py +++ b/course_shifts/api.py @@ -10,6 +10,9 @@ class CourseShiftsPermission(permissions.BasePermission): + """ + Allows staff or api-key users to change shifts. + """ def has_permission(self, request, view): return (ApiKeyHeaderPermission().has_permission(request, view) or (permissions.IsAuthenticated().has_permission(request, view) and IsStaffOrOwner().has_permission(request,view)) diff --git a/course_shifts/models.py b/course_shifts/models.py index f5163d2..97c2a15 100644 --- a/course_shifts/models.py +++ b/course_shifts/models.py @@ -155,7 +155,7 @@ def create(cls, name, course_key, start_date=None, days_shift=None): shift = CourseShiftGroup.objects.get(course_user_group=course_user_group) if shift.name != name: raise ValueError("Shift already exists with different name: {}".format(str(shift.name))) - if shift.start_date != start_date: + if start_date and shift.start_date != start_date: raise ValueError("Shift already exists with different start_date: {}".format(str(shift.start_date))) kwargs = {"course_user_group": course_user_group} if start_date: diff --git a/course_shifts/tests/test_shifts.py b/course_shifts/tests/test_shifts.py index 860228e..1c278aa 100644 --- a/course_shifts/tests/test_shifts.py +++ b/course_shifts/tests/test_shifts.py @@ -1,5 +1,9 @@ """ -Tests for course shifts +Tests for course shifts. +Run them by + paver test_system -s lms -t /course_shifts/tests/test_shifts.py --settings=test + +'course_shifts' must be added to INSTALLED_APPS in test.py """ # pylint: disable=no-member import datetime @@ -155,8 +159,12 @@ def test_same_name_different_date_error(self): self._no_groups_check() name = "test_shift_group" test_shift_group, created = CourseShiftGroup.create(name, self.course_key) - with self.assertRaises(IntegrityError) as context_manager: + with self.assertRaises(ValueError) as context_manager: test_shift_group2, created2 = CourseShiftGroup.create(name, self.course_key, start_date=date_shifted(1)) + message_list = ["Shift already exists with different start_date"] + message_right = list(x in str(context_manager.exception) for x in message_list) + self.assertTrue(all(message_right), "Message:{}".format(str(context_manager.exception))) + self._delete_all_shifts() @@ -624,8 +632,11 @@ def test_create_shift_different_date_error(self): test_group = shift_manager.create_shift() name = test_group.name - with self.assertRaises(IntegrityError): + with self.assertRaises(ValueError) as context_manager: test_group_error = shift_manager.create_shift(name=name, start_date=date_shifted(1)) + message_list = ["Shift already exists with different start_date"] + message_right = list(x in str(context_manager.exception) for x in message_list) + self.assertTrue(all(message_right), "Message:{}".format(str(context_manager.exception))) test_group2 = shift_manager.create_shift() self.assertTrue(test_group == test_group2, "Different groups: {} {}".format( From 5795675e0ba7c579a3dfeaf21b7d5a6aca64f9fa Mon Sep 17 00:00:00 2001 From: Boris Zimka Date: Mon, 4 Dec 2017 15:58:27 +0000 Subject: [PATCH 35/35] Remove edx static from repo --- modified/common.djangoapps.student.views.py | 2752 ----------------- .../lms.djangoapps.courseware.views.views.py | 1635 ---------- ...lms.templates.courseware.course_about.html | 369 --- static/course-shifts-detail.underscore | 58 - static/course_shifts.html | 62 - static/course_shifts.js | 311 -- 6 files changed, 5187 deletions(-) delete mode 100644 modified/common.djangoapps.student.views.py delete mode 100644 modified/lms.djangoapps.courseware.views.views.py delete mode 100644 modified/lms.templates.courseware.course_about.html delete mode 100644 static/course-shifts-detail.underscore delete mode 100644 static/course_shifts.html delete mode 100644 static/course_shifts.js diff --git a/modified/common.djangoapps.student.views.py b/modified/common.djangoapps.student.views.py deleted file mode 100644 index ebc083c..0000000 --- a/modified/common.djangoapps.student.views.py +++ /dev/null @@ -1,2752 +0,0 @@ -""" -Student Views -""" -import datetime -import logging -import uuid -import json -import warnings -from collections import defaultdict -from urlparse import urljoin, urlsplit, parse_qs, urlunsplit - -from django.views.generic import TemplateView -from pytz import UTC -from requests import HTTPError -from ipware.ip import get_ip - -import edx_oauth2_provider -from django.conf import settings -from django.contrib.auth import logout, authenticate, login -from django.contrib.auth.models import User, AnonymousUser -from django.contrib.auth.decorators import login_required -from django.contrib.auth.views import password_reset_confirm -from django.contrib import messages -from django.core.context_processors import csrf -from django.core import mail -from django.core.exceptions import PermissionDenied, ObjectDoesNotExist -from django.core.urlresolvers import reverse, NoReverseMatch, reverse_lazy -from django.core.validators import validate_email, ValidationError -from django.db import IntegrityError, transaction -from django.http import HttpResponse, HttpResponseBadRequest, HttpResponseForbidden, HttpResponseServerError, Http404 -from django.shortcuts import redirect -from django.utils.encoding import force_bytes, force_text -from django.utils.translation import ungettext -from django.utils.http import base36_to_int, is_safe_url, urlsafe_base64_encode, urlencode -from django.utils.translation import ugettext as _, get_language -from django.views.decorators.csrf import csrf_exempt, ensure_csrf_cookie -from django.views.decorators.http import require_POST, require_GET -from django.db.models.signals import post_save -from django.dispatch import receiver, Signal -from django.template.response import TemplateResponse -from provider.oauth2.models import Client -from ratelimitbackend.exceptions import RateLimitException - -from social.apps.django_app import utils as social_utils -from social.backends import oauth as social_oauth -from social.exceptions import AuthException, AuthAlreadyAssociated - -from edxmako.shortcuts import render_to_response, render_to_string - -from course_modes.models import CourseMode -from shoppingcart.api import order_history -from student.models import ( - Registration, UserProfile, - PendingEmailChange, CourseEnrollment, CourseEnrollmentAttribute, unique_id_for_user, - CourseEnrollmentAllowed, UserStanding, LoginFailures, - create_comments_service_user, PasswordHistory, UserSignupSource, - DashboardConfiguration, LinkedInAddToProfileConfiguration, ManualEnrollmentAudit, ALLOWEDTOENROLL_TO_ENROLLED, - LogoutViewConfiguration, RegistrationCookieConfiguration) -from student.forms import AccountCreationForm, PasswordResetFormNoActive, get_registration_extension_form -from student.tasks import send_activation_email -from lms.djangoapps.commerce.utils import EcommerceService # pylint: disable=import-error -from lms.djangoapps.verify_student.models import SoftwareSecurePhotoVerification # pylint: disable=import-error -from bulk_email.models import Optout, BulkEmailFlag # pylint: disable=import-error -from certificates.models import ( # pylint: disable=import-error - CertificateStatuses, GeneratedCertificate, certificate_status_for_student -) -from certificates.api import ( # pylint: disable=import-error - get_certificate_url, - has_html_certificates_enabled, -) -from lms.djangoapps.grades.new.course_grade_factory import CourseGradeFactory - -from xmodule.modulestore.django import modulestore -from opaque_keys import InvalidKeyError -from opaque_keys.edx.keys import CourseKey -from opaque_keys.edx.locations import SlashSeparatedCourseKey -from opaque_keys.edx.locator import CourseLocator - -from collections import namedtuple - -from courseware.courses import get_courses, sort_by_announcement, sort_by_start_date # pylint: disable=import-error -from courseware.access import has_access - -from django_comment_common.models import Role - -from openedx.core.djangoapps.external_auth.models import ExternalAuthMap -import openedx.core.djangoapps.external_auth.views -from openedx.core.djangoapps.external_auth.login_and_register import ( - login as external_auth_login, - register as external_auth_register -) -from openedx.core.djangoapps import monitoring_utils - -import track.views - -import dogstats_wrapper as dog_stats_api - -from util.db import outer_atomic -from util.json_request import JsonResponse -from util.bad_request_rate_limiter import BadRequestRateLimiter -from util.milestones_helpers import ( - get_pre_requisite_courses_not_completed, -) - -from util.password_policy_validators import validate_password_strength -import third_party_auth -from third_party_auth import pipeline, provider -from student.helpers import ( - check_verify_status_by_course, - auth_pipeline_urls, get_next_url_for_login_page, - DISABLE_UNENROLL_CERT_STATES, - destroy_oauth_tokens -) -from student.cookies import set_logged_in_cookies, delete_logged_in_cookies, set_user_info_cookie -from student.models import anonymous_id_for_user, UserAttribute, EnrollStatusChange -from shoppingcart.models import DonationConfiguration, CourseRegistrationCode - -from openedx.core.djangoapps.embargo import api as embargo_api -from openedx.features.enterprise_support.api import get_dashboard_consent_notification - -import analytics -from eventtracking import tracker - -# Note that this lives in LMS, so this dependency should be refactored. -from notification_prefs.views import enable_notifications - -from openedx.core.djangoapps.catalog.utils import get_programs_with_type -from openedx.core.djangoapps.credit.email_utils import get_credit_provider_display_names, make_providers_strings -from openedx.core.djangoapps.lang_pref import LANGUAGE_KEY -from openedx.core.djangoapps.programs.models import ProgramsApiConfig -from openedx.core.djangoapps.programs.utils import ProgramProgressMeter -from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers -from openedx.core.djangoapps.theming import helpers as theming_helpers -from openedx.core.djangoapps.user_api.preferences import api as preferences_api -from course_shifts.manager import CourseShiftManager - - -log = logging.getLogger("edx.student") -AUDIT_LOG = logging.getLogger("audit") -ReverifyInfo = namedtuple('ReverifyInfo', 'course_id course_name course_number date status display') # pylint: disable=invalid-name -SETTING_CHANGE_INITIATED = 'edx.user.settings.change_initiated' -# Used as the name of the user attribute for tracking affiliate registrations -REGISTRATION_AFFILIATE_ID = 'registration_affiliate_id' -REGISTRATION_UTM_PARAMETERS = { - 'utm_source': 'registration_utm_source', - 'utm_medium': 'registration_utm_medium', - 'utm_campaign': 'registration_utm_campaign', - 'utm_term': 'registration_utm_term', - 'utm_content': 'registration_utm_content', -} -REGISTRATION_UTM_CREATED_AT = 'registration_utm_created_at' -# used to announce a registration -REGISTER_USER = Signal(providing_args=["user", "profile"]) - -# Disable this warning because it doesn't make sense to completely refactor tests to appease Pylint -# pylint: disable=logging-format-interpolation - - -def csrf_token(context): - """A csrf token that can be included in a form.""" - token = context.get('csrf_token', '') - if token == 'NOTPROVIDED': - return '' - return (u'
' % (token)) - - -# NOTE: This view is not linked to directly--it is called from -# branding/views.py:index(), which is cached for anonymous users. -# This means that it should always return the same thing for anon -# users. (in particular, no switching based on query params allowed) -def index(request, extra_context=None, user=AnonymousUser()): - """ - Render the edX main page. - - extra_context is used to allow immediate display of certain modal windows, eg signup, - as used by external_auth. - """ - if extra_context is None: - extra_context = {} - - programs_list = [] - courses = get_courses(user) - - if configuration_helpers.get_value( - "ENABLE_COURSE_SORTING_BY_START_DATE", - settings.FEATURES["ENABLE_COURSE_SORTING_BY_START_DATE"], - ): - courses = sort_by_start_date(courses) - else: - courses = sort_by_announcement(courses) - - context = {'courses': courses} - - context['homepage_overlay_html'] = configuration_helpers.get_value('homepage_overlay_html') - - # This appears to be an unused context parameter, at least for the master templates... - context['show_partners'] = configuration_helpers.get_value('show_partners', True) - - # TO DISPLAY A YOUTUBE WELCOME VIDEO - # 1) Change False to True - context['show_homepage_promo_video'] = configuration_helpers.get_value('show_homepage_promo_video', False) - - # 2) Add your video's YouTube ID (11 chars, eg "123456789xX"), or specify via site configuration - # Note: This value should be moved into a configuration setting and plumbed-through to the - # context via the site configuration workflow, versus living here - youtube_video_id = configuration_helpers.get_value('homepage_promo_video_youtube_id', "your-youtube-id") - context['homepage_promo_video_youtube_id'] = youtube_video_id - - # allow for theme override of the courses list - context['courses_list'] = theming_helpers.get_template_path('courses_list.html') - - # Insert additional context for use in the template - context.update(extra_context) - - # Get the active programs of the type configured for the current site from the catalog service. The programs_list - # is being added to the context but it's not being used currently in courseware/courses.html. To use this list, - # you need to create a custom theme that overrides courses.html. The modifications to courses.html to display the - # programs will be done after the support for edx-pattern-library is added. - program_types = configuration_helpers.get_value('ENABLED_PROGRAM_TYPES') - - # Do not add programs to the context if there are no program types enabled for the site. - if program_types: - programs_list = get_programs_with_type(program_types) - - context["programs_list"] = programs_list - - return render_to_response('index.html', context) - - -def process_survey_link(survey_link, user): - """ - If {UNIQUE_ID} appears in the link, replace it with a unique id for the user. - Currently, this is sha1(user.username). Otherwise, return survey_link. - """ - return survey_link.format(UNIQUE_ID=unique_id_for_user(user)) - - -def cert_info(user, course_overview, course_mode): - """ - Get the certificate info needed to render the dashboard section for the given - student and course. - - Arguments: - user (User): A user. - course_overview (CourseOverview): A course. - course_mode (str): The enrollment mode (honor, verified, audit, etc.) - - Returns: - dict: Empty dict if certificates are disabled or hidden, or a dictionary with keys: - 'status': one of 'generating', 'ready', 'notpassing', 'processing', 'restricted' - 'show_download_url': bool - 'download_url': url, only present if show_download_url is True - 'show_disabled_download_button': bool -- true if state is 'generating' - 'show_survey_button': bool - 'survey_url': url, only if show_survey_button is True - 'grade': if status is not 'processing' - 'can_unenroll': if status allows for unenrollment - """ - if not course_overview.may_certify(): - return {} - return _cert_info( - user, - course_overview, - certificate_status_for_student(user, course_overview.id), - course_mode - ) - - -def reverification_info(statuses): - """ - Returns reverification-related information for *all* of user's enrollments whose - reverification status is in statuses. - - Args: - statuses (list): a list of reverification statuses we want information for - example: ["must_reverify", "denied"] - - Returns: - dictionary of lists: dictionary with one key per status, e.g. - dict["must_reverify"] = [] - dict["must_reverify"] = [some information] - """ - reverifications = defaultdict(list) - - # Sort the data by the reverification_end_date - for status in statuses: - if reverifications[status]: - reverifications[status].sort(key=lambda x: x.date) - return reverifications - - -def get_course_enrollments(user, orgs_to_include, orgs_to_exclude): - """ - Given a user, return a filtered set of his or her course enrollments. - - Arguments: - user (User): the user in question. - orgs_to_include (list[str]): If not None, ONLY courses of these orgs will be returned. - orgs_to_exclude (list[str]): If orgs_to_include is not None, this - argument is ignored. Else, courses of this org will be excluded. - - Returns: - generator[CourseEnrollment]: a sequence of enrollments to be displayed - on the user's dashboard. - """ - for enrollment in CourseEnrollment.enrollments_for_user_with_overviews_preload(user): - - # If the course is missing or broken, log an error and skip it. - course_overview = enrollment.course_overview - if not course_overview: - log.error( - "User %s enrolled in broken or non-existent course %s", - user.username, - enrollment.course_id - ) - continue - - # Filter out anything that is not attributed to the orgs to include. - if orgs_to_include and course_overview.location.org not in orgs_to_include: - continue - - # Conversely, filter out any enrollments with courses attributed to current ORG. - elif course_overview.location.org in orgs_to_exclude: - continue - - # Else, include the enrollment. - else: - yield enrollment - - -def _cert_info(user, course_overview, cert_status, course_mode): # pylint: disable=unused-argument - """ - Implements the logic for cert_info -- split out for testing. - - Arguments: - user (User): A user. - course_overview (CourseOverview): A course. - course_mode (str): The enrollment mode (honor, verified, audit, etc.) - """ - # simplify the status for the template using this lookup table - template_state = { - CertificateStatuses.generating: 'generating', - CertificateStatuses.downloadable: 'ready', - CertificateStatuses.notpassing: 'notpassing', - CertificateStatuses.restricted: 'restricted', - CertificateStatuses.auditing: 'auditing', - CertificateStatuses.audit_passing: 'auditing', - CertificateStatuses.audit_notpassing: 'auditing', - CertificateStatuses.unverified: 'unverified', - } - - default_status = 'processing' - - default_info = { - 'status': default_status, - 'show_disabled_download_button': False, - 'show_download_url': False, - 'show_survey_button': False, - 'can_unenroll': True, - } - - if cert_status is None: - return default_info - - is_hidden_status = cert_status['status'] in ('unavailable', 'processing', 'generating', 'notpassing', 'auditing') - - if course_overview.certificates_display_behavior == 'early_no_info' and is_hidden_status: - return {} - - status = template_state.get(cert_status['status'], default_status) - - status_dict = { - 'status': status, - 'show_download_url': status == 'ready', - 'show_disabled_download_button': status == 'generating', - 'mode': cert_status.get('mode', None), - 'linked_in_url': None, - 'can_unenroll': status not in DISABLE_UNENROLL_CERT_STATES, - } - - if (status in ('generating', 'ready', 'notpassing', 'restricted', 'auditing', 'unverified') and - course_overview.end_of_course_survey_url is not None): - status_dict.update({ - 'show_survey_button': True, - 'survey_url': process_survey_link(course_overview.end_of_course_survey_url, user)}) - else: - status_dict['show_survey_button'] = False - - if status == 'ready': - # showing the certificate web view button if certificate is ready state and feature flags are enabled. - if has_html_certificates_enabled(course_overview.id, course_overview): - if course_overview.has_any_active_web_certificate: - status_dict.update({ - 'show_cert_web_view': True, - 'cert_web_view_url': get_certificate_url(course_id=course_overview.id, uuid=cert_status['uuid']) - }) - else: - # don't show download certificate button if we don't have an active certificate for course - status_dict['show_download_url'] = False - elif 'download_url' not in cert_status: - log.warning( - u"User %s has a downloadable cert for %s, but no download url", - user.username, - course_overview.id - ) - return default_info - else: - status_dict['download_url'] = cert_status['download_url'] - - # If enabled, show the LinkedIn "add to profile" button - # Clicking this button sends the user to LinkedIn where they - # can add the certificate information to their profile. - linkedin_config = LinkedInAddToProfileConfiguration.current() - - # posting certificates to LinkedIn is not currently - # supported in White Labels - if linkedin_config.enabled and not theming_helpers.is_request_in_themed_site(): - status_dict['linked_in_url'] = linkedin_config.add_to_profile_url( - course_overview.id, - course_overview.display_name, - cert_status.get('mode'), - cert_status['download_url'] - ) - - if status in {'generating', 'ready', 'notpassing', 'restricted', 'auditing', 'unverified'}: - persisted_grade = CourseGradeFactory().read(user, course=course_overview) - if persisted_grade is not None: - status_dict['grade'] = unicode(persisted_grade.percent) - elif 'grade' in cert_status: - status_dict['grade'] = cert_status['grade'] - else: - # Note: as of 11/20/2012, we know there are students in this state-- cs169.1x, - # who need to be regraded (we weren't tracking 'notpassing' at first). - # We can add a log.warning here once we think it shouldn't happen. - return default_info - - return status_dict - - -@ensure_csrf_cookie -def signin_user(request): - """Deprecated. To be replaced by :class:`student_account.views.login_and_registration_form`.""" - external_auth_response = external_auth_login(request) - if external_auth_response is not None: - return external_auth_response - # Determine the URL to redirect to following login: - redirect_to = get_next_url_for_login_page(request) - if request.user.is_authenticated(): - return redirect(redirect_to) - - third_party_auth_error = None - for msg in messages.get_messages(request): - if msg.extra_tags.split()[0] == "social-auth": - # msg may or may not be translated. Try translating [again] in case we are able to: - third_party_auth_error = _(unicode(msg)) # pylint: disable=translation-of-non-string - break - - context = { - 'login_redirect_url': redirect_to, # This gets added to the query string of the "Sign In" button in the header - # Bool injected into JS to submit form if we're inside a running third- - # party auth pipeline; distinct from the actual instance of the running - # pipeline, if any. - 'pipeline_running': 'true' if pipeline.running(request) else 'false', - 'pipeline_url': auth_pipeline_urls(pipeline.AUTH_ENTRY_LOGIN, redirect_url=redirect_to), - 'platform_name': configuration_helpers.get_value( - 'platform_name', - settings.PLATFORM_NAME - ), - 'third_party_auth_error': third_party_auth_error - } - - return render_to_response('login.html', context) - - -@ensure_csrf_cookie -def register_user(request, extra_context=None): - """Deprecated. To be replaced by :class:`student_account.views.login_and_registration_form`.""" - # Determine the URL to redirect to following login: - redirect_to = get_next_url_for_login_page(request) - if request.user.is_authenticated(): - return redirect(redirect_to) - - external_auth_response = external_auth_register(request) - if external_auth_response is not None: - return external_auth_response - - context = { - 'login_redirect_url': redirect_to, # This gets added to the query string of the "Sign In" button in the header - 'email': '', - 'name': '', - 'running_pipeline': None, - 'pipeline_urls': auth_pipeline_urls(pipeline.AUTH_ENTRY_REGISTER, redirect_url=redirect_to), - 'platform_name': configuration_helpers.get_value( - 'platform_name', - settings.PLATFORM_NAME - ), - 'selected_provider': '', - 'username': '', - } - - if extra_context is not None: - context.update(extra_context) - - if context.get("extauth_domain", '').startswith( - openedx.core.djangoapps.external_auth.views.SHIBBOLETH_DOMAIN_PREFIX - ): - return render_to_response('register-shib.html', context) - - # If third-party auth is enabled, prepopulate the form with data from the - # selected provider. - if third_party_auth.is_enabled() and pipeline.running(request): - running_pipeline = pipeline.get(request) - current_provider = provider.Registry.get_from_pipeline(running_pipeline) - if current_provider is not None: - overrides = current_provider.get_register_form_data(running_pipeline.get('kwargs')) - overrides['running_pipeline'] = running_pipeline - overrides['selected_provider'] = current_provider.name - context.update(overrides) - - return render_to_response('register.html', context) - - -def complete_course_mode_info(course_id, enrollment, modes=None): - """ - We would like to compute some more information from the given course modes - and the user's current enrollment - - Returns the given information: - - whether to show the course upsell information - - numbers of days until they can't upsell anymore - """ - if modes is None: - modes = CourseMode.modes_for_course_dict(course_id) - - mode_info = {'show_upsell': False, 'days_for_upsell': None} - # we want to know if the user is already enrolled as verified or credit and - # if verified is an option. - if CourseMode.VERIFIED in modes and enrollment.mode in CourseMode.UPSELL_TO_VERIFIED_MODES: - mode_info['show_upsell'] = True - mode_info['verified_sku'] = modes['verified'].sku - mode_info['verified_bulk_sku'] = modes['verified'].bulk_sku - # if there is an expiration date, find out how long from now it is - if modes['verified'].expiration_datetime: - today = datetime.datetime.now(UTC).date() - mode_info['days_for_upsell'] = (modes['verified'].expiration_datetime.date() - today).days - - return mode_info - - -def is_course_blocked(request, redeemed_registration_codes, course_key): - """Checking either registration is blocked or not .""" - blocked = False - for redeemed_registration in redeemed_registration_codes: - # registration codes may be generated via Bulk Purchase Scenario - # we have to check only for the invoice generated registration codes - # that their invoice is valid or not - if redeemed_registration.invoice_item: - if not redeemed_registration.invoice_item.invoice.is_valid: - blocked = True - # disabling email notifications for unpaid registration courses - Optout.objects.get_or_create(user=request.user, course_id=course_key) - log.info( - u"User %s (%s) opted out of receiving emails from course %s", - request.user.username, - request.user.email, - course_key, - ) - track.views.server_track( - request, - "change-email1-settings", - {"receive_emails": "no", "course": course_key.to_deprecated_string()}, - page='dashboard', - ) - break - - return blocked - - -def compose_and_send_activation_email(user, profile, user_registration=None): - """ - Construct all the required params and send the activation email - through celery task - - Arguments: - user: current logged-in user - profile: profile object of the current logged-in user - user_registration: registration of the current logged-in user - """ - dest_addr = user.email - if user_registration is None: - user_registration = Registration.objects.get(user=user) - context = { - 'name': profile.name, - 'key': user_registration.activation_key, - } - subject = render_to_string('emails/activation_email_subject.txt', context) - # Email subject *must not* contain newlines - subject = ''.join(subject.splitlines()) - message_for_activation = render_to_string('emails/activation_email.txt', context) - from_address = configuration_helpers.get_value( - 'email_from_address', - settings.DEFAULT_FROM_EMAIL - ) - if settings.FEATURES.get('REROUTE_ACTIVATION_EMAIL'): - dest_addr = settings.FEATURES['REROUTE_ACTIVATION_EMAIL'] - message_for_activation = ("Activation for %s (%s): %s\n" % (user, user.email, profile.name) + - '-' * 80 + '\n\n' + message_for_activation) - send_activation_email.delay(subject, message_for_activation, from_address, dest_addr) - - -@login_required -@ensure_csrf_cookie -def dashboard(request): - """ - Provides the LMS dashboard view - - TODO: This is lms specific and does not belong in common code. - - Arguments: - request: The request object. - - Returns: - The dashboard response. - - """ - user = request.user - if not UserProfile.objects.filter(user=user).exists(): - return redirect(reverse('account_settings')) - - platform_name = configuration_helpers.get_value("platform_name", settings.PLATFORM_NAME) - enable_verified_certificates = configuration_helpers.get_value( - 'ENABLE_VERIFIED_CERTIFICATES', - settings.FEATURES.get('ENABLE_VERIFIED_CERTIFICATES') - ) - display_course_modes_on_dashboard = configuration_helpers.get_value( - 'DISPLAY_COURSE_MODES_ON_DASHBOARD', - settings.FEATURES.get('DISPLAY_COURSE_MODES_ON_DASHBOARD', True) - ) - - # Let's filter out any courses in an "org" that has been declared to be - # in a configuration - org_filter_out_set = configuration_helpers.get_all_orgs() - - # Remove current site orgs from the "filter out" list, if applicable. - # We want to filter and only show enrollments for courses within - # the organizations defined in configuration for the current site. - course_org_filter = configuration_helpers.get_current_site_orgs() - if course_org_filter: - org_filter_out_set = org_filter_out_set - set(course_org_filter) - - # Build our (course, enrollment) list for the user, but ignore any courses that no - # longer exist (because the course IDs have changed). Still, we don't delete those - # enrollments, because it could have been a data push snafu. - course_enrollments = list(get_course_enrollments(user, course_org_filter, org_filter_out_set)) - - # Record how many courses there are so that we can get a better - # understanding of usage patterns on prod. - monitoring_utils.accumulate('num_courses', len(course_enrollments)) - - # sort the enrollment pairs by the enrollment date - course_enrollments.sort(key=lambda x: x.created, reverse=True) - - # Retrieve the course modes for each course - enrolled_course_ids = [enrollment.course_id for enrollment in course_enrollments] - __, unexpired_course_modes = CourseMode.all_and_unexpired_modes_for_courses(enrolled_course_ids) - course_modes_by_course = { - course_id: { - mode.slug: mode - for mode in modes - } - for course_id, modes in unexpired_course_modes.iteritems() - } - - # Check to see if the student has recently enrolled in a course. - # If so, display a notification message confirming the enrollment. - enrollment_message = _create_recent_enrollment_message( - course_enrollments, course_modes_by_course - ) - - course_optouts = Optout.objects.filter(user=user).values_list('course_id', flat=True) - - message = "" - if not user.is_active: - message = render_to_string( - 'registration/activate_account_notice.html', - {'email': user.email, 'platform_name': platform_name} - ) - - enterprise_message = get_dashboard_consent_notification(request, user, course_enrollments) - - # Global staff can see what courses errored on their dashboard - staff_access = False - errored_courses = {} - if has_access(user, 'staff', 'global'): - # Show any courses that errored on load - staff_access = True - errored_courses = modulestore().get_errored_courses() - - show_courseware_links_for = frozenset( - enrollment.course_id for enrollment in course_enrollments - if has_access(request.user, 'load', enrollment.course_overview) - and has_access(request.user, 'view_courseware_with_prerequisites', enrollment.course_overview) - ) - - # Find programs associated with course runs being displayed. This information - # is passed in the template context to allow rendering of program-related - # information on the dashboard. - meter = ProgramProgressMeter(user, enrollments=course_enrollments) - inverted_programs = meter.invert_programs() - - # Construct a dictionary of course mode information - # used to render the course list. We re-use the course modes dict - # we loaded earlier to avoid hitting the database. - course_mode_info = { - enrollment.course_id: complete_course_mode_info( - enrollment.course_id, enrollment, - modes=course_modes_by_course[enrollment.course_id] - ) - for enrollment in course_enrollments - } - - # Determine the per-course verification status - # This is a dictionary in which the keys are course locators - # and the values are one of: - # - # VERIFY_STATUS_NEED_TO_VERIFY - # VERIFY_STATUS_SUBMITTED - # VERIFY_STATUS_APPROVED - # VERIFY_STATUS_MISSED_DEADLINE - # - # Each of which correspond to a particular message to display - # next to the course on the dashboard. - # - # If a course is not included in this dictionary, - # there is no verification messaging to display. - verify_status_by_course = check_verify_status_by_course(user, course_enrollments) - cert_statuses = { - enrollment.course_id: cert_info(request.user, enrollment.course_overview, enrollment.mode) - for enrollment in course_enrollments - } - - # only show email settings for Mongo course and when bulk email is turned on - show_email_settings_for = frozenset( - enrollment.course_id for enrollment in course_enrollments if ( - BulkEmailFlag.feature_enabled(enrollment.course_id) - ) - ) - - # Verification Attempts - # Used to generate the "you must reverify for course x" banner - verification_status, verification_msg = SoftwareSecurePhotoVerification.user_status(user) - - # Gets data for midcourse reverifications, if any are necessary or have failed - statuses = ["approved", "denied", "pending", "must_reverify"] - reverifications = reverification_info(statuses) - - user_already_has_certs_for = GeneratedCertificate.course_ids_with_certs_for_user(request.user) - show_refund_option_for = frozenset( - enrollment.course_id for enrollment in course_enrollments - if enrollment.refundable( - user_already_has_certs_for=user_already_has_certs_for - ) - ) - - block_courses = frozenset( - enrollment.course_id for enrollment in course_enrollments - if is_course_blocked( - request, - CourseRegistrationCode.objects.filter( - course_id=enrollment.course_id, - registrationcoderedemption__redeemed_by=request.user - ), - enrollment.course_id - ) - ) - - enrolled_courses_either_paid = frozenset( - enrollment.course_id for enrollment in course_enrollments - if enrollment.is_paid_course() - ) - - # If there are *any* denied reverifications that have not been toggled off, - # we'll display the banner - denied_banner = any(item.display for item in reverifications["denied"]) - - # Populate the Order History for the side-bar. - order_history_list = order_history(user, course_org_filter=course_org_filter, org_filter_out_set=org_filter_out_set) - - # get list of courses having pre-requisites yet to be completed - courses_having_prerequisites = frozenset( - enrollment.course_id for enrollment in course_enrollments - if enrollment.course_overview.pre_requisite_courses - ) - courses_requirements_not_met = get_pre_requisite_courses_not_completed(user, courses_having_prerequisites) - - if 'notlive' in request.GET: - redirect_message = _("The course you are looking for does not start until {date}.").format( - date=request.GET['notlive'] - ) - elif 'course_closed' in request.GET: - redirect_message = _("The course you are looking for is closed for enrollment as of {date}.").format( - date=request.GET['course_closed'] - ) - else: - redirect_message = '' - - valid_verification_statuses = ['approved', 'must_reverify', 'pending', 'expired'] - display_sidebar_on_dashboard = len(order_history_list) or verification_status in valid_verification_statuses - - context = { - 'enterprise_message': enterprise_message, - 'enrollment_message': enrollment_message, - 'redirect_message': redirect_message, - 'course_enrollments': course_enrollments, - 'course_optouts': course_optouts, - 'message': message, - 'staff_access': staff_access, - 'errored_courses': errored_courses, - 'show_courseware_links_for': show_courseware_links_for, - 'all_course_modes': course_mode_info, - 'cert_statuses': cert_statuses, - 'credit_statuses': _credit_statuses(user, course_enrollments), - 'show_email_settings_for': show_email_settings_for, - 'reverifications': reverifications, - 'verification_status': verification_status, - 'verification_status_by_course': verify_status_by_course, - 'verification_msg': verification_msg, - 'show_refund_option_for': show_refund_option_for, - 'block_courses': block_courses, - 'denied_banner': denied_banner, - 'billing_email': settings.PAYMENT_SUPPORT_EMAIL, - 'user': user, - 'logout_url': reverse('logout'), - 'platform_name': platform_name, - 'enrolled_courses_either_paid': enrolled_courses_either_paid, - 'provider_states': [], - 'order_history_list': order_history_list, - 'courses_requirements_not_met': courses_requirements_not_met, - 'nav_hidden': True, - 'inverted_programs': inverted_programs, - 'show_program_listing': ProgramsApiConfig.is_enabled(), - 'disable_courseware_js': True, - 'display_course_modes_on_dashboard': enable_verified_certificates and display_course_modes_on_dashboard, - 'display_sidebar_on_dashboard': display_sidebar_on_dashboard, - } - - ecommerce_service = EcommerceService() - if ecommerce_service.is_enabled(request.user): - context.update({ - 'use_ecommerce_payment_flow': True, - 'ecommerce_payment_page': ecommerce_service.payment_page_url(), - }) - - response = render_to_response('dashboard.html', context) - set_user_info_cookie(response, request) - return response - - -def _create_recent_enrollment_message(course_enrollments, course_modes): # pylint: disable=invalid-name - """ - Builds a recent course enrollment message. - - Constructs a new message template based on any recent course enrollments - for the student. - - Args: - course_enrollments (list[CourseEnrollment]): a list of course enrollments. - course_modes (dict): Mapping of course ID's to course mode dictionaries. - - Returns: - A string representing the HTML message output from the message template. - None if there are no recently enrolled courses. - - """ - recently_enrolled_courses = _get_recently_enrolled_courses(course_enrollments) - - if recently_enrolled_courses: - enroll_messages = [ - { - "course_id": enrollment.course_overview.id, - "course_name": enrollment.course_overview.display_name, - "allow_donation": _allow_donation(course_modes, enrollment.course_overview.id, enrollment) - } - for enrollment in recently_enrolled_courses - ] - - platform_name = configuration_helpers.get_value('platform_name', settings.PLATFORM_NAME) - - return render_to_string( - 'enrollment/course_enrollment_message.html', - {'course_enrollment_messages': enroll_messages, 'platform_name': platform_name} - ) - - -def _get_recently_enrolled_courses(course_enrollments): - """ - Given a list of enrollments, filter out all but recent enrollments. - - Args: - course_enrollments (list[CourseEnrollment]): A list of course enrollments. - - Returns: - list[CourseEnrollment]: A list of recent course enrollments. - """ - seconds = DashboardConfiguration.current().recent_enrollment_time_delta - time_delta = (datetime.datetime.now(UTC) - datetime.timedelta(seconds=seconds)) - return [ - enrollment for enrollment in course_enrollments - # If the enrollment has no created date, we are explicitly excluding the course - # from the list of recent enrollments. - if enrollment.is_active and enrollment.created > time_delta - ] - - -def _allow_donation(course_modes, course_id, enrollment): - """Determines if the dashboard will request donations for the given course. - - Check if donations are configured for the platform, and if the current course is accepting donations. - - Args: - course_modes (dict): Mapping of course ID's to course mode dictionaries. - course_id (str): The unique identifier for the course. - enrollment(CourseEnrollment): The enrollment object in which the user is enrolled - - Returns: - True if the course is allowing donations. - - """ - if course_id not in course_modes: - flat_unexpired_modes = { - unicode(course_id): [mode for mode in modes] - for course_id, modes in course_modes.iteritems() - } - flat_all_modes = { - unicode(course_id): [mode.slug for mode in modes] - for course_id, modes in CourseMode.all_modes_for_courses([course_id]).iteritems() - } - log.error( - u'Can not find `%s` in course modes.`%s`. All modes: `%s`', - course_id, - flat_unexpired_modes, - flat_all_modes - ) - donations_enabled = configuration_helpers.get_value( - 'ENABLE_DONATIONS', - DonationConfiguration.current().enabled - ) - return ( - donations_enabled and - enrollment.mode in course_modes[course_id] and - course_modes[course_id][enrollment.mode].min_price == 0 - ) - - -def _update_email_opt_in(request, org): - """Helper function used to hit the profile API if email opt-in is enabled.""" - - email_opt_in = request.POST.get('email_opt_in') - if email_opt_in is not None: - email_opt_in_boolean = email_opt_in == 'true' - preferences_api.update_email_opt_in(request.user, org, email_opt_in_boolean) - - -def _credit_statuses(user, course_enrollments): - """ - Retrieve the status for credit courses. - - A credit course is a course for which a user can purchased - college credit. The current flow is: - - 1. User becomes eligible for credit (submits verifications, passes the course, etc.) - 2. User purchases credit from a particular credit provider. - 3. User requests credit from the provider, usually creating an account on the provider's site. - 4. The credit provider notifies us whether the user's request for credit has been accepted or rejected. - - The dashboard is responsible for communicating the user's state in this flow. - - Arguments: - user (User): The currently logged-in user. - course_enrollments (list[CourseEnrollment]): List of enrollments for the - user. - - Returns: dict - - The returned dictionary has keys that are `CourseKey`s and values that - are dictionaries with: - - * eligible (bool): True if the user is eligible for credit in this course. - * deadline (datetime): The deadline for purchasing and requesting credit for this course. - * purchased (bool): Whether the user has purchased credit for this course. - * provider_name (string): The display name of the credit provider. - * provider_status_url (string): A URL the user can visit to check on their credit request status. - * request_status (string): Either "pending", "approved", or "rejected" - * error (bool): If true, an unexpected error occurred when retrieving the credit status, - so the user should contact the support team. - - Example: - >>> _credit_statuses(user, course_enrollments) - { - CourseKey.from_string("edX/DemoX/Demo_Course"): { - "course_key": "edX/DemoX/Demo_Course", - "eligible": True, - "deadline": 2015-11-23 00:00:00 UTC, - "purchased": True, - "provider_name": "Hogwarts", - "provider_status_url": "http://example.com/status", - "request_status": "pending", - "error": False - } - } - - """ - from openedx.core.djangoapps.credit import api as credit_api - - # Feature flag off - if not settings.FEATURES.get("ENABLE_CREDIT_ELIGIBILITY"): - return {} - - request_status_by_course = { - request["course_key"]: request["status"] - for request in credit_api.get_credit_requests_for_user(user.username) - } - - credit_enrollments = { - enrollment.course_id: enrollment - for enrollment in course_enrollments - if enrollment.mode == "credit" - } - - # When a user purchases credit in a course, the user's enrollment - # mode is set to "credit" and an enrollment attribute is set - # with the ID of the credit provider. We retrieve *all* such attributes - # here to minimize the number of database queries. - purchased_credit_providers = { - attribute.enrollment.course_id: attribute.value - for attribute in CourseEnrollmentAttribute.objects.filter( - namespace="credit", - name="provider_id", - enrollment__in=credit_enrollments.values() - ).select_related("enrollment") - } - - provider_info_by_id = { - provider["id"]: provider - for provider in credit_api.get_credit_providers() - } - - statuses = {} - for eligibility in credit_api.get_eligibilities_for_user(user.username): - course_key = CourseKey.from_string(unicode(eligibility["course_key"])) - providers_names = get_credit_provider_display_names(course_key) - status = { - "course_key": unicode(course_key), - "eligible": True, - "deadline": eligibility["deadline"], - "purchased": course_key in credit_enrollments, - "provider_name": make_providers_strings(providers_names), - "provider_status_url": None, - "provider_id": None, - "request_status": request_status_by_course.get(course_key), - "error": False, - } - - # If the user has purchased credit, then include information about the credit - # provider from which the user purchased credit. - # We retrieve the provider's ID from the an "enrollment attribute" set on the user's - # enrollment when the user's order for credit is fulfilled by the E-Commerce service. - if status["purchased"]: - provider_id = purchased_credit_providers.get(course_key) - if provider_id is None: - status["error"] = True - log.error( - u"Could not find credit provider associated with credit enrollment " - u"for user %s in course %s. The user will not be able to see his or her " - u"credit request status on the student dashboard. This attribute should " - u"have been set when the user purchased credit in the course.", - user.id, course_key - ) - else: - provider_info = provider_info_by_id.get(provider_id, {}) - status["provider_name"] = provider_info.get("display_name") - status["provider_status_url"] = provider_info.get("status_url") - status["provider_id"] = provider_id - - statuses[course_key] = status - - return statuses - - -@transaction.non_atomic_requests -@require_POST -@outer_atomic(read_committed=True) -def change_enrollment(request, check_access=True): - """ - Modify the enrollment status for the logged-in user. - - TODO: This is lms specific and does not belong in common code. - - The request parameter must be a POST request (other methods return 405) - that specifies course_id and enrollment_action parameters. If course_id or - enrollment_action is not specified, if course_id is not valid, if - enrollment_action is something other than "enroll" or "unenroll", if - enrollment_action is "enroll" and enrollment is closed for the course, or - if enrollment_action is "unenroll" and the user is not enrolled in the - course, a 400 error will be returned. If the user is not logged in, 403 - will be returned; it is important that only this case return 403 so the - front end can redirect the user to a registration or login page when this - happens. This function should only be called from an AJAX request, so - the error messages in the responses should never actually be user-visible. - - Args: - request (`Request`): The Django request object - - Keyword Args: - check_access (boolean): If True, we check that an accessible course actually - exists for the given course_key before we enroll the student. - The default is set to False to avoid breaking legacy code or - code with non-standard flows (ex. beta tester invitations), but - for any standard enrollment flow you probably want this to be True. - - Returns: - Response - - """ - # Get the user - user = request.user - - # Ensure the user is authenticated - if not user.is_authenticated(): - return HttpResponseForbidden() - - # Ensure we received a course_id - action = request.POST.get("enrollment_action") - if 'course_id' not in request.POST: - return HttpResponseBadRequest(_("Course id not specified")) - - try: - course_id = SlashSeparatedCourseKey.from_deprecated_string(request.POST.get("course_id")) - except InvalidKeyError: - log.warning( - u"User %s tried to %s with invalid course id: %s", - user.username, - action, - request.POST.get("course_id"), - ) - return HttpResponseBadRequest(_("Invalid course id")) - - course_shift_name = request.POST.get("course_shift") - if action == "enroll": - # Make sure the course exists - # We don't do this check on unenroll, or a bad course id can't be unenrolled from - if not modulestore().has_course(course_id): - log.warning( - u"User %s tried to enroll in non-existent course %s", - user.username, - course_id - ) - return HttpResponseBadRequest(_("Course id is invalid")) - - # Record the user's email opt-in preference - if settings.FEATURES.get('ENABLE_MKTG_EMAIL_OPT_IN'): - _update_email_opt_in(request, course_id.org) - - available_modes = CourseMode.modes_for_course_dict(course_id) - - # Check whether the user is blocked from enrolling in this course - # This can occur if the user's IP is on a global blacklist - # or if the user is enrolling in a country in which the course - # is not available. - redirect_url = embargo_api.redirect_if_blocked( - course_id, user=user, ip_address=get_ip(request), - url=request.path - ) - if redirect_url: - return HttpResponse(redirect_url) - - # Check that auto enrollment is allowed for this course - # (= the course is NOT behind a paywall) - if CourseMode.can_auto_enroll(course_id): - # Enroll the user using the default mode (audit) - # We're assuming that users of the course enrollment table - # will NOT try to look up the course enrollment model - # by its slug. If they do, it's possible (based on the state of the database) - # for no such model to exist, even though we've set the enrollment type - # to "audit". - try: - enroll_mode = CourseMode.auto_enroll_mode(course_id, available_modes) - if enroll_mode: - if course_shift_name: - shift_manager = CourseShiftManager(course_id) - shift = shift_manager.get_shift(course_shift_name) - shift_manager.enroll_user(user, shift) - CourseEnrollment.enroll(user, course_id, check_access=check_access, mode=enroll_mode) - except Exception: # pylint: disable=broad-except - return HttpResponseBadRequest(_("Could not enroll")) - - # If we have more than one course mode or professional ed is enabled, - # then send the user to the choose your track page. - # (In the case of no-id-professional/professional ed, this will redirect to a page that - # funnels users directly into the verification / payment flow) - if CourseMode.has_verified_mode(available_modes) or CourseMode.has_professional_mode(available_modes): - return HttpResponse( - reverse("course_modes_choose", kwargs={'course_id': unicode(course_id)}) - ) - - # Otherwise, there is only one mode available (the default) - return HttpResponse() - elif action == "unenroll": - enrollment = CourseEnrollment.get_enrollment(user, course_id) - if not enrollment: - return HttpResponseBadRequest(_("You are not enrolled in this course")) - - certificate_info = cert_info(user, enrollment.course_overview, enrollment.mode) - if certificate_info.get('status') in DISABLE_UNENROLL_CERT_STATES: - return HttpResponseBadRequest(_("Your certificate prevents you from unenrolling from this course")) - - CourseEnrollment.unenroll(user, course_id) - return HttpResponse() - else: - return HttpResponseBadRequest(_("Enrollment action is invalid")) - - -# Need different levels of logging -@ensure_csrf_cookie -def login_user(request, error=""): # pylint: disable=too-many-statements,unused-argument - """AJAX request to log in the user.""" - - backend_name = None - email = None - password = None - redirect_url = None - response = None - running_pipeline = None - third_party_auth_requested = third_party_auth.is_enabled() and pipeline.running(request) - third_party_auth_successful = False - trumped_by_first_party_auth = bool(request.POST.get('email')) or bool(request.POST.get('password')) - user = None - platform_name = configuration_helpers.get_value("platform_name", settings.PLATFORM_NAME) - - if third_party_auth_requested and not trumped_by_first_party_auth: - # The user has already authenticated via third-party auth and has not - # asked to do first party auth by supplying a username or password. We - # now want to put them through the same logging and cookie calculation - # logic as with first-party auth. - running_pipeline = pipeline.get(request) - username = running_pipeline['kwargs'].get('username') - backend_name = running_pipeline['backend'] - third_party_uid = running_pipeline['kwargs']['uid'] - requested_provider = provider.Registry.get_from_pipeline(running_pipeline) - - try: - user = pipeline.get_authenticated_user(requested_provider, username, third_party_uid) - third_party_auth_successful = True - except User.DoesNotExist: - AUDIT_LOG.warning( - u"Login failed - user with username {username} has no social auth " - "with backend_name {backend_name}".format( - username=username, backend_name=backend_name) - ) - message = _( - "You've successfully logged into your {provider_name} account, " - "but this account isn't linked with an {platform_name} account yet." - ).format( - platform_name=platform_name, - provider_name=requested_provider.name, - ) - message += "

" - message += _( - "Use your {platform_name} username and password to log into {platform_name} below, " - "and then link your {platform_name} account with {provider_name} from your dashboard." - ).format( - platform_name=platform_name, - provider_name=requested_provider.name, - ) - message += "

" - message += _( - "If you don't have an {platform_name} account yet, " - "click Register at the top of the page." - ).format( - platform_name=platform_name - ) - - return HttpResponse(message, content_type="text/plain", status=403) - - else: - - if 'email' not in request.POST or 'password' not in request.POST: - return JsonResponse({ - "success": False, - # TODO: User error message - "value": _('There was an error receiving your login information. Please email us.'), - }) # TODO: this should be status code 400 - - email = request.POST['email'] - password = request.POST['password'] - try: - user = User.objects.get(email=email) - except User.DoesNotExist: - if settings.FEATURES['SQUELCH_PII_IN_LOGS']: - AUDIT_LOG.warning(u"Login failed - Unknown user email") - else: - AUDIT_LOG.warning(u"Login failed - Unknown user email: {0}".format(email)) - - # check if the user has a linked shibboleth account, if so, redirect the user to shib-login - # This behavior is pretty much like what gmail does for shibboleth. Try entering some @stanford.edu - # address into the Gmail login. - if settings.FEATURES.get('AUTH_USE_SHIB') and user: - try: - eamap = ExternalAuthMap.objects.get(user=user) - if eamap.external_domain.startswith(openedx.core.djangoapps.external_auth.views.SHIBBOLETH_DOMAIN_PREFIX): - return JsonResponse({ - "success": False, - "redirect": reverse('shib-login'), - }) # TODO: this should be status code 301 # pylint: disable=fixme - except ExternalAuthMap.DoesNotExist: - # This is actually the common case, logging in user without external linked login - AUDIT_LOG.info(u"User %s w/o external auth attempting login", user) - - # see if account has been locked out due to excessive login failures - user_found_by_email_lookup = user - if user_found_by_email_lookup and LoginFailures.is_feature_enabled(): - if LoginFailures.is_user_locked_out(user_found_by_email_lookup): - lockout_message = _('This account has been temporarily locked due ' - 'to excessive login failures. Try again later.') - return JsonResponse({ - "success": False, - "value": lockout_message, - }) # TODO: this should be status code 429 # pylint: disable=fixme - - # see if the user must reset his/her password due to any policy settings - if user_found_by_email_lookup and PasswordHistory.should_user_reset_password_now(user_found_by_email_lookup): - return JsonResponse({ - "success": False, - "value": _('Your password has expired due to password policy on this account. You must ' - 'reset your password before you can log in again. Please click the ' - '"Forgot Password" link on this page to reset your password before logging in again.'), - }) # TODO: this should be status code 403 # pylint: disable=fixme - - # if the user doesn't exist, we want to set the username to an invalid - # username so that authentication is guaranteed to fail and we can take - # advantage of the ratelimited backend - username = user.username if user else "" - - if not third_party_auth_successful: - try: - user = authenticate(username=username, password=password, request=request) - # this occurs when there are too many attempts from the same IP address - except RateLimitException: - return JsonResponse({ - "success": False, - "value": _('Too many failed login attempts. Try again later.'), - }) # TODO: this should be status code 429 # pylint: disable=fixme - - if user is None: - # tick the failed login counters if the user exists in the database - if user_found_by_email_lookup and LoginFailures.is_feature_enabled(): - LoginFailures.increment_lockout_counter(user_found_by_email_lookup) - - # if we didn't find this username earlier, the account for this email - # doesn't exist, and doesn't have a corresponding password - if username != "": - if settings.FEATURES['SQUELCH_PII_IN_LOGS']: - loggable_id = user_found_by_email_lookup.id if user_found_by_email_lookup else "" - AUDIT_LOG.warning(u"Login failed - password for user.id: {0} is invalid".format(loggable_id)) - else: - AUDIT_LOG.warning(u"Login failed - password for {0} is invalid".format(email)) - return JsonResponse({ - "success": False, - "value": _('Email or password is incorrect.'), - }) # TODO: this should be status code 400 # pylint: disable=fixme - - # successful login, clear failed login attempts counters, if applicable - if LoginFailures.is_feature_enabled(): - LoginFailures.clear_lockout_counter(user) - - # Track the user's sign in - if hasattr(settings, 'LMS_SEGMENT_KEY') and settings.LMS_SEGMENT_KEY: - tracking_context = tracker.get_tracker().resolve_context() - analytics.identify( - user.id, - { - 'email': email, - 'username': username - }, - { - # Disable MailChimp because we don't want to update the user's email - # and username in MailChimp on every page load. We only need to capture - # this data on registration/activation. - 'MailChimp': False - } - ) - - analytics.track( - user.id, - "edx.bi.user.account.authenticated", - { - 'category': "conversion", - 'label': request.POST.get('course_id'), - 'provider': None - }, - context={ - 'ip': tracking_context.get('ip'), - 'Google Analytics': { - 'clientId': tracking_context.get('client_id') - } - } - ) - if user is not None and user.is_active: - try: - # We do not log here, because we have a handler registered - # to perform logging on successful logins. - login(request, user) - if request.POST.get('remember') == 'true': - request.session.set_expiry(604800) - log.debug("Setting user session to never expire") - else: - request.session.set_expiry(0) - except Exception as exc: # pylint: disable=broad-except - AUDIT_LOG.critical("Login failed - Could not create session. Is memcached running?") - log.critical("Login failed - Could not create session. Is memcached running?") - log.exception(exc) - raise - - redirect_url = None # The AJAX method calling should know the default destination upon success - if third_party_auth_successful: - redirect_url = pipeline.get_complete_url(backend_name) - - response = JsonResponse({ - "success": True, - "redirect_url": redirect_url, - }) - - # Ensure that the external marketing site can - # detect that the user is logged in. - return set_logged_in_cookies(request, response, user) - - if settings.FEATURES['SQUELCH_PII_IN_LOGS']: - AUDIT_LOG.warning(u"Login failed - Account not active for user.id: {0}, resending activation".format(user.id)) - else: - AUDIT_LOG.warning(u"Login failed - Account not active for user {0}, resending activation".format(username)) - - reactivation_email_for_user(user) - not_activated_msg = _("Before you sign in, you need to activate your account. We have sent you an " - "email message with instructions for activating your account.") - return JsonResponse({ - "success": False, - "value": not_activated_msg, - }) # TODO: this should be status code 400 # pylint: disable=fixme - - -@csrf_exempt -@require_POST -@social_utils.strategy("social:complete") -def login_oauth_token(request, backend): - """ - Authenticate the client using an OAuth access token by using the token to - retrieve information from a third party and matching that information to an - existing user. - """ - warnings.warn("Please use AccessTokenExchangeView instead.", DeprecationWarning) - - backend = request.backend - if isinstance(backend, social_oauth.BaseOAuth1) or isinstance(backend, social_oauth.BaseOAuth2): - if "access_token" in request.POST: - # Tell third party auth pipeline that this is an API call - request.session[pipeline.AUTH_ENTRY_KEY] = pipeline.AUTH_ENTRY_LOGIN_API - user = None - try: - user = backend.do_auth(request.POST["access_token"]) - except (HTTPError, AuthException): - pass - # do_auth can return a non-User object if it fails - if user and isinstance(user, User): - login(request, user) - return JsonResponse(status=204) - else: - # Ensure user does not re-enter the pipeline - request.social_strategy.clean_partial_pipeline() - return JsonResponse({"error": "invalid_token"}, status=401) - else: - return JsonResponse({"error": "invalid_request"}, status=400) - raise Http404 - - -@require_GET -@login_required -@ensure_csrf_cookie -def manage_user_standing(request): - """ - Renders the view used to manage user standing. Also displays a table - of user accounts that have been disabled and who disabled them. - """ - if not request.user.is_staff: - raise Http404 - all_disabled_accounts = UserStanding.objects.filter( - account_status=UserStanding.ACCOUNT_DISABLED - ) - - all_disabled_users = [standing.user for standing in all_disabled_accounts] - - headers = ['username', 'account_changed_by'] - rows = [] - for user in all_disabled_users: - row = [user.username, user.standing.changed_by] - rows.append(row) - - context = {'headers': headers, 'rows': rows} - - return render_to_response("manage_user_standing.html", context) - - -@require_POST -@login_required -@ensure_csrf_cookie -def disable_account_ajax(request): - """ - Ajax call to change user standing. Endpoint of the form - in manage_user_standing.html - """ - if not request.user.is_staff: - raise Http404 - username = request.POST.get('username') - context = {} - if username is None or username.strip() == '': - context['message'] = _('Please enter a username') - return JsonResponse(context, status=400) - - account_action = request.POST.get('account_action') - if account_action is None: - context['message'] = _('Please choose an option') - return JsonResponse(context, status=400) - - username = username.strip() - try: - user = User.objects.get(username=username) - except User.DoesNotExist: - context['message'] = _("User with username {} does not exist").format(username) - return JsonResponse(context, status=400) - else: - user_account, _success = UserStanding.objects.get_or_create( - user=user, defaults={'changed_by': request.user}, - ) - if account_action == 'disable': - user_account.account_status = UserStanding.ACCOUNT_DISABLED - context['message'] = _("Successfully disabled {}'s account").format(username) - log.info(u"%s disabled %s's account", request.user, username) - elif account_action == 'reenable': - user_account.account_status = UserStanding.ACCOUNT_ENABLED - context['message'] = _("Successfully reenabled {}'s account").format(username) - log.info(u"%s reenabled %s's account", request.user, username) - else: - context['message'] = _("Unexpected account status") - return JsonResponse(context, status=400) - user_account.changed_by = request.user - user_account.standing_last_changed_at = datetime.datetime.now(UTC) - user_account.save() - - return JsonResponse(context) - - -@login_required -@ensure_csrf_cookie -def change_setting(request): - """JSON call to change a profile setting: Right now, location""" - # TODO (vshnayder): location is no longer used - u_prof = UserProfile.objects.get(user=request.user) # request.user.profile_cache - if 'location' in request.POST: - u_prof.location = request.POST['location'] - u_prof.save() - - return JsonResponse({ - "success": True, - "location": u_prof.location, - }) - - -class AccountValidationError(Exception): - def __init__(self, message, field): - super(AccountValidationError, self).__init__(message) - self.field = field - - -@receiver(post_save, sender=User) -def user_signup_handler(sender, **kwargs): # pylint: disable=unused-argument - """ - handler that saves the user Signup Source - when the user is created - """ - if 'created' in kwargs and kwargs['created']: - site = configuration_helpers.get_value('SITE_NAME') - if site: - user_signup_source = UserSignupSource(user=kwargs['instance'], site=site) - user_signup_source.save() - log.info(u'user {} originated from a white labeled "Microsite"'.format(kwargs['instance'].id)) - - -def _do_create_account(form, custom_form=None, site=None): - """ - Given cleaned post variables, create the User and UserProfile objects, as well as the - registration for this user. - - Returns a tuple (User, UserProfile, Registration). - - Note: this function is also used for creating test users. - """ - # Check if ALLOW_PUBLIC_ACCOUNT_CREATION flag turned off to restrict user account creation - if not configuration_helpers.get_value( - 'ALLOW_PUBLIC_ACCOUNT_CREATION', - settings.FEATURES.get('ALLOW_PUBLIC_ACCOUNT_CREATION', True) - ): - raise PermissionDenied() - - errors = {} - errors.update(form.errors) - if custom_form: - errors.update(custom_form.errors) - - if errors: - raise ValidationError(errors) - - user = User( - username=form.cleaned_data["username"], - email=form.cleaned_data["email"], - is_active=False - ) - user.set_password(form.cleaned_data["password"]) - registration = Registration() - - # TODO: Rearrange so that if part of the process fails, the whole process fails. - # Right now, we can have e.g. no registration e-mail sent out and a zombie account - try: - with transaction.atomic(): - user.save() - if custom_form: - custom_model = custom_form.save(commit=False) - custom_model.user = user - custom_model.save() - - if site: - # Set UserAttribute indicating the site the user account was created on. - UserAttribute.set_user_attribute(user, 'created_on_site', site.domain) - except IntegrityError: - # Figure out the cause of the integrity error - if len(User.objects.filter(username=user.username)) > 0: - raise AccountValidationError( - _("An account with the Public Username '{username}' already exists.").format(username=user.username), - field="username" - ) - elif len(User.objects.filter(email=user.email)) > 0: - raise AccountValidationError( - _("An account with the Email '{email}' already exists.").format(email=user.email), - field="email" - ) - else: - raise - - # add this account creation to password history - # NOTE, this will be a NOP unless the feature has been turned on in configuration - password_history_entry = PasswordHistory() - password_history_entry.create(user) - - registration.register(user) - - profile_fields = [ - "name", "level_of_education", "gender", "mailing_address", "city", "country", "goals", - "year_of_birth" - ] - profile = UserProfile( - user=user, - **{key: form.cleaned_data.get(key) for key in profile_fields} - ) - extended_profile = form.cleaned_extended_profile - if extended_profile: - profile.meta = json.dumps(extended_profile) - try: - profile.save() - except Exception: # pylint: disable=broad-except - log.exception("UserProfile creation failed for user {id}.".format(id=user.id)) - raise - - return (user, profile, registration) - - -def create_account_with_params(request, params): - """ - Given a request and a dict of parameters (which may or may not have come - from the request), create an account for the requesting user, including - creating a comments service user object and sending an activation email. - This also takes external/third-party auth into account, updates that as - necessary, and authenticates the user for the request's session. - - Does not return anything. - - Raises AccountValidationError if an account with the username or email - specified by params already exists, or ValidationError if any of the given - parameters is invalid for any other reason. - - Issues with this code: - * It is not transactional. If there is a failure part-way, an incomplete - account will be created and left in the database. - * Third-party auth passwords are not verified. There is a comment that - they are unused, but it would be helpful to have a sanity check that - they are sane. - * It is over 300 lines long (!) and includes disprate functionality, from - registration e-mails to all sorts of other things. It should be broken - up into semantically meaningful functions. - * The user-facing text is rather unfriendly (e.g. "Username must be a - minimum of two characters long" rather than "Please use a username of - at least two characters"). - """ - # Copy params so we can modify it; we can't just do dict(params) because if - # params is request.POST, that results in a dict containing lists of values - params = dict(params.items()) - - # allow to define custom set of required/optional/hidden fields via configuration - extra_fields = configuration_helpers.get_value( - 'REGISTRATION_EXTRA_FIELDS', - getattr(settings, 'REGISTRATION_EXTRA_FIELDS', {}) - ) - # registration via third party (Google, Facebook) using mobile application - # doesn't use social auth pipeline (no redirect uri(s) etc involved). - # In this case all related info (required for account linking) - # is sent in params. - # `third_party_auth_credentials_in_api` essentially means 'request - # is made from mobile application' - third_party_auth_credentials_in_api = 'provider' in params - - is_third_party_auth_enabled = third_party_auth.is_enabled() - - if is_third_party_auth_enabled and (pipeline.running(request) or third_party_auth_credentials_in_api): - params["password"] = pipeline.make_random_password() - - # in case user is registering via third party (Google, Facebook) and pipeline has expired, show appropriate - # error message - if is_third_party_auth_enabled and ('social_auth_provider' in params and not pipeline.running(request)): - raise ValidationError( - {'session_expired': [ - _(u"Registration using {provider} has timed out.").format( - provider=params.get('social_auth_provider')) - ]} - ) - - # if doing signup for an external authorization, then get email, password, name from the eamap - # don't use the ones from the form, since the user could have hacked those - # unless originally we didn't get a valid email or name from the external auth - # TODO: We do not check whether these values meet all necessary criteria, such as email length - do_external_auth = 'ExternalAuthMap' in request.session - if do_external_auth: - eamap = request.session['ExternalAuthMap'] - try: - validate_email(eamap.external_email) - params["email"] = eamap.external_email - except ValidationError: - pass - if eamap.external_name.strip() != '': - params["name"] = eamap.external_name - params["password"] = eamap.internal_password - log.debug(u'In create_account with external_auth: user = %s, email=%s', params["name"], params["email"]) - - extended_profile_fields = configuration_helpers.get_value('extended_profile_fields', []) - enforce_password_policy = ( - settings.FEATURES.get("ENFORCE_PASSWORD_POLICY", False) and - not do_external_auth - ) - # Can't have terms of service for certain SHIB users, like at Stanford - registration_fields = getattr(settings, 'REGISTRATION_EXTRA_FIELDS', {}) - tos_required = ( - registration_fields.get('terms_of_service') != 'hidden' or - registration_fields.get('honor_code') != 'hidden' - ) and ( - not settings.FEATURES.get("AUTH_USE_SHIB") or - not settings.FEATURES.get("SHIB_DISABLE_TOS") or - not do_external_auth or - not eamap.external_domain.startswith(openedx.core.djangoapps.external_auth.views.SHIBBOLETH_DOMAIN_PREFIX) - ) - - form = AccountCreationForm( - data=params, - extra_fields=extra_fields, - extended_profile_fields=extended_profile_fields, - enforce_username_neq_password=True, - enforce_password_policy=enforce_password_policy, - tos_required=tos_required, - ) - custom_form = get_registration_extension_form(data=params) - - # Perform operations within a transaction that are critical to account creation - with transaction.atomic(): - # first, create the account - (user, profile, registration) = _do_create_account(form, custom_form, site=request.site) - - # If a 3rd party auth provider and credentials were provided in the API, link the account with social auth - # (If the user is using the normal register page, the social auth pipeline does the linking, not this code) - - # Note: this is orthogonal to the 3rd party authentication pipeline that occurs - # when the account is created via the browser and redirect URLs. - - if is_third_party_auth_enabled and third_party_auth_credentials_in_api: - backend_name = params['provider'] - request.social_strategy = social_utils.load_strategy(request) - redirect_uri = reverse('social:complete', args=(backend_name, )) - request.backend = social_utils.load_backend(request.social_strategy, backend_name, redirect_uri) - social_access_token = params.get('access_token') - if not social_access_token: - raise ValidationError({ - 'access_token': [ - _("An access_token is required when passing value ({}) for provider.").format( - params['provider'] - ) - ] - }) - request.session[pipeline.AUTH_ENTRY_KEY] = pipeline.AUTH_ENTRY_REGISTER_API - pipeline_user = None - error_message = "" - try: - pipeline_user = request.backend.do_auth(social_access_token, user=user) - except AuthAlreadyAssociated: - error_message = _("The provided access_token is already associated with another user.") - except (HTTPError, AuthException): - error_message = _("The provided access_token is not valid.") - if not pipeline_user or not isinstance(pipeline_user, User): - # Ensure user does not re-enter the pipeline - request.social_strategy.clean_partial_pipeline() - raise ValidationError({'access_token': [error_message]}) - - # Perform operations that are non-critical parts of account creation - preferences_api.set_user_preference(user, LANGUAGE_KEY, get_language()) - - if settings.FEATURES.get('ENABLE_DISCUSSION_EMAIL_DIGEST'): - try: - enable_notifications(user) - except Exception: # pylint: disable=broad-except - log.exception("Enable discussion notifications failed for user {id}.".format(id=user.id)) - - dog_stats_api.increment("common.student.account_created") - - # If the user is registering via 3rd party auth, track which provider they use - third_party_provider = None - running_pipeline = None - if is_third_party_auth_enabled and pipeline.running(request): - running_pipeline = pipeline.get(request) - third_party_provider = provider.Registry.get_from_pipeline(running_pipeline) - - # Track the user's registration - if hasattr(settings, 'LMS_SEGMENT_KEY') and settings.LMS_SEGMENT_KEY: - tracking_context = tracker.get_tracker().resolve_context() - identity_args = [ - user.id, # pylint: disable=no-member - { - 'email': user.email, - 'username': user.username, - 'name': profile.name, - # Mailchimp requires the age & yearOfBirth to be integers, we send a sane integer default if falsey. - 'age': profile.age or -1, - 'yearOfBirth': profile.year_of_birth or datetime.datetime.now(UTC).year, - 'education': profile.level_of_education_display, - 'address': profile.mailing_address, - 'gender': profile.gender_display, - 'country': unicode(profile.country), - } - ] - - if hasattr(settings, 'MAILCHIMP_NEW_USER_LIST_ID'): - identity_args.append({ - "MailChimp": { - "listId": settings.MAILCHIMP_NEW_USER_LIST_ID - } - }) - - analytics.identify(*identity_args) - - analytics.track( - user.id, - "edx.bi.user.account.registered", - { - 'category': 'conversion', - 'label': params.get('course_id'), - 'provider': third_party_provider.name if third_party_provider else None - }, - context={ - 'ip': tracking_context.get('ip'), - 'Google Analytics': { - 'clientId': tracking_context.get('client_id') - } - } - ) - - # Announce registration - REGISTER_USER.send(sender=None, user=user, profile=profile) - - create_comments_service_user(user) - - # Don't send email if we are: - # - # 1. Doing load testing. - # 2. Random user generation for other forms of testing. - # 3. External auth bypassing activation. - # 4. Have the platform configured to not require e-mail activation. - # 5. Registering a new user using a trusted third party provider (with skip_email_verification=True) - # - # Note that this feature is only tested as a flag set one way or - # the other for *new* systems. we need to be careful about - # changing settings on a running system to make sure no users are - # left in an inconsistent state (or doing a migration if they are). - send_email = ( - not settings.FEATURES.get('SKIP_EMAIL_VALIDATION', None) and - not settings.FEATURES.get('AUTOMATIC_AUTH_FOR_TESTING') and - not (do_external_auth and settings.FEATURES.get('BYPASS_ACTIVATION_EMAIL_FOR_EXTAUTH')) and - not ( - third_party_provider and third_party_provider.skip_email_verification and - user.email == running_pipeline['kwargs'].get('details', {}).get('email') - ) - ) - if send_email: - compose_and_send_activation_email(user, profile, registration) - else: - registration.activate() - _enroll_user_in_pending_courses(user) # Enroll student in any pending courses - - # Immediately after a user creates an account, we log them in. They are only - # logged in until they close the browser. They can't log in again until they click - # the activation link from the email. - new_user = authenticate(username=user.username, password=params['password']) - login(request, new_user) - request.session.set_expiry(0) - - try: - record_registration_attributions(request, new_user) - # Don't prevent a user from registering due to attribution errors. - except Exception: # pylint: disable=broad-except - log.exception('Error while attributing cookies to user registration.') - - # TODO: there is no error checking here to see that the user actually logged in successfully, - # and is not yet an active user. - if new_user is not None: - AUDIT_LOG.info(u"Login success on new account creation - {0}".format(new_user.username)) - - if do_external_auth: - eamap.user = new_user - eamap.dtsignup = datetime.datetime.now(UTC) - eamap.save() - AUDIT_LOG.info(u"User registered with external_auth %s", new_user.username) - AUDIT_LOG.info(u'Updated ExternalAuthMap for %s to be %s', new_user.username, eamap) - - if settings.FEATURES.get('BYPASS_ACTIVATION_EMAIL_FOR_EXTAUTH'): - log.info('bypassing activation email') - new_user.is_active = True - new_user.save() - AUDIT_LOG.info(u"Login activated on extauth account - {0} ({1})".format(new_user.username, new_user.email)) - - return new_user - - -def _enroll_user_in_pending_courses(student): - """ - Enroll student in any pending courses he/she may have. - """ - ceas = CourseEnrollmentAllowed.objects.filter(email=student.email) - for cea in ceas: - if cea.auto_enroll: - enrollment = CourseEnrollment.enroll(student, cea.course_id) - manual_enrollment_audit = ManualEnrollmentAudit.get_manual_enrollment_by_email(student.email) - if manual_enrollment_audit is not None: - # get the enrolled by user and reason from the ManualEnrollmentAudit table. - # then create a new ManualEnrollmentAudit table entry for the same email - # different transition state. - ManualEnrollmentAudit.create_manual_enrollment_audit( - manual_enrollment_audit.enrolled_by, student.email, ALLOWEDTOENROLL_TO_ENROLLED, - manual_enrollment_audit.reason, enrollment - ) - - -def record_affiliate_registration_attribution(request, user): - """ - Attribute this user's registration to the referring affiliate, if - applicable. - """ - affiliate_id = request.COOKIES.get(settings.AFFILIATE_COOKIE_NAME) - if user and affiliate_id: - UserAttribute.set_user_attribute(user, REGISTRATION_AFFILIATE_ID, affiliate_id) - - -def record_utm_registration_attribution(request, user): - """ - Attribute this user's registration to the latest UTM referrer, if - applicable. - """ - utm_cookie_name = RegistrationCookieConfiguration.current().utm_cookie_name - utm_cookie = request.COOKIES.get(utm_cookie_name) - if user and utm_cookie: - utm = json.loads(utm_cookie) - for utm_parameter_name in REGISTRATION_UTM_PARAMETERS: - utm_parameter = utm.get(utm_parameter_name) - if utm_parameter: - UserAttribute.set_user_attribute( - user, - REGISTRATION_UTM_PARAMETERS.get(utm_parameter_name), - utm_parameter - ) - created_at_unixtime = utm.get('created_at') - if created_at_unixtime: - # We divide by 1000 here because the javascript timestamp generated is in milliseconds not seconds. - # PYTHON: time.time() => 1475590280.823698 - # JS: new Date().getTime() => 1475590280823 - created_at_datetime = datetime.datetime.fromtimestamp(int(created_at_unixtime) / float(1000), tz=UTC) - UserAttribute.set_user_attribute( - user, - REGISTRATION_UTM_CREATED_AT, - created_at_datetime - ) - - -def record_registration_attributions(request, user): - """ - Attribute this user's registration based on referrer cookies. - """ - record_affiliate_registration_attribution(request, user) - record_utm_registration_attribution(request, user) - - -@csrf_exempt -def create_account(request, post_override=None): - """ - JSON call to create new edX account. - Used by form in signup_modal.html, which is included into navigation.html - """ - # Check if ALLOW_PUBLIC_ACCOUNT_CREATION flag turned off to restrict user account creation - if not configuration_helpers.get_value( - 'ALLOW_PUBLIC_ACCOUNT_CREATION', - settings.FEATURES.get('ALLOW_PUBLIC_ACCOUNT_CREATION', True) - ): - return HttpResponseForbidden(_("Account creation not allowed.")) - - warnings.warn("Please use RegistrationView instead.", DeprecationWarning) - - try: - user = create_account_with_params(request, post_override or request.POST) - except AccountValidationError as exc: - return JsonResponse({'success': False, 'value': exc.message, 'field': exc.field}, status=400) - except ValidationError as exc: - field, error_list = next(exc.message_dict.iteritems()) - return JsonResponse( - { - "success": False, - "field": field, - "value": error_list[0], - }, - status=400 - ) - - redirect_url = None # The AJAX method calling should know the default destination upon success - - # Resume the third-party-auth pipeline if necessary. - if third_party_auth.is_enabled() and pipeline.running(request): - running_pipeline = pipeline.get(request) - redirect_url = pipeline.get_complete_url(running_pipeline['backend']) - - response = JsonResponse({ - 'success': True, - 'redirect_url': redirect_url, - }) - set_logged_in_cookies(request, response, user) - return response - - -def auto_auth(request): - """ - Create or configure a user account, then log in as that user. - - Enabled only when - settings.FEATURES['AUTOMATIC_AUTH_FOR_TESTING'] is true. - - Accepts the following querystring parameters: - * `username`, `email`, and `password` for the user account - * `full_name` for the user profile (the user's full name; defaults to the username) - * `staff`: Set to "true" to make the user global staff. - * `course_id`: Enroll the student in the course with `course_id` - * `roles`: Comma-separated list of roles to grant the student in the course with `course_id` - * `no_login`: Define this to create the user but not login - * `redirect`: Set to "true" will redirect to the `redirect_to` value if set, or - course home page if course_id is defined, otherwise it will redirect to dashboard - * `redirect_to`: will redirect to to this url - * `is_active` : make/update account with status provided as 'is_active' - If username, email, or password are not provided, use - randomly generated credentials. - """ - - # Generate a unique name to use if none provided - unique_name = uuid.uuid4().hex[0:30] - - # Use the params from the request, otherwise use these defaults - username = request.GET.get('username', unique_name) - password = request.GET.get('password', unique_name) - email = request.GET.get('email', unique_name + "@example.com") - full_name = request.GET.get('full_name', username) - is_staff = request.GET.get('staff', None) - is_superuser = request.GET.get('superuser', None) - course_id = request.GET.get('course_id', None) - redirect_to = request.GET.get('redirect_to', None) - active_status = request.GET.get('is_active') - - # mode has to be one of 'honor'/'professional'/'verified'/'audit'/'no-id-professional'/'credit' - enrollment_mode = request.GET.get('enrollment_mode', 'honor') - - active_status = (not active_status or active_status == 'true') - - course_key = None - if course_id: - course_key = CourseLocator.from_string(course_id) - role_names = [v.strip() for v in request.GET.get('roles', '').split(',') if v.strip()] - redirect_when_done = request.GET.get('redirect', '').lower() == 'true' or redirect_to - login_when_done = 'no_login' not in request.GET - - form = AccountCreationForm( - data={ - 'username': username, - 'email': email, - 'password': password, - 'name': full_name, - }, - tos_required=False - ) - - # Attempt to create the account. - # If successful, this will return a tuple containing - # the new user object. - try: - user, profile, reg = _do_create_account(form, site=request.site) - except (AccountValidationError, ValidationError): - # Attempt to retrieve the existing user. - user = User.objects.get(username=username) - user.email = email - user.set_password(password) - user.is_active = active_status - user.save() - profile = UserProfile.objects.get(user=user) - reg = Registration.objects.get(user=user) - except PermissionDenied: - return HttpResponseForbidden(_("Account creation not allowed.")) - - # Set the user's global staff bit - if is_staff is not None: - user.is_staff = (is_staff == "true") - user.save() - - if is_superuser is not None: - user.is_superuser = (is_superuser == "true") - user.save() - - if active_status: - reg.activate() - reg.save() - - # ensure parental consent threshold is met - year = datetime.date.today().year - age_limit = settings.PARENTAL_CONSENT_AGE_LIMIT - profile.year_of_birth = (year - age_limit) - 1 - profile.save() - - # Enroll the user in a course - if course_key is not None: - CourseEnrollment.enroll(user, course_key, mode=enrollment_mode) - - # Apply the roles - for role_name in role_names: - role = Role.objects.get(name=role_name, course_id=course_key) - user.roles.add(role) - - # Log in as the user - if login_when_done: - user = authenticate(username=username, password=password) - login(request, user) - - create_comments_service_user(user) - - # Provide the user with a valid CSRF token - # then return a 200 response unless redirect is true - if redirect_when_done: - # Redirect to specific page if specified - if redirect_to: - redirect_url = redirect_to - # Redirect to course info page if course_id is known - elif course_id: - try: - # redirect to course info page in LMS - redirect_url = reverse( - 'info', - kwargs={'course_id': course_id} - ) - except NoReverseMatch: - # redirect to course outline page in Studio - redirect_url = reverse( - 'course_handler', - kwargs={'course_key_string': course_id} - ) - else: - try: - # redirect to dashboard for LMS - redirect_url = reverse('dashboard') - except NoReverseMatch: - # redirect to home for Studio - redirect_url = reverse('home') - - return redirect(redirect_url) - elif request.META.get('HTTP_ACCEPT') == 'application/json': - response = JsonResponse({ - 'created_status': u"Logged in" if login_when_done else "Created", - 'username': username, - 'email': email, - 'password': password, - 'user_id': user.id, # pylint: disable=no-member - 'anonymous_id': anonymous_id_for_user(user, None), - }) - else: - success_msg = u"{} user {} ({}) with password {} and user_id {}".format( - u"Logged in" if login_when_done else "Created", - username, email, password, user.id # pylint: disable=no-member - ) - response = HttpResponse(success_msg) - response.set_cookie('csrftoken', csrf(request)['csrf_token']) - return response - - -@ensure_csrf_cookie -def activate_account(request, key): - """When link in activation e-mail is clicked""" - regs = Registration.objects.filter(activation_key=key) - if len(regs) == 1: - user_logged_in = request.user.is_authenticated() - already_active = True - if not regs[0].user.is_active: - regs[0].activate() - already_active = False - - # Enroll student in any pending courses he/she may have if auto_enroll flag is set - _enroll_user_in_pending_courses(regs[0].user) - - resp = render_to_response( - "registration/activation_complete.html", - { - 'user_logged_in': user_logged_in, - 'already_active': already_active - } - ) - return resp - if len(regs) == 0: - return render_to_response( - "registration/activation_invalid.html", - {'csrf': csrf(request)['csrf_token']} - ) - return HttpResponseServerError(_("Unknown error. Please e-mail us to let us know how it happened.")) - - -@csrf_exempt -@require_POST -def password_reset(request): - """ Attempts to send a password reset e-mail. """ - # Add some rate limiting here by re-using the RateLimitMixin as a helper class - limiter = BadRequestRateLimiter() - if limiter.is_rate_limit_exceeded(request): - AUDIT_LOG.warning("Rate limit exceeded in password_reset") - return HttpResponseForbidden() - - form = PasswordResetFormNoActive(request.POST) - if form.is_valid(): - form.save(use_https=request.is_secure(), - from_email=configuration_helpers.get_value('email_from_address', settings.DEFAULT_FROM_EMAIL), - request=request, - domain_override=request.get_host()) - # When password change is complete, a "edx.user.settings.changed" event will be emitted. - # But because changing the password is multi-step, we also emit an event here so that we can - # track where the request was initiated. - tracker.emit( - SETTING_CHANGE_INITIATED, - { - "setting": "password", - "old": None, - "new": None, - "user_id": request.user.id, - } - ) - destroy_oauth_tokens(request.user) - else: - # bad user? tick the rate limiter counter - AUDIT_LOG.info("Bad password_reset user passed in.") - limiter.tick_bad_request_counter(request) - - return JsonResponse({ - 'success': True, - 'value': render_to_string('registration/password_reset_done.html', {}), - }) - - -def uidb36_to_uidb64(uidb36): - """ - Needed to support old password reset URLs that use base36-encoded user IDs - https://github.com/django/django/commit/1184d077893ff1bc947e45b00a4d565f3df81776#diff-c571286052438b2e3190f8db8331a92bR231 - Args: - uidb36: base36-encoded user ID - - Returns: base64-encoded user ID. Otherwise returns a dummy, invalid ID - """ - try: - uidb64 = force_text(urlsafe_base64_encode(force_bytes(base36_to_int(uidb36)))) - except ValueError: - uidb64 = '1' # dummy invalid ID (incorrect padding for base64) - return uidb64 - - -def validate_password(user, password): - """ - Tie in password policy enforcement as an optional level of - security protection - - Args: - user: the user object whose password we're checking. - password: the user's proposed new password. - - Returns: - is_valid_password: a boolean indicating if the new password - passes the validation. - err_msg: an error message if there's a violation of one of the password - checks. Otherwise, `None`. - """ - err_msg = None - - if settings.FEATURES.get('ENFORCE_PASSWORD_POLICY', False): - try: - validate_password_strength(password) - except ValidationError as err: - err_msg = _('Password: ') + '; '.join(err.messages) - - # also, check the password reuse policy - if not PasswordHistory.is_allowable_password_reuse(user, password): - if user.is_staff: - num_distinct = settings.ADVANCED_SECURITY_CONFIG['MIN_DIFFERENT_STAFF_PASSWORDS_BEFORE_REUSE'] - else: - num_distinct = settings.ADVANCED_SECURITY_CONFIG['MIN_DIFFERENT_STUDENT_PASSWORDS_BEFORE_REUSE'] - # Because of how ngettext is, splitting the following into shorter lines would be ugly. - # pylint: disable=line-too-long - err_msg = ungettext( - "You are re-using a password that you have used recently. You must have {num} distinct password before reusing a previous password.", - "You are re-using a password that you have used recently. You must have {num} distinct passwords before reusing a previous password.", - num_distinct - ).format(num=num_distinct) - - # also, check to see if passwords are getting reset too frequent - if PasswordHistory.is_password_reset_too_soon(user): - num_days = settings.ADVANCED_SECURITY_CONFIG['MIN_TIME_IN_DAYS_BETWEEN_ALLOWED_RESETS'] - # Because of how ngettext is, splitting the following into shorter lines would be ugly. - # pylint: disable=line-too-long - err_msg = ungettext( - "You are resetting passwords too frequently. Due to security policies, {num} day must elapse between password resets.", - "You are resetting passwords too frequently. Due to security policies, {num} days must elapse between password resets.", - num_days - ).format(num=num_days) - - is_password_valid = err_msg is None - - return is_password_valid, err_msg - - -def password_reset_confirm_wrapper(request, uidb36=None, token=None): - """ - A wrapper around django.contrib.auth.views.password_reset_confirm. - Needed because we want to set the user as active at this step. - We also optionally do some additional password policy checks. - """ - # convert old-style base36-encoded user id to base64 - uidb64 = uidb36_to_uidb64(uidb36) - platform_name = { - "platform_name": configuration_helpers.get_value('platform_name', settings.PLATFORM_NAME) - } - try: - uid_int = base36_to_int(uidb36) - user = User.objects.get(id=uid_int) - except (ValueError, User.DoesNotExist): - # if there's any error getting a user, just let django's - # password_reset_confirm function handle it. - return password_reset_confirm( - request, uidb64=uidb64, token=token, extra_context=platform_name - ) - - if request.method == 'POST': - password = request.POST['new_password1'] - is_password_valid, password_err_msg = validate_password(user, password) - if not is_password_valid: - # We have a password reset attempt which violates some security - # policy. Use the existing Django template to communicate that - # back to the user. - context = { - 'validlink': False, - 'form': None, - 'title': _('Password reset unsuccessful'), - 'err_msg': password_err_msg, - } - context.update(platform_name) - return TemplateResponse( - request, 'registration/password_reset_confirm.html', context - ) - - # remember what the old password hash is before we call down - old_password_hash = user.password - - response = password_reset_confirm( - request, uidb64=uidb64, token=token, extra_context=platform_name - ) - - # If password reset was unsuccessful a template response is returned (status_code 200). - # Check if form is invalid then show an error to the user. - # Note if password reset was successful we get response redirect (status_code 302). - if response.status_code == 200 and not response.context_data['form'].is_valid(): - response.context_data['err_msg'] = _('Error in resetting your password. Please try again.') - return response - - # get the updated user - updated_user = User.objects.get(id=uid_int) - - # did the password hash change, if so record it in the PasswordHistory - if updated_user.password != old_password_hash: - entry = PasswordHistory() - entry.create(updated_user) - - else: - response = password_reset_confirm( - request, uidb64=uidb64, token=token, extra_context=platform_name - ) - - response_was_successful = response.context_data.get('validlink') - if response_was_successful and not user.is_active: - user.is_active = True - user.save() - - return response - - -def reactivation_email_for_user(user): - try: - reg = Registration.objects.get(user=user) - except Registration.DoesNotExist: - return JsonResponse({ - "success": False, - "error": _('No inactive user with this e-mail exists'), - }) # TODO: this should be status code 400 # pylint: disable=fixme - - try: - context = { - 'name': user.profile.name, - 'key': reg.activation_key, - } - except ObjectDoesNotExist: - log.error( - u'Unable to send reactivation email due to unavailable profile for the user "%s"', - user.username, - exc_info=True - ) - return JsonResponse({ - "success": False, - "error": _('Unable to send reactivation email') - }) - - subject = render_to_string('emails/activation_email_subject.txt', context) - subject = ''.join(subject.splitlines()) - message = render_to_string('emails/activation_email.txt', context) - from_address = configuration_helpers.get_value('email_from_address', settings.DEFAULT_FROM_EMAIL) - - try: - user.email_user(subject, message, from_address) - except Exception: # pylint: disable=broad-except - log.error( - u'Unable to send reactivation email from "%s" to "%s"', - from_address, - user.email, - exc_info=True - ) - return JsonResponse({ - "success": False, - "error": _('Unable to send reactivation email') - }) # TODO: this should be status code 500 # pylint: disable=fixme - - return JsonResponse({"success": True}) - - -def validate_new_email(user, new_email): - """ - Given a new email for a user, does some basic verification of the new address If any issues are encountered - with verification a ValueError will be thrown. - """ - try: - validate_email(new_email) - except ValidationError: - raise ValueError(_('Valid e-mail address required.')) - - if new_email == user.email: - raise ValueError(_('Old email is the same as the new email.')) - - if User.objects.filter(email=new_email).count() != 0: - raise ValueError(_('An account with this e-mail already exists.')) - - -def do_email_change_request(user, new_email, activation_key=None): - """ - Given a new email for a user, does some basic verification of the new address and sends an activation message - to the new address. If any issues are encountered with verification or sending the message, a ValueError will - be thrown. - """ - pec_list = PendingEmailChange.objects.filter(user=user) - if len(pec_list) == 0: - pec = PendingEmailChange() - pec.user = user - else: - pec = pec_list[0] - - # if activation_key is not passing as an argument, generate a random key - if not activation_key: - activation_key = uuid.uuid4().hex - - pec.new_email = new_email - pec.activation_key = activation_key - pec.save() - - context = { - 'key': pec.activation_key, - 'old_email': user.email, - 'new_email': pec.new_email - } - - subject = render_to_string('emails/email_change_subject.txt', context) - subject = ''.join(subject.splitlines()) - - message = render_to_string('emails/email_change.txt', context) - - from_address = configuration_helpers.get_value( - 'email_from_address', - settings.DEFAULT_FROM_EMAIL - ) - try: - mail.send_mail(subject, message, from_address, [pec.new_email]) - except Exception: # pylint: disable=broad-except - log.error(u'Unable to send email activation link to user from "%s"', from_address, exc_info=True) - raise ValueError(_('Unable to send email activation link. Please try again later.')) - - # When the email address change is complete, a "edx.user.settings.changed" event will be emitted. - # But because changing the email address is multi-step, we also emit an event here so that we can - # track where the request was initiated. - tracker.emit( - SETTING_CHANGE_INITIATED, - { - "setting": "email", - "old": context['old_email'], - "new": context['new_email'], - "user_id": user.id, - } - ) - - -@ensure_csrf_cookie -def confirm_email_change(request, key): # pylint: disable=unused-argument - """ - User requested a new e-mail. This is called when the activation - link is clicked. We confirm with the old e-mail, and update - """ - with transaction.atomic(): - try: - pec = PendingEmailChange.objects.get(activation_key=key) - except PendingEmailChange.DoesNotExist: - response = render_to_response("invalid_email_key.html", {}) - transaction.set_rollback(True) - return response - - user = pec.user - address_context = { - 'old_email': user.email, - 'new_email': pec.new_email - } - - if len(User.objects.filter(email=pec.new_email)) != 0: - response = render_to_response("email_exists.html", {}) - transaction.set_rollback(True) - return response - - subject = render_to_string('emails/email_change_subject.txt', address_context) - subject = ''.join(subject.splitlines()) - message = render_to_string('emails/confirm_email_change.txt', address_context) - u_prof = UserProfile.objects.get(user=user) - meta = u_prof.get_meta() - if 'old_emails' not in meta: - meta['old_emails'] = [] - meta['old_emails'].append([user.email, datetime.datetime.now(UTC).isoformat()]) - u_prof.set_meta(meta) - u_prof.save() - # Send it to the old email... - try: - user.email_user( - subject, - message, - configuration_helpers.get_value('email_from_address', settings.DEFAULT_FROM_EMAIL) - ) - except Exception: # pylint: disable=broad-except - log.warning('Unable to send confirmation email to old address', exc_info=True) - response = render_to_response("email_change_failed.html", {'email': user.email}) - transaction.set_rollback(True) - return response - - user.email = pec.new_email - user.save() - pec.delete() - # And send it to the new email... - try: - user.email_user( - subject, - message, - configuration_helpers.get_value('email_from_address', settings.DEFAULT_FROM_EMAIL) - ) - except Exception: # pylint: disable=broad-except - log.warning('Unable to send confirmation email to new address', exc_info=True) - response = render_to_response("email_change_failed.html", {'email': pec.new_email}) - transaction.set_rollback(True) - return response - - response = render_to_response("email_change_successful.html", address_context) - return response - - -@require_POST -@login_required -@ensure_csrf_cookie -def change_email_settings(request): - """Modify logged-in user's setting for receiving emails from a course.""" - user = request.user - - course_id = request.POST.get("course_id") - course_key = SlashSeparatedCourseKey.from_deprecated_string(course_id) - receive_emails = request.POST.get("receive_emails") - if receive_emails: - optout_object = Optout.objects.filter(user=user, course_id=course_key) - if optout_object: - optout_object.delete() - log.info( - u"User %s (%s) opted in to receive emails from course %s", - user.username, - user.email, - course_id, - ) - track.views.server_track( - request, - "change-email-settings", - {"receive_emails": "yes", "course": course_id}, - page='dashboard', - ) - else: - Optout.objects.get_or_create(user=user, course_id=course_key) - log.info( - u"User %s (%s) opted out of receiving emails from course %s", - user.username, - user.email, - course_id, - ) - track.views.server_track( - request, - "change-email-settings", - {"receive_emails": "no", "course": course_id}, - page='dashboard', - ) - - return JsonResponse({"success": True}) - - -class LogoutView(TemplateView): - """ - Logs out user and redirects. - - The template should load iframes to log the user out of OpenID Connect services. - See http://openid.net/specs/openid-connect-logout-1_0.html. - """ - oauth_client_ids = [] - template_name = 'logout.html' - - # Keep track of the page to which the user should ultimately be redirected. - default_target = reverse_lazy('cas-logout') if settings.FEATURES.get('AUTH_USE_CAS') else '/' - - @property - def target(self): - """ - If a redirect_url is specified in the querystring for this request, and the value is a url - with the same host, the view will redirect to this page after rendering the template. - If it is not specified, we will use the default target url. - """ - target_url = self.request.GET.get('redirect_url') - - if target_url and is_safe_url(target_url, self.request.META.get('HTTP_HOST')): - return target_url - else: - return self.default_target - - def dispatch(self, request, *args, **kwargs): # pylint: disable=missing-docstring - # We do not log here, because we have a handler registered to perform logging on successful logouts. - request.is_from_logout = True - - # Get the list of authorized clients before we clear the session. - self.oauth_client_ids = request.session.get(edx_oauth2_provider.constants.AUTHORIZED_CLIENTS_SESSION_KEY, []) - - logout(request) - - # If we don't need to deal with OIDC logouts, just redirect the user. - if LogoutViewConfiguration.current().enabled and self.oauth_client_ids: - response = super(LogoutView, self).dispatch(request, *args, **kwargs) - else: - response = redirect(self.target) - - # Clear the cookie used by the edx.org marketing site - delete_logged_in_cookies(response) - - return response - - def _build_logout_url(self, url): - """ - Builds a logout URL with the `no_redirect` query string parameter. - - Args: - url (str): IDA logout URL - - Returns: - str - """ - scheme, netloc, path, query_string, fragment = urlsplit(url) - query_params = parse_qs(query_string) - query_params['no_redirect'] = 1 - new_query_string = urlencode(query_params, doseq=True) - return urlunsplit((scheme, netloc, path, new_query_string, fragment)) - - def get_context_data(self, **kwargs): - context = super(LogoutView, self).get_context_data(**kwargs) - - # Create a list of URIs that must be called to log the user out of all of the IDAs. - uris = Client.objects.filter(client_id__in=self.oauth_client_ids, - logout_uri__isnull=False).values_list('logout_uri', flat=True) - - referrer = self.request.META.get('HTTP_REFERER', '').strip('/') - logout_uris = [] - - for uri in uris: - if not referrer or (referrer and not uri.startswith(referrer)): - logout_uris.append(self._build_logout_url(uri)) - - context.update({ - 'target': self.target, - 'logout_uris': logout_uris, - }) - - return context diff --git a/modified/lms.djangoapps.courseware.views.views.py b/modified/lms.djangoapps.courseware.views.views.py deleted file mode 100644 index 8e42972..0000000 --- a/modified/lms.djangoapps.courseware.views.views.py +++ /dev/null @@ -1,1635 +0,0 @@ -""" -Courseware views functions -""" -import json -import logging -import urllib -from collections import OrderedDict, namedtuple -from datetime import datetime - -import analytics -from django.conf import settings -from django.contrib.auth.decorators import login_required -from django.contrib.auth.models import User, AnonymousUser -from django.core.exceptions import PermissionDenied - -from django.core.urlresolvers import reverse -from django.core.context_processors import csrf -from django.db import transaction -from django.db.models import Q -from django.http import ( - Http404, - HttpResponse, - HttpResponseBadRequest, - HttpResponseForbidden, - QueryDict, -) -from django.shortcuts import redirect -from django.utils.decorators import method_decorator -from django.utils.timezone import UTC -from django.utils.translation import ugettext as _ -from django.views.decorators.cache import cache_control -from django.views.decorators.csrf import ensure_csrf_cookie -from django.views.decorators.http import require_GET, require_POST, require_http_methods -from django.views.generic import View -from eventtracking import tracker -from ipware.ip import get_ip -from markupsafe import escape -from opaque_keys import InvalidKeyError -from opaque_keys.edx.keys import CourseKey, UsageKey -from rest_framework import status -from lms.djangoapps.ccx.custom_exception import CCXLocatorValidationException -from lms.djangoapps.ccx.utils import prep_course_for_grading -from lms.djangoapps.courseware.exceptions import CourseAccessRedirect, Redirect -from lms.djangoapps.grades.new.course_grade_factory import CourseGradeFactory -from lms.djangoapps.instructor.enrollment import uses_shib -from lms.djangoapps.instructor.views.api import require_global_staff -from lms.djangoapps.verify_student.models import SoftwareSecurePhotoVerification - -import shoppingcart -import survey.utils -import survey.views -from certificates import api as certs_api -from certificates.models import CertificateStatuses -from openedx.core.djangoapps.models.course_details import CourseDetails -from openedx.core.djangoapps.plugin_api.views import EdxFragmentView -from commerce.utils import EcommerceService -from enrollment.api import add_enrollment -from course_modes.models import CourseMode -from courseware.access import has_access, has_ccx_coach_role, _adjust_start_date_for_beta_testers -from courseware.access_response import StartDateError -from courseware.access_utils import in_preview_mode -from courseware.courses import ( - get_courses, - get_course, - get_course_by_id, - get_course_overview_with_access, - get_course_with_access, - get_last_accessed_courseware, - get_permission_for_course_about, - get_studio_url, - sort_by_announcement, - sort_by_start_date, -) -from courseware.date_summary import VerifiedUpgradeDeadlineDate -from courseware.masquerade import setup_masquerade -from courseware.model_data import FieldDataCache -from courseware.models import StudentModule, BaseStudentModuleHistory -from courseware.url_helpers import get_redirect_url -from courseware.user_state_client import DjangoXBlockUserStateClient -from edxmako.shortcuts import render_to_response, render_to_string, marketing_link -from openedx.core.djangoapps.catalog.utils import get_programs, get_programs_with_type -from openedx.core.djangoapps.content.course_overviews.models import CourseOverview -from openedx.core.djangoapps.coursetalk.helpers import inject_coursetalk_keys_into_context -from openedx.core.djangoapps.credit.api import ( - get_credit_requirement_status, - is_user_eligible_for_credit, - is_credit_course -) -from openedx.core.djangoapps.programs.utils import ProgramMarketingDataExtender -from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers -from openedx.core.djangoapps.self_paced.models import SelfPacedConfiguration -from openedx.core.djangoapps.monitoring_utils import set_custom_metrics_for_course_key -from openedx.features.enterprise_support.api import data_sharing_consent_required -from shoppingcart.models import CourseRegistrationCode -from shoppingcart.utils import is_shopping_cart_enabled -from student.models import UserTestGroup, CourseEnrollment -from student.roles import GlobalStaff -from survey.utils import must_answer_survey -from util.cache import cache, cache_if_anonymous -from util.date_utils import strftime_localized -from util.db import outer_atomic -from util.milestones_helpers import get_prerequisite_courses_display -from util.views import _record_feedback_in_zendesk -from util.views import ensure_valid_course_key, ensure_valid_usage_key -from xmodule.modulestore.django import modulestore -from xmodule.modulestore.exceptions import ItemNotFoundError, NoPathToItem -from xmodule.tabs import CourseTabList -from xmodule.x_module import STUDENT_VIEW -from web_fragments.fragment import Fragment - -from ..entrance_exams import user_can_skip_entrance_exam -from ..module_render import get_module_for_descriptor, get_module, get_module_by_usage_id -from course_shifts import get_course_active_shifts_json - -log = logging.getLogger("edx.courseware") - - -# Only display the requirements on learner dashboard for -# credit and verified modes. -REQUIREMENTS_DISPLAY_MODES = CourseMode.CREDIT_MODES + [CourseMode.VERIFIED] - -CertData = namedtuple("CertData", ["cert_status", "title", "msg", "download_url", "cert_web_view_url"]) - - -def user_groups(user): - """ - TODO (vshnayder): This is not used. When we have a new plan for groups, adjust appropriately. - """ - if not user.is_authenticated(): - return [] - - # TODO: Rewrite in Django - key = 'user_group_names_{user.id}'.format(user=user) - cache_expiration = 60 * 60 # one hour - - # Kill caching on dev machines -- we switch groups a lot - group_names = cache.get(key) # pylint: disable=no-member - if settings.DEBUG: - group_names = None - - if group_names is None: - group_names = [u.name for u in UserTestGroup.objects.filter(users=user)] - cache.set(key, group_names, cache_expiration) # pylint: disable=no-member - - return group_names - - -@ensure_csrf_cookie -@cache_if_anonymous() -def courses(request): - """ - Render "find courses" page. The course selection work is done in courseware.courses. - """ - courses_list = [] - programs_list = [] - course_discovery_meanings = getattr(settings, 'COURSE_DISCOVERY_MEANINGS', {}) - if not settings.FEATURES.get('ENABLE_COURSE_DISCOVERY'): - courses_list = get_courses(request.user) - - if configuration_helpers.get_value("ENABLE_COURSE_SORTING_BY_START_DATE", - settings.FEATURES["ENABLE_COURSE_SORTING_BY_START_DATE"]): - courses_list = sort_by_start_date(courses_list) - else: - courses_list = sort_by_announcement(courses_list) - - # Get the active programs of the type configured for the current site from the catalog service. The programs_list - # is being added to the context but it's not being used currently in courseware/courses.html. To use this list, - # you need to create a custom theme that overrides courses.html. The modifications to courses.html to display the - # programs will be done after the support for edx-pattern-library is added. - program_types = configuration_helpers.get_value('ENABLED_PROGRAM_TYPES') - - # Do not add programs to the context if there are no program types enabled for the site. - if program_types: - programs_list = get_programs_with_type(program_types) - - return render_to_response( - "courseware/courses.html", - { - 'courses': courses_list, - 'course_discovery_meanings': course_discovery_meanings, - 'programs_list': programs_list - } - ) - - -@ensure_csrf_cookie -@ensure_valid_course_key -def jump_to_id(request, course_id, module_id): - """ - This entry point allows for a shorter version of a jump to where just the id of the element is - passed in. This assumes that id is unique within the course_id namespace - """ - course_key = CourseKey.from_string(course_id) - items = modulestore().get_items(course_key, qualifiers={'name': module_id}) - - if len(items) == 0: - raise Http404( - u"Could not find id: {0} in course_id: {1}. Referer: {2}".format( - module_id, course_id, request.META.get("HTTP_REFERER", "") - )) - if len(items) > 1: - log.warning( - u"Multiple items found with id: %s in course_id: %s. Referer: %s. Using first: %s", - module_id, - course_id, - request.META.get("HTTP_REFERER", ""), - items[0].location.to_deprecated_string() - ) - - return jump_to(request, course_id, items[0].location.to_deprecated_string()) - - -@ensure_csrf_cookie -def jump_to(_request, course_id, location): - """ - Show the page that contains a specific location. - - If the location is invalid or not in any class, return a 404. - - Otherwise, delegates to the index view to figure out whether this user - has access, and what they should see. - """ - try: - course_key = CourseKey.from_string(course_id) - usage_key = UsageKey.from_string(location).replace(course_key=course_key) - except InvalidKeyError: - raise Http404(u"Invalid course_key or usage_key") - try: - redirect_url = get_redirect_url(course_key, usage_key) - except ItemNotFoundError: - raise Http404(u"No data at this location: {0}".format(usage_key)) - except NoPathToItem: - raise Http404(u"This location is not in any class: {0}".format(usage_key)) - - return redirect(redirect_url) - - -@ensure_csrf_cookie -@ensure_valid_course_key -@data_sharing_consent_required -def course_info(request, course_id): - """ - Display the course's info.html, or 404 if there is no such course. - - Assumes the course_id is in a valid format. - """ - course_key = CourseKey.from_string(course_id) - with modulestore().bulk_operations(course_key): - course = get_course_by_id(course_key, depth=2) - access_response = has_access(request.user, 'load', course, course_key) - - if not access_response: - - # The user doesn't have access to the course. If they're - # denied permission due to the course not being live yet, - # redirect to the dashboard page. - if isinstance(access_response, StartDateError): - start_date = strftime_localized(course.start, 'SHORT_DATE') - params = QueryDict(mutable=True) - params['notlive'] = start_date - return redirect('{dashboard_url}?{params}'.format( - dashboard_url=reverse('dashboard'), - params=params.urlencode() - )) - # Otherwise, give a 404 to avoid leaking info about access - # control. - raise Http404("Course not found.") - - staff_access = has_access(request.user, 'staff', course) - masquerade, user = setup_masquerade(request, course_key, staff_access, reset_masquerade_data=True) - - # if user is not enrolled in a course then app will show enroll/get register link inside course info page. - show_enroll_banner = request.user.is_authenticated() and not CourseEnrollment.is_enrolled(user, course.id) - if show_enroll_banner and hasattr(course_key, 'ccx'): - # if course is CCX and user is not enrolled/registered then do not let him open course direct via link for - # self registration. Because only CCX coach can register/enroll a student. If un-enrolled user try - # to access CCX redirect him to dashboard. - return redirect(reverse('dashboard')) - - # Redirect the user if they are not yet allowed to view this course - check_access_to_course(request, course) - - # If the user needs to take an entrance exam to access this course, then we'll need - # to send them to that specific course module before allowing them into other areas - if not user_can_skip_entrance_exam(user, course): - return redirect(reverse('courseware', args=[unicode(course.id)])) - - # If the user is coming from the dashboard and bypass_home setting is set, - # redirect them straight to the courseware page. - is_from_dashboard = reverse('dashboard') in request.META.get('HTTP_REFERER', []) - if course.bypass_home and is_from_dashboard: - return redirect(reverse('courseware', args=[course_id])) - - # link to where the student should go to enroll in the course: - # about page if there is not marketing site, SITE_NAME if there is - url_to_enroll = reverse(course_about, args=[course_id]) - if settings.FEATURES.get('ENABLE_MKTG_SITE'): - url_to_enroll = marketing_link('COURSES') - - store_upgrade_cookie = False - upgrade_cookie_name = 'show_upgrade_notification' - upgrade_link = None - if request.user.is_authenticated(): - show_upgrade_notification = False - if request.GET.get('upgrade', 'false') == 'true': - store_upgrade_cookie = True - show_upgrade_notification = True - elif upgrade_cookie_name in request.COOKIES and course_id in request.COOKIES[upgrade_cookie_name]: - show_upgrade_notification = True - - if show_upgrade_notification: - upgrade_data = VerifiedUpgradeDeadlineDate(course, user) - if upgrade_data.is_enabled: - upgrade_link = upgrade_data.link - else: - # The upgrade is not enabled so the cookie does not need to be stored - store_upgrade_cookie = False - - context = { - 'request': request, - 'masquerade_user': user, - 'course_id': course_key.to_deprecated_string(), - 'cache': None, - 'course': course, - 'staff_access': staff_access, - 'masquerade': masquerade, - 'supports_preview_menu': True, - 'studio_url': get_studio_url(course, 'course_info'), - 'show_enroll_banner': show_enroll_banner, - 'url_to_enroll': url_to_enroll, - 'upgrade_link': upgrade_link, - } - - # Get the URL of the user's last position in order to display the 'where you were last' message - context['last_accessed_courseware_url'] = None - if SelfPacedConfiguration.current().enable_course_home_improvements: - context['last_accessed_courseware_url'], _ = get_last_accessed_courseware(course, request, user) - - now = datetime.now(UTC()) - effective_start = _adjust_start_date_for_beta_testers(user, course, course_key) - if not in_preview_mode() and staff_access and now < effective_start: - # Disable student view button if user is staff and - # course is not yet visible to students. - context['disable_student_access'] = True - - if CourseEnrollment.is_enrolled(request.user, course.id): - inject_coursetalk_keys_into_context(context, course_key) - - response = render_to_response('courseware/info.html', context) - if store_upgrade_cookie: - if upgrade_cookie_name in request.COOKIES and str(course_id) not in request.COOKIES[upgrade_cookie_name]: - cookie_value = '%s,%s' % (course_id, request.COOKIES[upgrade_cookie_name]) - elif upgrade_cookie_name in request.COOKIES and str(course_id) in request.COOKIES[upgrade_cookie_name]: - cookie_value = request.COOKIES[upgrade_cookie_name] - else: - cookie_value = course_id - - if cookie_value is not None: - response.set_cookie( - upgrade_cookie_name, - cookie_value, - max_age=10 * 24 * 60 * 60, # set for 10 days - domain=settings.SESSION_COOKIE_DOMAIN, - httponly=True # no use case for accessing from JavaScript - ) - - return response - - -class StaticCourseTabView(EdxFragmentView): - """ - View that displays a static course tab with a given name. - """ - @method_decorator(ensure_csrf_cookie) - @method_decorator(ensure_valid_course_key) - def get(self, request, course_id, tab_slug, **kwargs): - """ - Displays a static course tab page with a given name - """ - course_key = CourseKey.from_string(course_id) - course = get_course_with_access(request.user, 'load', course_key) - tab = CourseTabList.get_tab_by_slug(course.tabs, tab_slug) - if tab is None: - raise Http404 - return super(StaticCourseTabView, self).get(request, course=course, tab=tab, **kwargs) - - def render_to_fragment(self, request, course=None, tab=None, **kwargs): - """ - Renders the static tab to a fragment. - """ - return get_static_tab_fragment(request, course, tab) - - def render_to_standalone_html(self, request, fragment, course=None, tab=None, **kwargs): - """ - Renders this static tab's fragment to HTML for a standalone page. - """ - return render_to_response('courseware/static_tab.html', { - 'course': course, - 'active_page': 'static_tab_{0}'.format(tab['url_slug']), - 'tab': tab, - 'fragment': fragment, - 'uses_pattern_library': False, - 'disable_courseware_js': True, - }) - - -class CourseTabView(EdxFragmentView): - """ - View that displays a course tab page. - """ - @method_decorator(ensure_csrf_cookie) - @method_decorator(ensure_valid_course_key) - @method_decorator(data_sharing_consent_required) - def get(self, request, course_id, tab_type, **kwargs): - """ - Displays a course tab page that contains a web fragment. - """ - course_key = CourseKey.from_string(course_id) - with modulestore().bulk_operations(course_key): - course = get_course_with_access(request.user, 'load', course_key) - try: - # Verify that the user has access to the course - check_access_to_course(request, course) - - # Render the page - tab = CourseTabList.get_tab_by_type(course.tabs, tab_type) - page_context = self.create_page_context(request, course=course, tab=tab, **kwargs) - set_custom_metrics_for_course_key(course_key) - return super(CourseTabView, self).get(request, course=course, page_context=page_context, **kwargs) - except Exception as exception: # pylint: disable=broad-except - return CourseTabView.handle_exceptions(request, course, exception) - - @staticmethod - def handle_exceptions(request, course, exception): - """ - Handle exceptions raised when rendering a view. - """ - if isinstance(exception, Redirect) or isinstance(exception, Http404): - raise - if isinstance(exception, UnicodeEncodeError): - raise Http404("URL contains Unicode characters") - if settings.DEBUG: - raise - user = request.user - log.exception( - u"Error in %s: user=%s, effective_user=%s, course=%s", - request.path, - getattr(user, 'real_user', user), - user, - unicode(course.id), - ) - try: - return render_to_response( - 'courseware/courseware-error.html', - { - 'staff_access': has_access(user, 'staff', course), - 'course': course, - }, - status=500, - ) - except: - # Let the exception propagate, relying on global config to - # at least return a nice error message - log.exception("Error while rendering courseware-error page") - raise - - def create_page_context(self, request, course=None, tab=None, **kwargs): - """ - Creates the context for the fragment's template. - """ - staff_access = has_access(request.user, 'staff', course) - supports_preview_menu = tab.get('supports_preview_menu', False) - if supports_preview_menu: - masquerade, masquerade_user = setup_masquerade(request, course.id, staff_access, reset_masquerade_data=True) - request.user = masquerade_user - else: - masquerade = None - return { - 'course': course, - 'tab': tab, - 'active_page': tab.get('type', None), - 'staff_access': staff_access, - 'masquerade': masquerade, - 'supports_preview_menu': supports_preview_menu, - 'uses_pattern_library': True, - 'disable_courseware_js': True, - } - - def render_to_fragment(self, request, course=None, page_context=None, **kwargs): - """ - Renders the course tab to a fragment. - """ - tab = page_context['tab'] - return tab.render_to_fragment(request, course, **kwargs) - - def render_to_standalone_html(self, request, fragment, course=None, tab=None, page_context=None, **kwargs): - """ - Renders this course tab's fragment to HTML for a standalone page. - """ - if not page_context: - page_context = self.create_page_context(request, course=course, tab=tab, **kwargs) - page_context['fragment'] = fragment - return render_to_string('courseware/tab-view.html', page_context) - - -@ensure_csrf_cookie -@ensure_valid_course_key -def syllabus(request, course_id): - """ - Display the course's syllabus.html, or 404 if there is no such course. - - Assumes the course_id is in a valid format. - """ - - course_key = CourseKey.from_string(course_id) - - course = get_course_with_access(request.user, 'load', course_key) - staff_access = bool(has_access(request.user, 'staff', course)) - - return render_to_response('courseware/syllabus.html', { - 'course': course, - 'staff_access': staff_access, - }) - - -def registered_for_course(course, user): - """ - Return True if user is registered for course, else False - """ - if user is None: - return False - if user.is_authenticated(): - return CourseEnrollment.is_enrolled(user, course.id) - else: - return False - - -def get_cosmetic_display_price(course, registration_price): - """ - Return Course Price as a string preceded by correct currency, or 'Free' - """ - currency_symbol = settings.PAID_COURSE_REGISTRATION_CURRENCY[1] - - price = course.cosmetic_display_price - if registration_price > 0: - price = registration_price - - if price: - # Translators: This will look like '$50', where {currency_symbol} is a symbol such as '$' and {price} is a - # numerical amount in that currency. Adjust this display as needed for your language. - return _("{currency_symbol}{price}").format(currency_symbol=currency_symbol, price=price) - else: - # Translators: This refers to the cost of the course. In this case, the course costs nothing so it is free. - return _('Free') - - -class EnrollStaffView(View): - """ - Displays view for registering in the course to a global staff user. - - User can either choose to 'Enroll' or 'Don't Enroll' in the course. - Enroll: Enrolls user in course and redirects to the courseware. - Don't Enroll: Redirects user to course about page. - - Arguments: - - request : HTTP request - - course_id : course id - - Returns: - - RedirectResponse - """ - template_name = 'enroll_staff.html' - - @method_decorator(require_global_staff) - @method_decorator(ensure_valid_course_key) - def get(self, request, course_id): - """ - Display enroll staff view to global staff user with `Enroll` and `Don't Enroll` options. - """ - user = request.user - course_key = CourseKey.from_string(course_id) - with modulestore().bulk_operations(course_key): - course = get_course_with_access(user, 'load', course_key) - if not registered_for_course(course, user): - context = { - 'course': course, - 'csrftoken': csrf(request)["csrf_token"] - } - return render_to_response(self.template_name, context) - - @method_decorator(require_global_staff) - @method_decorator(ensure_valid_course_key) - def post(self, request, course_id): - """ - Either enrolls the user in course or redirects user to course about page - depending upon the option (Enroll, Don't Enroll) chosen by the user. - """ - _next = urllib.quote_plus(request.GET.get('next', 'info'), safe='/:?=') - course_key = CourseKey.from_string(course_id) - enroll = 'enroll' in request.POST - if enroll: - add_enrollment(request.user.username, course_id) - log.info( - u"User %s enrolled in %s via `enroll_staff` view", - request.user.username, - course_id - ) - return redirect(_next) - - # In any other case redirect to the course about page. - return redirect(reverse('about_course', args=[unicode(course_key)])) - - -@ensure_csrf_cookie -@ensure_valid_course_key -@cache_if_anonymous() -def course_about(request, course_id): - """ - Display the course's about page. - """ - - course_key = CourseKey.from_string(course_id) - - if hasattr(course_key, 'ccx'): - # if un-enrolled/non-registered user try to access CCX (direct for registration) - # then do not show him about page to avoid self registration. - # Note: About page will only be shown to user who is not register. So that he can register. But for - # CCX only CCX coach can enroll students. - return redirect(reverse('dashboard')) - - with modulestore().bulk_operations(course_key): - permission = get_permission_for_course_about() - course = get_course_with_access(request.user, permission, course_key) - course_details = CourseDetails.populate(course) - modes = CourseMode.modes_for_course_dict(course_key) - - if configuration_helpers.get_value('ENABLE_MKTG_SITE', settings.FEATURES.get('ENABLE_MKTG_SITE', False)): - return redirect(reverse('info', args=[course.id.to_deprecated_string()])) - - registered = registered_for_course(course, request.user) - - staff_access = bool(has_access(request.user, 'staff', course)) - studio_url = get_studio_url(course, 'settings/details') - - if has_access(request.user, 'load', course): - course_target = reverse('info', args=[course.id.to_deprecated_string()]) - else: - course_target = reverse('about_course', args=[course.id.to_deprecated_string()]) - - show_courseware_link = bool( - ( - has_access(request.user, 'load', course) and - has_access(request.user, 'view_courseware_with_prerequisites', course) - ) or settings.FEATURES.get('ENABLE_LMS_MIGRATION') - ) - - # Note: this is a flow for payment for course registration, not the Verified Certificate flow. - in_cart = False - reg_then_add_to_cart_link = "" - - _is_shopping_cart_enabled = is_shopping_cart_enabled() - if _is_shopping_cart_enabled: - if request.user.is_authenticated(): - cart = shoppingcart.models.Order.get_cart_for_user(request.user) - in_cart = shoppingcart.models.PaidCourseRegistration.contained_in_order(cart, course_key) or \ - shoppingcart.models.CourseRegCodeItem.contained_in_order(cart, course_key) - - reg_then_add_to_cart_link = "{reg_url}?course_id={course_id}&enrollment_action=add_to_cart".format( - reg_url=reverse('register_user'), course_id=urllib.quote(str(course_id)) - ) - - # If the ecommerce checkout flow is enabled and the mode of the course is - # professional or no id professional, we construct links for the enrollment - # button to add the course to the ecommerce basket. - ecomm_service = EcommerceService() - ecommerce_checkout = ecomm_service.is_enabled(request.user) - ecommerce_checkout_link = '' - ecommerce_bulk_checkout_link = '' - professional_mode = None - is_professional_mode = CourseMode.PROFESSIONAL in modes or CourseMode.NO_ID_PROFESSIONAL_MODE in modes - if ecommerce_checkout and is_professional_mode: - professional_mode = modes.get(CourseMode.PROFESSIONAL, '') or \ - modes.get(CourseMode.NO_ID_PROFESSIONAL_MODE, '') - if professional_mode.sku: - ecommerce_checkout_link = ecomm_service.checkout_page_url(professional_mode.sku) - if professional_mode.bulk_sku: - ecommerce_bulk_checkout_link = ecomm_service.checkout_page_url(professional_mode.bulk_sku) - - # Find the minimum price for the course across all course modes - registration_price = CourseMode.min_course_price_for_currency( - course_key, - settings.PAID_COURSE_REGISTRATION_CURRENCY[0] - ) - course_price = get_cosmetic_display_price(course, registration_price) - - # Determine which checkout workflow to use -- LMS shoppingcart or Otto basket - can_add_course_to_cart = _is_shopping_cart_enabled and registration_price and not ecommerce_checkout_link - - # Used to provide context to message to student if enrollment not allowed - can_enroll = bool(has_access(request.user, 'enroll', course)) - invitation_only = course.invitation_only - is_course_full = CourseEnrollment.objects.is_course_full(course) - - # Register button should be disabled if one of the following is true: - # - Student is already registered for course - # - Course is already full - # - Student cannot enroll in course - active_reg_button = not(registered or is_course_full or not can_enroll) - - is_shib_course = uses_shib(course) - - # get prerequisite courses display names - pre_requisite_courses = get_prerequisite_courses_display(course) - - # Overview - overview = CourseOverview.get_from_id(course.id) - active_shifts_json = get_course_active_shifts_json(course.id) - - context = { - 'course_shifts': active_shifts_json, - 'course': course, - 'course_details': course_details, - 'staff_access': staff_access, - 'studio_url': studio_url, - 'registered': registered, - 'course_target': course_target, - 'is_cosmetic_price_enabled': settings.FEATURES.get('ENABLE_COSMETIC_DISPLAY_PRICE'), - 'course_price': course_price, - 'in_cart': in_cart, - 'ecommerce_checkout': ecommerce_checkout, - 'ecommerce_checkout_link': ecommerce_checkout_link, - 'ecommerce_bulk_checkout_link': ecommerce_bulk_checkout_link, - 'professional_mode': professional_mode, - 'reg_then_add_to_cart_link': reg_then_add_to_cart_link, - 'show_courseware_link': show_courseware_link, - 'is_course_full': is_course_full, - 'can_enroll': can_enroll, - 'invitation_only': invitation_only, - 'active_reg_button': active_reg_button, - 'is_shib_course': is_shib_course, - # We do not want to display the internal courseware header, which is used when the course is found in the - # context. This value is therefor explicitly set to render the appropriate header. - 'disable_courseware_header': True, - 'can_add_course_to_cart': can_add_course_to_cart, - 'cart_link': reverse('shoppingcart.views.show_cart'), - 'pre_requisite_courses': pre_requisite_courses, - 'course_image_urls': overview.image_urls, - } - inject_coursetalk_keys_into_context(context, course_key) - - return render_to_response('courseware/course_about.html', context) - - -@ensure_csrf_cookie -@cache_if_anonymous() -def program_marketing(request, program_uuid): - """ - Display the program marketing page. - """ - program_data = get_programs(uuid=program_uuid) - - if not program_data: - raise Http404 - - return render_to_response('courseware/program_marketing.html', { - 'program': ProgramMarketingDataExtender(program_data, request.user).extend() - }) - - -@transaction.non_atomic_requests -@login_required -@cache_control(no_cache=True, no_store=True, must_revalidate=True) -@ensure_valid_course_key -@data_sharing_consent_required -def progress(request, course_id, student_id=None): - """ Display the progress page. """ - course_key = CourseKey.from_string(course_id) - - with modulestore().bulk_operations(course_key): - return _progress(request, course_key, student_id) - - -def _progress(request, course_key, student_id): - """ - Unwrapped version of "progress". - - User progress. We show the grade bar and every problem score. - - Course staff are allowed to see the progress of students in their class. - """ - - if student_id is not None: - try: - student_id = int(student_id) - # Check for ValueError if 'student_id' cannot be converted to integer. - except ValueError: - raise Http404 - - course = get_course_with_access(request.user, 'load', course_key) - - staff_access = bool(has_access(request.user, 'staff', course)) - - masquerade = None - if student_id is None or student_id == request.user.id: - # This will be a no-op for non-staff users, returning request.user - masquerade, student = setup_masquerade(request, course_key, staff_access, reset_masquerade_data=True) - else: - try: - coach_access = has_ccx_coach_role(request.user, course_key) - except CCXLocatorValidationException: - coach_access = False - - has_access_on_students_profiles = staff_access or coach_access - # Requesting access to a different student's profile - if not has_access_on_students_profiles: - raise Http404 - try: - student = User.objects.get(id=student_id) - except User.DoesNotExist: - raise Http404 - - # NOTE: To make sure impersonation by instructor works, use - # student instead of request.user in the rest of the function. - - # Redirect the user if they are not yet allowed to view this course - check_access_to_course(request, course) - - # The pre-fetching of groups is done to make auth checks not require an - # additional DB lookup (this kills the Progress page in particular). - student = User.objects.prefetch_related("groups").get(id=student.id) - if request.user.id != student.id: - # refetch the course as the assumed student - course = get_course_with_access(student, 'load', course_key, check_if_enrolled=True) - prep_course_for_grading(course, request) - - # NOTE: To make sure impersonation by instructor works, use - # student instead of request.user in the rest of the function. - - course_grade = CourseGradeFactory().create(student, course) - courseware_summary = course_grade.chapter_grades.values() - grade_summary = course_grade.summary - - studio_url = get_studio_url(course, 'settings/grading') - - # checking certificate generation configuration - enrollment_mode, is_active = CourseEnrollment.enrollment_mode_for_user(student, course_key) - - context = { - 'course': course, - 'courseware_summary': courseware_summary, - 'studio_url': studio_url, - 'grade_summary': grade_summary, - 'staff_access': staff_access, - 'masquerade': masquerade, - 'supports_preview_menu': True, - 'student': student, - 'passed': is_course_passed(course, grade_summary), - 'credit_course_requirements': _credit_course_requirements(course_key, student), - 'certificate_data': _get_cert_data(student, course, course_key, is_active, enrollment_mode), - } - - with outer_atomic(): - response = render_to_response('courseware/progress.html', context) - - return response - - -def _get_cert_data(student, course, course_key, is_active, enrollment_mode): - """Returns students course certificate related data. - - Arguments: - student (User): Student for whom certificate to retrieve. - course (Course): Course object for which certificate data to retrieve. - course_key (CourseKey): Course identifier for course. - is_active (Bool): Boolean value to check if course is active. - enrollment_mode (String): Course mode in which student is enrolled. - - Returns: - returns dict if course certificate is available else None. - """ - - if enrollment_mode == CourseMode.AUDIT: - return CertData( - CertificateStatuses.audit_passing, - _('Your enrollment: Audit track'), - _('You are enrolled in the audit track for this course. The audit track does not include a certificate.'), - download_url=None, - cert_web_view_url=None - ) - - show_generate_cert_btn = ( - is_active and CourseMode.is_eligible_for_certificate(enrollment_mode) - and certs_api.cert_generation_enabled(course_key) - ) - - if not show_generate_cert_btn: - return None - - if certs_api.is_certificate_invalid(student, course_key): - return CertData( - CertificateStatuses.invalidated, - _('Your certificate has been invalidated'), - _('Please contact your course team if you have any questions.'), - download_url=None, - cert_web_view_url=None - ) - - cert_downloadable_status = certs_api.certificate_downloadable_status(student, course_key) - - if cert_downloadable_status['is_downloadable']: - cert_status = CertificateStatuses.downloadable - title = _('Your certificate is available') - msg = _('You can keep working for a higher grade, or request your certificate now.') - if certs_api.has_html_certificates_enabled(course_key, course): - if certs_api.get_active_web_certificate(course) is not None: - cert_web_view_url = certs_api.get_certificate_url( - course_id=course_key, uuid=cert_downloadable_status['uuid'] - ) - return CertData(cert_status, title, msg, download_url=None, cert_web_view_url=cert_web_view_url) - else: - return CertData( - CertificateStatuses.generating, - _("We're working on it..."), - _( - "We're creating your certificate. You can keep working in your courses and a link " - "to it will appear here and on your Dashboard when it is ready." - ), - download_url=None, - cert_web_view_url=None - ) - - return CertData( - cert_status, title, msg, download_url=cert_downloadable_status['download_url'], cert_web_view_url=None - ) - - if cert_downloadable_status['is_generating']: - return CertData( - CertificateStatuses.generating, - _("We're working on it..."), - _( - "We're creating your certificate. You can keep working in your courses and a link to " - "it will appear here and on your Dashboard when it is ready." - ), - download_url=None, - cert_web_view_url=None - ) - - # If the learner is in verified modes and the student did not have - # their ID verified, we need to show message to ask learner to verify their ID first - missing_required_verification = enrollment_mode in CourseMode.VERIFIED_MODES and \ - not SoftwareSecurePhotoVerification.user_is_verified(student) - - if missing_required_verification or cert_downloadable_status['is_unverified']: - platform_name = configuration_helpers.get_value('PLATFORM_NAME', settings.PLATFORM_NAME) - return CertData( - CertificateStatuses.unverified, - _('Certificate unavailable'), - _( - 'You have not received a certificate because you do not have a current {platform_name} ' - 'verified identity.' - ).format(platform_name=platform_name), - download_url=None, - cert_web_view_url=None - ) - - return CertData( - CertificateStatuses.requesting, - _('Congratulations, you qualified for a certificate!'), - _('You can keep working for a higher grade, or request your certificate now.'), - download_url=None, - cert_web_view_url=None - ) - - -def _credit_course_requirements(course_key, student): - """Return information about which credit requirements a user has satisfied. - - Arguments: - course_key (CourseKey): Identifier for the course. - student (User): Currently logged in user. - - Returns: dict if the credit eligibility enabled and it is a credit course - and the user is enrolled in either verified or credit mode, and None otherwise. - - """ - # If credit eligibility is not enabled or this is not a credit course, - # short-circuit and return `None`. This indicates that credit requirements - # should NOT be displayed on the progress page. - if not (settings.FEATURES.get("ENABLE_CREDIT_ELIGIBILITY", False) and is_credit_course(course_key)): - return None - - # This indicates that credit requirements should NOT be displayed on the progress page. - enrollment = CourseEnrollment.get_enrollment(student, course_key) - if enrollment and enrollment.mode not in REQUIREMENTS_DISPLAY_MODES: - return None - - # Credit requirement statuses for which user does not remain eligible to get credit. - non_eligible_statuses = ['failed', 'declined'] - - # Retrieve the status of the user for each eligibility requirement in the course. - # For each requirement, the user's status is either "satisfied", "failed", or None. - # In this context, `None` means that we don't know the user's status, either because - # the user hasn't done something (for example, submitting photos for verification) - # or we're waiting on more information (for example, a response from the photo - # verification service). - requirement_statuses = get_credit_requirement_status(course_key, student.username) - - # If the user has been marked as "eligible", then they are *always* eligible - # unless someone manually intervenes. This could lead to some strange behavior - # if the requirements change post-launch. For example, if the user was marked as eligible - # for credit, then a new requirement was added, the user will see that they're eligible - # AND that one of the requirements is still pending. - # We're assuming here that (a) we can mitigate this by properly training course teams, - # and (b) it's a better user experience to allow students who were at one time - # marked as eligible to continue to be eligible. - # If we need to, we can always manually move students back to ineligible by - # deleting CreditEligibility records in the database. - if is_user_eligible_for_credit(student.username, course_key): - eligibility_status = "eligible" - - # If the user has *failed* any requirements (for example, if a photo verification is denied), - # then the user is NOT eligible for credit. - elif any(requirement['status'] in non_eligible_statuses for requirement in requirement_statuses): - eligibility_status = "not_eligible" - - # Otherwise, the user may be eligible for credit, but the user has not - # yet completed all the requirements. - else: - eligibility_status = "partial_eligible" - - return { - 'eligibility_status': eligibility_status, - 'requirements': requirement_statuses, - } - - -@login_required -@ensure_valid_course_key -def submission_history(request, course_id, student_username, location): - """Render an HTML fragment (meant for inclusion elsewhere) that renders a - history of all state changes made by this user for this problem location. - Right now this only works for problems because that's all - StudentModuleHistory records. - """ - - course_key = CourseKey.from_string(course_id) - - try: - usage_key = course_key.make_usage_key_from_deprecated_string(location) - except (InvalidKeyError, AssertionError): - return HttpResponse(escape(_(u'Invalid location.'))) - - course = get_course_overview_with_access(request.user, 'load', course_key) - staff_access = bool(has_access(request.user, 'staff', course)) - - # Permission Denied if they don't have staff access and are trying to see - # somebody else's submission history. - if (student_username != request.user.username) and (not staff_access): - raise PermissionDenied - - user_state_client = DjangoXBlockUserStateClient() - try: - history_entries = list(user_state_client.get_history(student_username, usage_key)) - except DjangoXBlockUserStateClient.DoesNotExist: - return HttpResponse(escape(_(u'User {username} has never accessed problem {location}').format( - username=student_username, - location=location - ))) - - # This is ugly, but until we have a proper submissions API that we can use to provide - # the scores instead, it will have to do. - csm = StudentModule.objects.filter( - module_state_key=usage_key, - student__username=student_username, - course_id=course_key) - - scores = BaseStudentModuleHistory.get_history(csm) - - if len(scores) != len(history_entries): - log.warning( - "Mismatch when fetching scores for student " - "history for course %s, user %s, xblock %s. " - "%d scores were found, and %d history entries were found. " - "Matching scores to history entries by date for display.", - course_id, - student_username, - location, - len(scores), - len(history_entries), - ) - scores_by_date = { - score.created: score - for score in scores - } - scores = [ - scores_by_date[history.updated] - for history in history_entries - ] - - context = { - 'history_entries': history_entries, - 'scores': scores, - 'username': student_username, - 'location': location, - 'course_id': course_key.to_deprecated_string() - } - - return render_to_response('courseware/submission_history.html', context) - - -def get_static_tab_fragment(request, course, tab): - """ - Returns the fragment for the given static tab - """ - loc = course.id.make_usage_key( - tab.type, - tab.url_slug, - ) - field_data_cache = FieldDataCache.cache_for_descriptor_descendents( - course.id, request.user, modulestore().get_item(loc), depth=0 - ) - tab_module = get_module( - request.user, request, loc, field_data_cache, static_asset_path=course.static_asset_path, course=course - ) - - logging.debug('course_module = %s', tab_module) - - fragment = Fragment() - if tab_module is not None: - try: - fragment = tab_module.render(STUDENT_VIEW, {}) - except Exception: # pylint: disable=broad-except - fragment.content = render_to_string('courseware/error-message.html', None) - log.exception( - u"Error rendering course=%s, tab=%s", course, tab['url_slug'] - ) - - return fragment - - -@require_GET -@ensure_valid_course_key -def get_course_lti_endpoints(request, course_id): - """ - View that, given a course_id, returns the a JSON object that enumerates all of the LTI endpoints for that course. - - The LTI 2.0 result service spec at - http://www.imsglobal.org/lti/ltiv2p0/uml/purl.imsglobal.org/vocab/lis/v2/outcomes/Result/service.html - says "This specification document does not prescribe a method for discovering the endpoint URLs." This view - function implements one way of discovering these endpoints, returning a JSON array when accessed. - - Arguments: - request (django request object): the HTTP request object that triggered this view function - course_id (unicode): id associated with the course - - Returns: - (django response object): HTTP response. 404 if course is not found, otherwise 200 with JSON body. - """ - - course_key = CourseKey.from_string(course_id) - - try: - course = get_course(course_key, depth=2) - except ValueError: - return HttpResponse(status=404) - - anonymous_user = AnonymousUser() - anonymous_user.known = False # make these "noauth" requests like module_render.handle_xblock_callback_noauth - lti_descriptors = modulestore().get_items(course.id, qualifiers={'category': 'lti'}) - - lti_noauth_modules = [ - get_module_for_descriptor( - anonymous_user, - request, - descriptor, - FieldDataCache.cache_for_descriptor_descendents( - course_key, - anonymous_user, - descriptor - ), - course_key, - course=course - ) - for descriptor in lti_descriptors - ] - - endpoints = [ - { - 'display_name': module.display_name, - 'lti_2_0_result_service_json_endpoint': module.get_outcome_service_url( - service_name='lti_2_0_result_rest_handler') + "/user/{anon_user_id}", - 'lti_1_1_result_service_xml_endpoint': module.get_outcome_service_url( - service_name='grade_handler'), - } - for module in lti_noauth_modules - ] - - return HttpResponse(json.dumps(endpoints), content_type='application/json') - - -@login_required -def course_survey(request, course_id): - """ - URL endpoint to present a survey that is associated with a course_id - Note that the actual implementation of course survey is handled in the - views.py file in the Survey Djangoapp - """ - - course_key = CourseKey.from_string(course_id) - course = get_course_with_access(request.user, 'load', course_key) - - redirect_url = reverse('info', args=[course_id]) - - # if there is no Survey associated with this course, - # then redirect to the course instead - if not course.course_survey_name: - return redirect(redirect_url) - - return survey.views.view_student_survey( - request.user, - course.course_survey_name, - course=course, - redirect_url=redirect_url, - is_required=course.course_survey_required, - ) - - -def is_course_passed(course, grade_summary=None, student=None, request=None): - """ - check user's course passing status. return True if passed - - Arguments: - course : course object - grade_summary (dict) : contains student grade details. - student : user object - request (HttpRequest) - - Returns: - returns bool value - """ - nonzero_cutoffs = [cutoff for cutoff in course.grade_cutoffs.values() if cutoff > 0] - success_cutoff = min(nonzero_cutoffs) if nonzero_cutoffs else None - - if grade_summary is None: - grade_summary = CourseGradeFactory().create(student, course).summary - - return success_cutoff and grade_summary['percent'] >= success_cutoff - - -# Grades can potentially be written - if so, let grading manage the transaction. -@transaction.non_atomic_requests -@require_POST -def generate_user_cert(request, course_id): - """Start generating a new certificate for the user. - - Certificate generation is allowed if: - * The user has passed the course, and - * The user does not already have a pending/completed certificate. - - Note that if an error occurs during certificate generation - (for example, if the queue is down), then we simply mark the - certificate generation task status as "error" and re-run - the task with a management command. To students, the certificate - will appear to be "generating" until it is re-run. - - Args: - request (HttpRequest): The POST request to this view. - course_id (unicode): The identifier for the course. - - Returns: - HttpResponse: 200 on success, 400 if a new certificate cannot be generated. - - """ - - if not request.user.is_authenticated(): - log.info(u"Anon user trying to generate certificate for %s", course_id) - return HttpResponseBadRequest( - _('You must be signed in to {platform_name} to create a certificate.').format( - platform_name=configuration_helpers.get_value('PLATFORM_NAME', settings.PLATFORM_NAME) - ) - ) - - student = request.user - course_key = CourseKey.from_string(course_id) - - course = modulestore().get_course(course_key, depth=2) - if not course: - return HttpResponseBadRequest(_("Course is not valid")) - - if not is_course_passed(course, None, student, request): - return HttpResponseBadRequest(_("Your certificate will be available when you pass the course.")) - - certificate_status = certs_api.certificate_downloadable_status(student, course.id) - - if certificate_status["is_downloadable"]: - return HttpResponseBadRequest(_("Certificate has already been created.")) - elif certificate_status["is_generating"]: - return HttpResponseBadRequest(_("Certificate is being created.")) - else: - # If the certificate is not already in-process or completed, - # then create a new certificate generation task. - # If the certificate cannot be added to the queue, this will - # mark the certificate with "error" status, so it can be re-run - # with a management command. From the user's perspective, - # it will appear that the certificate task was submitted successfully. - certs_api.generate_user_certificates(student, course.id, course=course, generation_mode='self') - _track_successful_certificate_generation(student.id, course.id) - return HttpResponse() - - -def _track_successful_certificate_generation(user_id, course_id): # pylint: disable=invalid-name - """ - Track a successful certificate generation event. - - Arguments: - user_id (str): The ID of the user generting the certificate. - course_id (CourseKey): Identifier for the course. - Returns: - None - - """ - if settings.LMS_SEGMENT_KEY: - event_name = 'edx.bi.user.certificate.generate' - tracking_context = tracker.get_tracker().resolve_context() - - analytics.track( - user_id, - event_name, - { - 'category': 'certificates', - 'label': unicode(course_id) - }, - context={ - 'ip': tracking_context.get('ip'), - 'Google Analytics': { - 'clientId': tracking_context.get('client_id') - } - } - ) - - -@require_http_methods(["GET", "POST"]) -@ensure_valid_usage_key -def render_xblock(request, usage_key_string, check_if_enrolled=True): - """ - Returns an HttpResponse with HTML content for the xBlock with the given usage_key. - The returned HTML is a chromeless rendering of the xBlock (excluding content of the containing courseware). - """ - usage_key = UsageKey.from_string(usage_key_string) - - usage_key = usage_key.replace(course_key=modulestore().fill_in_run(usage_key.course_key)) - course_key = usage_key.course_key - - requested_view = request.GET.get('view', 'student_view') - if requested_view != 'student_view': - return HttpResponseBadRequest("Rendering of the xblock view '{}' is not supported.".format(requested_view)) - - with modulestore().bulk_operations(course_key): - # verify the user has access to the course, including enrollment check - try: - course = get_course_with_access(request.user, 'load', course_key, check_if_enrolled=check_if_enrolled) - except CourseAccessRedirect: - raise Http404("Course not found.") - - # get the block, which verifies whether the user has access to the block. - block, _ = get_module_by_usage_id( - request, unicode(course_key), unicode(usage_key), disable_staff_debug_info=True, course=course - ) - - student_view_context = request.GET.dict() - student_view_context['show_bookmark_button'] = False - - context = { - 'fragment': block.render('student_view', context=student_view_context), - 'course': course, - 'disable_accordion': True, - 'allow_iframing': True, - 'disable_header': True, - 'disable_footer': True, - 'disable_window_wrap': True, - 'staff_access': bool(has_access(request.user, 'staff', course)), - 'xqa_server': settings.FEATURES.get('XQA_SERVER', 'http://your_xqa_server.com'), - } - return render_to_response('courseware/courseware-chromeless.html', context) - - -# Translators: "percent_sign" is the symbol "%". "platform_name" is a -# string identifying the name of this installation, such as "edX". -FINANCIAL_ASSISTANCE_HEADER = _( - '{platform_name} now offers financial assistance for learners who want to earn Verified Certificates but' - ' who may not be able to pay the Verified Certificate fee. Eligible learners may receive up to 90{percent_sign} off' - ' the Verified Certificate fee for a course.\nTo apply for financial assistance, enroll in the' - ' audit track for a course that offers Verified Certificates, and then complete this application.' - ' Note that you must complete a separate application for each course you take.\n We plan to use this' - ' information to evaluate your application for financial assistance and to further develop our' - ' financial assistance program.' -).format( - percent_sign="%", - platform_name=configuration_helpers.get_value('PLATFORM_NAME', settings.PLATFORM_NAME) -).split('\n') - - -FA_INCOME_LABEL = _('Annual Household Income') -FA_REASON_FOR_APPLYING_LABEL = _( - 'Tell us about your current financial situation. Why do you need assistance?' -) -FA_GOALS_LABEL = _( - 'Tell us about your learning or professional goals. How will a Verified Certificate in' - ' this course help you achieve these goals?' -) -FA_EFFORT_LABEL = _( - 'Tell us about your plans for this course. What steps will you take to help you complete' - ' the course work and receive a certificate?' -) -FA_SHORT_ANSWER_INSTRUCTIONS = _('Use between 250 and 500 words or so in your response.') - - -@login_required -def financial_assistance(_request): - """Render the initial financial assistance page.""" - return render_to_response('financial-assistance/financial-assistance.html', { - 'header_text': FINANCIAL_ASSISTANCE_HEADER - }) - - -@login_required -@require_POST -def financial_assistance_request(request): - """Submit a request for financial assistance to Zendesk.""" - try: - data = json.loads(request.body) - # Simple sanity check that the session belongs to the user - # submitting an FA request - username = data['username'] - if request.user.username != username: - return HttpResponseForbidden() - - course_id = data['course'] - course = modulestore().get_course(CourseKey.from_string(course_id)) - legal_name = data['name'] - email = data['email'] - country = data['country'] - income = data['income'] - reason_for_applying = data['reason_for_applying'] - goals = data['goals'] - effort = data['effort'] - marketing_permission = data['mktg-permission'] - ip_address = get_ip(request) - except ValueError: - # Thrown if JSON parsing fails - return HttpResponseBadRequest(u'Could not parse request JSON.') - except InvalidKeyError: - # Thrown if course key parsing fails - return HttpResponseBadRequest(u'Could not parse request course key.') - except KeyError as err: - # Thrown if fields are missing - return HttpResponseBadRequest(u'The field {} is required.'.format(err.message)) - - zendesk_submitted = _record_feedback_in_zendesk( - legal_name, - email, - u'Financial assistance request for learner {username} in course {course_name}'.format( - username=username, - course_name=course.display_name - ), - u'Financial Assistance Request', - {'course_id': course_id}, - # Send the application as additional info on the ticket so - # that it is not shown when support replies. This uses - # OrderedDict so that information is presented in the right - # order. - OrderedDict(( - ('Username', username), - ('Full Name', legal_name), - ('Course ID', course_id), - ('Annual Household Income', income), - ('Country', country), - ('Allowed for marketing purposes', 'Yes' if marketing_permission else 'No'), - (FA_REASON_FOR_APPLYING_LABEL, '\n' + reason_for_applying + '\n\n'), - (FA_GOALS_LABEL, '\n' + goals + '\n\n'), - (FA_EFFORT_LABEL, '\n' + effort + '\n\n'), - ('Client IP', ip_address), - )), - group_name='Financial Assistance', - require_update=True - ) - - if not zendesk_submitted: - # The call to Zendesk failed. The frontend will display a - # message to the user. - return HttpResponse(status=status.HTTP_500_INTERNAL_SERVER_ERROR) - - return HttpResponse(status=status.HTTP_204_NO_CONTENT) - - -@login_required -def financial_assistance_form(request): - """Render the financial assistance application form page.""" - user = request.user - enrolled_courses = get_financial_aid_courses(user) - incomes = ['Less than $5,000', '$5,000 - $10,000', '$10,000 - $15,000', '$15,000 - $20,000', '$20,000 - $25,000'] - annual_incomes = [ - {'name': _(income), 'value': income} for income in incomes # pylint: disable=translation-of-non-string - ] - return render_to_response('financial-assistance/apply.html', { - 'header_text': FINANCIAL_ASSISTANCE_HEADER, - 'student_faq_url': marketing_link('FAQ'), - 'dashboard_url': reverse('dashboard'), - 'account_settings_url': reverse('account_settings'), - 'platform_name': configuration_helpers.get_value('PLATFORM_NAME', settings.PLATFORM_NAME), - 'user_details': { - 'email': user.email, - 'username': user.username, - 'name': user.profile.name, - 'country': str(user.profile.country.name), - }, - 'submit_url': reverse('submit_financial_assistance_request'), - 'fields': [ - { - 'name': 'course', - 'type': 'select', - 'label': _('Course'), - 'placeholder': '', - 'defaultValue': '', - 'required': True, - 'options': enrolled_courses, - 'instructions': _( - 'Select the course for which you want to earn a verified certificate. If' - ' the course does not appear in the list, make sure that you have enrolled' - ' in the audit track for the course.' - ) - }, - { - 'name': 'income', - 'type': 'select', - 'label': FA_INCOME_LABEL, - 'placeholder': '', - 'defaultValue': '', - 'required': True, - 'options': annual_incomes, - 'instructions': _('Specify your annual household income in US Dollars.') - }, - { - 'name': 'reason_for_applying', - 'type': 'textarea', - 'label': FA_REASON_FOR_APPLYING_LABEL, - 'placeholder': '', - 'defaultValue': '', - 'required': True, - 'restrictions': { - 'min_length': settings.FINANCIAL_ASSISTANCE_MIN_LENGTH, - 'max_length': settings.FINANCIAL_ASSISTANCE_MAX_LENGTH - }, - 'instructions': FA_SHORT_ANSWER_INSTRUCTIONS - }, - { - 'name': 'goals', - 'type': 'textarea', - 'label': FA_GOALS_LABEL, - 'placeholder': '', - 'defaultValue': '', - 'required': True, - 'restrictions': { - 'min_length': settings.FINANCIAL_ASSISTANCE_MIN_LENGTH, - 'max_length': settings.FINANCIAL_ASSISTANCE_MAX_LENGTH - }, - 'instructions': FA_SHORT_ANSWER_INSTRUCTIONS - }, - { - 'name': 'effort', - 'type': 'textarea', - 'label': FA_EFFORT_LABEL, - 'placeholder': '', - 'defaultValue': '', - 'required': True, - 'restrictions': { - 'min_length': settings.FINANCIAL_ASSISTANCE_MIN_LENGTH, - 'max_length': settings.FINANCIAL_ASSISTANCE_MAX_LENGTH - }, - 'instructions': FA_SHORT_ANSWER_INSTRUCTIONS - }, - { - 'placeholder': '', - 'name': 'mktg-permission', - 'label': _( - 'I allow edX to use the information provided in this application ' - '(except for financial information) for edX marketing purposes.' - ), - 'defaultValue': '', - 'type': 'checkbox', - 'required': False, - 'instructions': '', - 'restrictions': {} - } - ], - }) - - -def get_financial_aid_courses(user): - """ Retrieve the courses eligible for financial assistance. """ - financial_aid_courses = [] - for enrollment in CourseEnrollment.enrollments_for_user(user).order_by('-created'): - - if enrollment.mode != CourseMode.VERIFIED and \ - enrollment.course_overview.eligible_for_financial_aid and \ - CourseMode.objects.filter( - Q(_expiration_datetime__isnull=True) | Q(_expiration_datetime__gt=datetime.now(UTC())), - course_id=enrollment.course_id, - mode_slug=CourseMode.VERIFIED).exists(): - - financial_aid_courses.append( - { - 'name': enrollment.course_overview.display_name, - 'value': unicode(enrollment.course_id) - } - ) - - return financial_aid_courses - - -def check_access_to_course(request, course): - """ - Raises Redirect exceptions if the user does not have course access. - """ - # Redirect to the dashboard if not all prerequisites have been met - if not has_access(request.user, 'view_courseware_with_prerequisites', course): - log.info( - u'User %d tried to view course %s ' - u'without fulfilling prerequisites', - request.user.id, unicode(course.id)) - raise CourseAccessRedirect(reverse('dashboard')) - - # Redirect if the user must answer a survey before entering the course. - if must_answer_survey(course, request.user): - raise CourseAccessRedirect(reverse('course_survey', args=[unicode(course.id)])) diff --git a/modified/lms.templates.courseware.course_about.html b/modified/lms.templates.courseware.course_about.html deleted file mode 100644 index 9b492bb..0000000 --- a/modified/lms.templates.courseware.course_about.html +++ /dev/null @@ -1,369 +0,0 @@ -<%namespace name='static' file='../static_content.html'/> -<%! -from django.utils.translation import ugettext as _ -from django.core.urlresolvers import reverse -from courseware.courses import get_course_about_section -from django.conf import settings -from edxmako.shortcuts import marketing_link -from openedx.core.lib.courses import course_image_url -%> - -<%inherit file="../main.html" /> -<%block name="headextra"> - ## OG (Open Graph) title and description added below to give social media info to display - ## (https://developers.facebook.com/docs/opengraph/howtos/maximizing-distribution-media-content#tags) - - - - -<%block name="js_extra"> - ## CourseTalk widget js script - % if show_coursetalk_widget: - - % endif - - - - - -<%block name="pagetitle">${course.display_name_with_default_escaped} - -
-
-
-
-
-
-

- ${course.display_name_with_default_escaped} - -

-
- -
- %if user.is_authenticated() and registered: - %if show_courseware_link: - - %endif - - ${_("You are enrolled in this course")} - - %if show_courseware_link: - ${_("View Course")} - - %endif - - %elif in_cart: - - ${_('This course is in your cart.').format(cart_link=cart_link)} - - % elif is_course_full: - - ${_("Course is full")} - - % elif invitation_only and not can_enroll: - ${_("Enrollment in this course is by invitation only")} - ## Shib courses need the enrollment button to be displayed even when can_enroll is False, - ## because AnonymousUsers cause can_enroll for shib courses to be False, but we need them to be able to click - ## so that they can register and become a real user that can enroll. - % elif not is_shib_course and not can_enroll: - ${_("Enrollment is Closed")} - %elif can_add_course_to_cart: - <% - if user.is_authenticated(): - reg_href = "#" - reg_element_id = "add_to_cart_post" - else: - reg_href = reg_then_add_to_cart_link - reg_element_id = "reg_then_add_to_cart" - %> - <% if ecommerce_checkout: - reg_href = ecommerce_checkout_link - reg_element_id = "" - %> - - ${_("Add {course_name} to Cart ({price} USD)")\ - .format(course_name=course.display_number_with_default, price=course_price)} - -
- %else: - <% - if ecommerce_checkout: - reg_href = ecommerce_checkout_link - else: - reg_href="#" - if professional_mode: - href_class = "add-to-cart" - else: - href_class = "register" - %> - - ${_("Enroll in {course_name}").format(course_name=course.display_number_with_default) | h} - -

${_("Choose course start date:")}

- % if course_shifts: - - % endif - -
- %endif -
- -
- % if get_course_about_section(request, course, "video"): - -
- -
-
-
- %else: -
-
- -
-
- % endif -
-
-
- -
-
- % if staff_access and studio_url is not None: - - % endif - -
- ${get_course_about_section(request, course, "overview")} -
-
- -
-
- - <%include file="course_about_sidebar_header.html" /> - -
    -
  1. ${_("Course Number")}

    ${course.display_number_with_default | h}
  2. - % if not course.start_date_is_still_default: - <% - course_start_date = course.start - %> -
  3. - -

    ${_("Classes Start")}

    - % if isinstance(course_start_date, str): - ${course_start_date} - % else: - <% - course_date_string = course_start_date.strftime('%Y-%m-%dT%H:%M:%S%z') - %> - - % endif -
  4. - % endif - ## We plan to ditch end_date (which is not stored in course metadata), - ## but for backwards compatibility, show about/end_date blob if it exists. - % if get_course_about_section(request, course, "end_date") or course.end: - <% - course_end_date = course.end - %> - -
  5. - -

    ${_("Classes End")}

    - % if isinstance(course_end_date, str): - ${course_end_date} - % else: - <% - course_date_string = course_end_date.strftime('%Y-%m-%dT%H:%M:%S%z') - %> - - % endif -
  6. - % endif - - % if get_course_about_section(request, course, "effort"): -
  7. ${_("Estimated Effort")}

    ${get_course_about_section(request, course, "effort")}
  8. - % endif - - ##
  9. ${_('Course Length')}

    ${_('{number} weeks').format(number=15)}
  10. - - %if course_price and (can_add_course_to_cart or is_cosmetic_price_enabled): -
  11. - -

    ${_("Price")}

    - ${course_price} -
  12. - %endif - - % if pre_requisite_courses: - <% prc_target = reverse('about_course', args=[unicode(pre_requisite_courses[0]['key'])]) %> -
  13. - -

    ${_("Prerequisites")}

    - ## Multiple pre-requisite courses are not supported on frontend that's why we are pulling first element - ${pre_requisite_courses[0]['display']} -

    - ${_("You must successfully complete {link_start}{prc_display}{link_end} before you begin this course.").format( - link_start=''.format(prc_target), - link_end='', - prc_display=pre_requisite_courses[0]['display'], - )} -

    -
  14. - % endif - % if get_course_about_section(request, course, "prerequisites"): -
  15. ${_("Requirements")}

    ${get_course_about_section(request, course, "prerequisites")}
  16. - % endif -
-
- - ## CourseTalk widget - % if show_coursetalk_widget: -
-
-
- % endif - - ## For now, ocw links are the only thing that goes in additional resources - % if get_course_about_section(request, course, "ocw_links"): -
-
-

${_("Additional Resources")}

-
- -
- ## "MITOpenCourseware" should *not* be translated -

MITOpenCourseware

- ${get_course_about_section(request, course, "ocw_links")} -
-
- %endif - -
- - - - -## Need to put this hidden form on the page so that the registration button works. -## Since it's no harm to display a hidden form, we display it with the most permissive conditional -## which is when the student is not registered. -%if active_reg_button or is_shib_course: -
-
-
- ${_("Enroll")} - - - %if course_shifts: - - %endif -
-
- -
-
-
-%endif - -<%include file="../video_modal.html" /> - -<%static:require_module_async module_name="js/dateutil_factory" class_name="DateUtilFactory"> - DateUtilFactory.transform(iterationKey=".localized_datetime"); - diff --git a/static/course-shifts-detail.underscore b/static/course-shifts-detail.underscore deleted file mode 100644 index c6040df..0000000 --- a/static/course-shifts-detail.underscore +++ /dev/null @@ -1,58 +0,0 @@ -
-

<%- gettext("Shifts Editing View") %>

-
-
- -
-
- - -
- - - - - - - - - - - - - - - -
<%- gettext("Enrollement start")%>
<%- gettext("Enrollement finish")%>
<%- gettext("Users in shift")%>
-
- - - -
-
-
-
- -
- -
- -
-
diff --git a/static/course_shifts.html b/static/course_shifts.html deleted file mode 100644 index 0cf406c..0000000 --- a/static/course_shifts.html +++ /dev/null @@ -1,62 +0,0 @@ -<%page args="section_data" expression_filter="h"/> -<%namespace name='static' file='../../static_content.html'/> - -<%! -from django.utils.translation import ugettext as _ -from openedx.core.djangolib.js_utils import dump_js_escaped_json, js_escaped_string -%> - - -
-
-
-

${_("Settings Editor")}

-
-
    - -
  • - True - False - - -
  • -
  • - - -
  • -
  • - - -
  • -
  • - - -
  • -
- - -
-
-
-
-
-
-
- -
-
-
-
-
diff --git a/static/course_shifts.js b/static/course_shifts.js deleted file mode 100644 index 2df312b..0000000 --- a/static/course_shifts.js +++ /dev/null @@ -1,311 +0,0 @@ -/* globals _ */ - -(function() { - 'use strict'; - var CourseShifts; - - CourseShifts = (function() { - function course_shifts($section) { - var ext = this; - this.$section = $section; - this.$section.data('wrapper', this); - - this.$enroll_after_days = this.$section.find("input[name='enroll-after-days']"); - this.$enroll_before_days = this.$section.find("input[name='enroll-before-days']"); - this.$autostart_period_days = this.$section.find("input[name='autostart-period-days']"); - this.$is_autostart = this.$section.find("input[name='is-autostart']"); - this.$settings_submit = this.$section.find("input[name='settings-submit']"); - - this.$section.find('.request-response').hide(); - this.$section.find('.request-response-error').hide(); - - this.$settings_submit.click(function() { - ext.clear_display(); - var sendData = { - enroll_after_days: ext.$enroll_after_days.val(), - enroll_before_days: ext.$enroll_before_days.val(), - is_autostart: ext.$is_autostart.filter(":checked").val() - }; - if (!(ext.$autostart_period_days.attr("disabled"))){ - sendData['autostart_period_days'] = ext.$autostart_period_days.val(); - } - return $.ajax({ - type: 'POST', - dataType: 'json', - url: ext.$settings_submit.data('endpoint'), - data: sendData, - success: function(data) { - ext.render_shift_view(); - return ext.display_response('course-shifts-settings-editor', data); - }, - error: function(xhr) { - return ext.fail_with_error('course-shifts-settings-editor', 'Error changing settings', xhr); - } - }); - }); - - this.autostart_change = function (){ - var value = ext.$is_autostart.filter(":checked").val(); - if (value.includes("True")){ - ext.$autostart_period_days.val(ext.$autostart_period_days.data('default-value')); - ext.$autostart_period_days.attr("disabled", false); - } - if (value.includes("False")){ - ext.$autostart_period_days.val(null); - ext.$autostart_period_days.attr("disabled", true); - } - }; - this.$is_autostart.change(this.autostart_change); - this.$course_shifts_view = ext.$section.find('#course-shifts-view'); - this.create_shift_code = 'create-new-shift'; - this.autostart_change(); - this.render_shift_view(); - } - - course_shifts.prototype.render_shift_view = function() { - var ext = this; - if (ext.$is_autostart.filter(":checked").val().includes("True")){ - ext.$course_shifts_view.html(''); - return - } - var render = function (data) { - var rendered_shifts = edx.HtmlUtils.template($('#course-shifts-detail-tpl').text())({ - shifts_list: data - }); - var template_place = ext.$course_shifts_view.find("#course-shifts-view-template"); - template_place.html(rendered_shifts["text"]); - var select_shift = ext.$section.find("#shift-select"); - select_shift.change(function () { - ext.render_shift(this.value); - }); - - ext.render_shift(select_shift.val()); - - ext.$submit_shift_view_button = ext.$course_shifts_view.find("#change-create-shift-button"); - var shift_view_submit_clicked = function() { - var name = ext.$course_shifts_view.find("input[name='course-shift-name']").attr("value"); - var date = ext.$course_shifts_view.find("input[name='course-shift-date']").attr("value"); - var select = ext.$course_shifts_view.find("#shift-select").val(); - if (select.includes(ext.create_shift_code)) { - data = {}; - if (name) { - data["name"] = name; - } - if (date) { - data["start_date"] = date; - } - return $.ajax({ - type: 'POST', - dataType: 'json', - url: ext.$course_shifts_view.data('url-detail'), - data: data, - success: function (data) { - ext.render_shift_view(); - ext.display_response('course-shifts-view', data); - }, - error: function (xhr) { - return ext.fail_with_error('course-shifts-view', 'Error creating shift', xhr); - } - }); - } - else { - data = {}; - data["name"] = select; - if (name) { - data["new_name"] = name; - } - if (date) { - data["new_start_date"] = date; - } - return $.ajax({ - type: 'PATCH', - dataType: 'json', - url: ext.$course_shifts_view.data('url-detail'), - data: data, - success: function (data) { - ext.render_shift_view(); - ext.display_response('course-shifts-view', data); - - }, - error: function (xhr) { - return ext.fail_with_error('course-shifts-view', 'Error updating shift info', xhr); - } - }); - } - }; - ext.$submit_shift_view_button.click(shift_view_submit_clicked); - - ext.$delete_shift_button = ext.$course_shifts_view.find("#delete-shift-button"); - ext.$delete_shift_button.click(function () { - ext.clear_display(); - var select = ext.$course_shifts_view.find("#shift-select").val(); - if (select.includes(ext.create_shift_code)){ - return - } - data = {"name":select}; - return $.ajax({ - type: 'DELETE', - dataType: 'json', - url: ext.$course_shifts_view.data('url-detail'), - data: data, - success: function (data) { - ext.render_shift_view(); - ext.display_response('course-shifts-view', data); - }, - error: function (xhr) { - return ext.fail_with_error('course-shifts-view', 'Error deleting shift', xhr); - } - }) - }); - - ext.$user_add_button= ext.$course_shifts_view.find("#course-shift-add-user-button"); - ext.$user_add_button.click(function () { - ext.clear_display(); - var select_value = ext.$course_shifts_view.find("#shift-select").val(); - if (select_value.includes(ext.create_shift_code)){ - return - } - var username_add = ext.$course_shifts_view.find("input[name='course-shift-username-add']"); - var data = { - shift_name:select_value, - username:username_add.attr("value") - }; - - return $.ajax({ - type: 'POST', - dataType: 'json', - url: ext.$course_shifts_view.data('url-membership'), - data: data, - success: function (data) { - ext.render_shift_view(); - ext.display_response('course-shifts-view', data); - }, - error: function (xhr) { - return ext.fail_with_error('course-shifts-view', 'Error adding user', xhr); - } - }) - }); - ext.clear_display(); - }; - - return $.ajax({ - type: 'GET', - dataType: 'json', - url: this.$course_shifts_view.data('url-list'), - success: function(data) { - return render(data); - }, - error: function(xhr) { - return render([]); - } - }); - }; - - course_shifts.prototype.render_shift = function(name){ - var ext = this; - ext.clear_display(); - var render_shift_info = function(data){ - var name_field = ext.$course_shifts_view.find("input[name='course-shift-name']"); - var date_field = ext.$course_shifts_view.find("input[name='course-shift-date']"); - var enroll_start_field = ext.$course_shifts_view.find("#current-shift-enrollement-start"); - var enroll_finish_field = ext.$course_shifts_view.find("#current-shift-enrollement-finish"); - var users_count = ext.$course_shifts_view.find("#current-shift-users-count"); - var create_shift_disable = ext.$course_shifts_view.find(".create-shift-disable"); - if ($.isEmptyObject(data)){ - name_field.attr("value", ''); - date_field.attr("value", ''); - enroll_start_field.html(''); - enroll_finish_field.html(''); - users_count.html(''); - create_shift_disable.attr("disabled", true); - return; - } - name_field.attr("value", data["name"]); - date_field.attr("value", data["start_date"]); - enroll_start_field.html(data["enroll_start"]); - enroll_finish_field.html(data["enroll_finish"]); - users_count.html(data["users_count"]); - if (create_shift_disable.attr("disabled")){ - create_shift_disable.attr("disabled", false); - } - }; - if (name.includes(ext.create_shift_code)){ - render_shift_info({}); - return; - } - var data = {"name": name}; - return $.ajax({ - type: 'GET', - dataType: 'json', - url: this.$course_shifts_view.data('url-detail'), - data:data, - success: function(data) { - render_shift_info(data); - }, - error: function(xhr) { - return ext.fail_with_error('course-shifts', 'Error getting shift data', xhr); - } - }); - }; - - course_shifts.prototype.shift_view_submit_clicked = function (ext) { - - }; - - course_shifts.prototype.clear_display = function() { - this.$section.find('.request-response-error').empty().hide(); - return this.$section.find('.request-response').empty().hide(); - }; - - course_shifts.prototype.display_response = function(id, data) { - var $taskError, $taskResponse; - $taskError = this.$section.find('#' + id + ' .request-response-error'); - $taskResponse = this.$section.find('#' + id + ' .request-response'); - $taskError.empty().hide(); - if ($.isEmptyObject(data)){ - data = "Success."; - } - var message = data; - $taskResponse.empty().text(message); - return $taskResponse.show(); - }; - - course_shifts.prototype.fail_with_error = function(id, msg, xhr) { - var $taskError, $taskResponse, data, - message = msg; - $taskError = this.$section.find('#' + id + ' .request-response-error'); - $taskResponse = this.$section.find('#' + id + ' .request-response'); - this.clear_display(); - data = $.parseJSON(xhr.responseText); - - var error_message = data.error; - if ($.type(error_message) != 'string'){ - error_message = ''; - for (var key in data.error){ - error_message += key + ":" +data.error[key] +";"; - } - } - message += ': ' + error_message; - $taskResponse.empty(); - $taskError.empty(); - $taskError.text(message); - return $taskError.show(); - }; - - course_shifts.prototype.onClickTitle = function() {}; - - return course_shifts; - }()); - - _.defaults(window, { - InstructorDashboard: {} - }); - - _.defaults(window.InstructorDashboard, { - sections: {} - }); - - _.defaults(window.InstructorDashboard.sections, { - CourseShifts: CourseShifts - }); -}).call(this);