diff --git a/.gitignore b/.gitignore index 0d20b64..c92982b 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,3 @@ *.pyc +*.egg-info/ +.idea/ diff --git a/README.rst b/README.rst new file mode 100644 index 0000000..2c495dc --- /dev/null +++ b/README.rst @@ -0,0 +1,60 @@ +Description +----------- +This is django app based on OpenEdx Ficus release `"open-release/ficus.2" +`_ +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 to get credit. + +There are several differences between this app and course rerun/CCX: + +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. + +3. Students are able to change due dates if they need to, and therefore course schedule becomes more flexible. + +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 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 += ('course_shifts',) + FEATURES["ENABLE_COURSE_SHIFTS"] = True + +2. course_shifts.provider.CourseShiftOverrideProvider should be added to the FIELD_OVERRIDE_PROVIDERS + + :: + + FIELD_OVERRIDE_PROVIDERS += ( + 'course_shifts.provider.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 + + +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 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/__init__.py b/course_shifts/__init__.py new file mode 100644 index 0000000..68b5c96 --- /dev/null +++ b/course_shifts/__init__.py @@ -0,0 +1,38 @@ +from django.core.urlresolvers import reverse +from django.utils.translation import ugettext as _ + +from .models import CourseShiftSettings +from .serializers import CourseShiftSettingsSerializer, CourseShiftSerializer +from .manager import CourseShiftManager + + +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}) + + shift_manager = CourseShiftManager(course_key) + if not shift_manager.is_enabled: + return {} + serial_settings = shift_manager.get_serial_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, + 'course_shifts_membership_url':url_membership, + 'current_settings': serial_settings.data, + } + 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/course_shifts/api.py b/course_shifts/api.py new file mode 100644 index 0000000..80a94cf --- /dev/null +++ b/course_shifts/api.py @@ -0,0 +1,235 @@ +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 openedx.core.lib.api.permissions import ApiKeyHeaderPermission + + +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)) + ) + + +class CourseShiftSettingsView(views.APIView): + """ + Allows instructor to edit course shift settings + """ + permission_classes = CourseShiftsPermission, + + def get(self, request, course_id): + course_key = CourseKey.from_string(course_id) + shift_settings = CourseShiftSettings.get_course_settings(course_key) + 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()) + 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(): + 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: + 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}) + + +class CourseShiftListView(generics.ListAPIView): + """ + Returns list of shifts for given course + """ + serializer_class = CourseShiftSerializer + permission_classes = CourseShiftsPermission, + + 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): + 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) + + +class CourseShiftDetailView(views.APIView): + """ + Allows instructor to watch, to create, to modify and to delete course shifts + """ + permission_classes = CourseShiftsPermission, + + 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 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(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: + 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({}) + + +class CourseShiftUserView(views.APIView): + """ + Allows instructor to add users to shifts and check their + current shift + """ + permission_classes = CourseShiftsPermission, + + def post(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.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}) + + 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({"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) diff --git a/course_shifts/manager.py b/course_shifts/manager.py new file mode 100644 index 0000000..194adb5 --- /dev/null +++ b/course_shifts/manager.py @@ -0,0 +1,151 @@ +from datetime import timedelta + +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() + + +class CourseShiftManager(object): + """ + Provides the interface to perform operations on users and + 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 + self.settings = CourseShiftSettings.get_course_settings(self.course_key) + + @property + def is_enabled(self): + 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.is_enabled: + return + + membership = CourseShiftGroupMembership.get_user_membership(user, self.course_key) + if membership: + return membership.course_shift_group + + 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, + i.e. enrollment have started but haven't finished yet. + If user is given and he has membership all later started shifts are considered + as active + """ + if not self.settings.is_shift_enabled: + return [] + 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_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 enroll_user(self, user, shift, forced=False): + """ + 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 and shift.course_key != self.course_key: + raise ValueError("Shift's course_key: '{}', manager course_key:'{}'".format( + str(shift.course_key), + str(self.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 + + 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=None, name=None): + """ + 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: + 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") + 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 + + def get_serial_settings(self): + return CourseShiftSettingsSerializer(self.settings) \ No newline at end of file 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/__init__.py b/course_shifts/migrations/__init__.py similarity index 100% rename from __init__.py rename to course_shifts/migrations/__init__.py diff --git a/course_shifts/models.py b/course_shifts/models.py new file mode 100644 index 0000000..97c2a15 --- /dev/null +++ b/course_shifts/models.py @@ -0,0 +1,483 @@ +""" +This file contains the logic for course shifts. +""" +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.db import models, IntegrityError +from django.utils import timezone +from opaque_keys.edx.keys import CourseKey +from openedx.core.djangoapps.course_groups.models import CourseUserGroup, CourseKeyField +from xmodule.modulestore.django import modulestore + +log = getLogger(__name__) + + +def date_now(): + return timezone.now().date() + + +class CourseShiftGroup(models.Model): + """ + Represents group of users with shifted due dates. + It is based on CourseUserGroup. To ensure that + 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 + other models only. Direct usage can lead to the inconsistent + 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, + help_text="Date when this shift starts" + ) + days_shift = models.IntegerField( + default=0, + help_text="Days to add to the block's due" + ) + + class Meta: + unique_together = ('course_key', 'start_date',) + app_label = 'course_shifts' + + @property + def users(self): + return self.course_user_group.users + + @property + 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 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))) + delta_days = (value - self.start_date).days + self.days_shift += delta_days + 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, + str(self) + )) + return date + 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 = self._shift_settings + + 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 = self.settings + 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): + """ + 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_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): + """ + 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, + group_type=CourseUserGroup.SHIFT + ) + 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 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: + 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) + 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): + 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: '{}' 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) + + +class CourseShiftGroupMembership(models.Model): + """ + Represents membership in CourseShiftGroup. At any changes it + updates CourseUserGroup. + """ + 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 + + @classmethod + 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: + course_membership = None + return course_membership + + @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 in the 'course_shift_group_to'. If the last one + is None, user is unenrolled from shift 'course_shift_group_from' + """ + + if not course_shift_group_to and not course_shift_group_from: + return + + if course_shift_group_from == course_shift_group_to: + return + + 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 + + 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 + + if membership_group != course_shift_group_from: + raise ValueError("User's membership is '{}', not '{}'".format( + str(membership_group), + 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) + + @classmethod + def _push_add_to_group(cls, course_shift_group, user): + """ + 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 + + 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 _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_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") + 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, + 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_result + + def delete(self, *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) + + def __unicode__(self): + return u"'{}' in '{}'".format( + self.user.username, + self.course_shift_group.name + ) + + +class CourseShiftSettings(models.Model): + """ + 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="True value if this feature is enabled for the course run" + ) + + is_autostart = models.BooleanField( + default=True, + help_text="Are groups generated automatically with period " + "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.", + 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.", + 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", + validators=[MinValueValidator(0)] + ) + + class Meta: + app_label = 'course_shifts' + + def __init__(self, *args, **kwargs): + super(CourseShiftSettings, self).__init__(*args, **kwargs) + + @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 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): + return self.course.start.date() + + @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( + str(course_key) + )) + return current_settings + + def build_default_name(self, **kwargs): + """ + :param start_date + 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_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): + """ + In autostart mode returns date when next shift starts + In manual mode returns None + """ + 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) + + def _calculate_launch_date(self, start_date): + """ + 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 + """ + return start_date - timedelta(days=self.enroll_before_days) + + 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 = "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, + start_date=start_date, + days_shift=days_shift, + 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) + )) + 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({})".format(self.autostart_period_days) + else: + text += u"manual" + return text \ No newline at end of file diff --git a/course_shifts/provider.py b/course_shifts/provider.py new file mode 100644 index 0000000..122aab4 --- /dev/null +++ b/course_shifts/provider.py @@ -0,0 +1,92 @@ +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. + """ + 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) + 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) diff --git a/course_shifts/serializers.py b/course_shifts/serializers.py new file mode 100644 index 0000000..f6da0af --- /dev/null +++ b/course_shifts/serializers.py @@ -0,0 +1,77 @@ +from openedx.core.lib.api.serializers import CourseKeyField +from rest_framework import serializers + +from .models import CourseShiftSettings, CourseShiftGroup + + +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): + 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 + + +class CourseShiftSerializer(serializers.ModelSerializer): + course_key = CourseKeyField(required=False) + 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', + ) + + 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 + return errors_by_key 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 + + +
+ + + +
+ + + +
+ + + +
+ + +
+
+
+
+
+
+
+ +
+
+
+
+
diff --git a/course_shifts/tests/__init__.py b/course_shifts/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/course_shifts/tests/test_shifts.py b/course_shifts/tests/test_shifts.py new file mode 100644 index 0000000..1c278aa --- /dev/null +++ b/course_shifts/tests/test_shifts.py @@ -0,0 +1,842 @@ +""" +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 +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 ..manager import CourseShiftManager +from ..models import CourseShiftGroup, CourseShiftGroupMembership, CourseUserGroup, CourseShiftSettings + + +def date_shifted(days): + return (datetime.datetime.now() + datetime.timedelta(days=days)).date() + + +@attr(shard=2) +class TestCourseShiftGroup(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(TestCourseShiftGroup, self).setUp() + date = datetime.datetime.now() + self.course = ToyCourseFactory.create(start=date) + self.course_key = self.course.id + + def _no_groups_check(self): + """ + Checks that there is no groups. + Used at start and anywhere needed + """ + groups = CourseUserGroup.objects.filter(course_id=self.course_key) + self.assertTrue( + len(groups) == 0, + "Course has user groups at start" + ) + shift_groups = CourseShiftGroup.get_course_shifts(self.course_key) + self.assertTrue( + len(shift_groups) == 0, + "Course has shift groups at start" + ) + + 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_creates_cug(self): + """ + Checks that CourseUserGroup is created when CSG created + """ + self._no_groups_check() + + name = "test_shift_group" + test_shift_group, created = CourseShiftGroup.create(name, 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( + correct, + "Should be only {}, found:{}".format( + str(test_shift_group), + str(shift_groups) + )) + + self._delete_all_shifts() + + 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() + 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_create_same_course_and_date_error(self): + """ + 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() + + def test_create_same_course_dif_date_ok(self): + """ + 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) + 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 + + self.assertTrue(correct, "Should be test_shift_group and test_shift_group2, found:{}".format( + str(groups) + )) + self._delete_all_shifts() + + 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) + + 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(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() + + +@attr(shard=2) +class TestCourseShiftGroupMembership(ModuleStoreTestCase): + MODULESTORE = TEST_DATA_MIXED_MODULESTORE + + def setUp(self): + """ + Make sure that course is reloaded every time--clear out the modulestore. + """ + 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) + + self.second_course = ToyCourseFactory.create(org="neworg") + self.second_course_key = self.second_course.id + + def _delete_all_memberships(self): + memberships = CourseShiftGroupMembership.objects.all() + for m in memberships: + m.delete() + + def _check_no_memberships(self): + mems = CourseShiftGroupMembership.objects.all() + self.assertTrue(len(mems) == 0) + + 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) + + 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) + + 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.objects.create(user=self.user, course_shift_group=group2) + group2.delete() + self._delete_all_memberships() + + 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_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() + + 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() + + 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) + + membership = CourseShiftGroupMembership.transfer_user(self.user, None, self.group) + self.assertTrue(self.user in self.group.users.all()) + + 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) + + 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() + + 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) + )) + + +class EnrollClsFields(object): + _ENROLL_BEFORE = 7 + _ENROLL_AFTER = 0 + _PERIOD = 20 + _COURSE_DATE_START = 14 + + +@attr(shard=2) +class TestCourseShiftSettings(ModuleStoreTestCase, EnrollClsFields): + """ + 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=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=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() + 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): + shift_groups = CourseShiftGroup.objects.all() + for x in shift_groups: + x.delete() + + def _number_of_shifts(self, custom_period): + """ + 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 + # 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. + + course_started_days_ago += self._ENROLL_BEFORE + shifts_number = int(course_started_days_ago / custom_period) + shifts_number += 1 + return shifts_number + + 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:{}".format(shift_groups) + ) + + def test_settings_generation_and_saving(self): + """ + Tests that settings got by get_course_settings saved correctly + """ + self._settings_setup(autostart=False) + settings = CourseShiftSettings.get_course_settings(self.course_key) + + self.assertTrue(settings.is_shift_enabled == True) + self.assertTrue(settings.is_autostart == False) + self.assertTrue(settings.autostart_period_days == self._PERIOD) + + def test_autostart_generation_one(self): + """ + Single start should be generated - default shift at start + """ + custom_period = 30 + 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))) + + def test_autostart_generation_two(self): + """ + Two shifts must be generated automatically, default and one more + """ + 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) == shifts_number, "Must be {} shifts, found:{}".format( + shifts_number, + str(course_shifts))) + + def test_autostart_generation_three(self): + """ + Three shifts must be generated automatically + """ + 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) == shifts_number, "Must be {} shifts, found:{}".format( + shifts_number, + str(course_shifts))) + + 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, EnrollClsFields): + 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.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(): + 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) + correct = len(shift_groups) == 0 + message = str(shift_groups) + if not correct: + self._delete_groups() + self.assertTrue( + correct, + message + ) + + def test_get_user_course_shift(self): + """ + Tests method get_user_course_shift + """ + self._settings_setup() + user = self.user + shift_manager = CourseShiftManager(course_key=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_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( + str(shift_group), + str(test_a_shift_group) + )) + self._delete_groups() + self._no_groups_check() + + def test_get_user_course_shift_disabled(self): + 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.assertTrue(shift_group is None, "User shift group is {}, should be None".format(str(shift_group))) + + self.shift_settings.is_shift_enabled = True + + 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) + + 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) + )) + + 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(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( + 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) + + 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 + """ + self._no_groups_check() + user = self.user + shift_manager = CourseShiftManager(self.course_key) + + group1 = shift_manager.create_shift() + group2 = shift_manager.create_shift(date_shifted(days=-5)) + + 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.enroll_user( + user=user, + shift=group2 + ) + 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.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) + )) + self._delete_groups() + + def test_enroll_user_error_course_key(self): + """ + Checks that error is raised when enroll_user + gets shift from other course + """ + self._no_groups_check() + user = self.user + shift_manager = CourseShiftManager(self.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): + shift_manager.enroll_user(user, other_group) + self._delete_groups() + + 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() + + 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) + ) + ) + + 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)) + ) diff --git a/course_shifts/urls.py b/course_shifts/urls.py new file mode 100644 index 0000000..60fc0f4 --- /dev/null +++ b/course_shifts/urls.py @@ -0,0 +1,20 @@ +""" +URLs for course shifts app +""" +from django.conf import settings +from django.conf.urls import patterns, url + +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(), + name='list'), + +) 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', + ], +)