From a6d64778adfe2ba020abe606b14fb3225a5dfe86 Mon Sep 17 00:00:00 2001 From: Teemu Lehtinen Date: Mon, 25 Jan 2016 09:06:39 +0200 Subject: [PATCH] Add enrollment to courses --- a-plus/settings.py | 2 + course/long_urls.py | 3 + course/migrations/0016_auto_20160124_2214.py | 34 ++++++ course/models.py | 53 ++++---- course/templates/course/index.html | 7 ++ course/templates/course/toc.html | 24 ++++ course/tree.py | 2 +- course/viewbase.py | 35 ++++-- course/views.py | 29 ++++- edit_course/course_forms.py | 4 +- exercise/exercise_models.py | 114 +++++++++++------- .../migrations/0015_auto_20160124_2139.py | 33 +++++ exercise/views.py | 17 +++ templates/403.html | 2 + userprofile/viewbase.py | 3 +- 15 files changed, 281 insertions(+), 81 deletions(-) create mode 100644 course/migrations/0016_auto_20160124_2214.py create mode 100644 exercise/migrations/0015_auto_20160124_2139.py diff --git a/a-plus/settings.py b/a-plus/settings.py index 5e84a0585..36071bafc 100644 --- a/a-plus/settings.py +++ b/a-plus/settings.py @@ -37,6 +37,8 @@ LOGIN_TITLE_TEXT = '' LOGIN_BODY_TEXT = '' LOGIN_BUTTON_TEXT = 'Maintenance login' +INTERNAL_USER_LABEL = 'Aalto' +EXTERNAL_USER_LABEL = 'MOOC' WELCOME_TEXT_FI = 'A+ verkkopohjainen oppimisympäristö' SHIBBOLETH_TITLE_TEXT_FI = 'Aalto-yliopiston käyttäjät' diff --git a/course/long_urls.py b/course/long_urls.py index 571dd334e..80cb4c0e6 100644 --- a/course/long_urls.py +++ b/course/long_urls.py @@ -5,6 +5,9 @@ # These need to be listed before the exercise URL routings. urlpatterns = [ + url(USER_URL_PREFIX + r'enroll/$', + views.Enroll.as_view(), + name='enroll'), url(USER_URL_PREFIX + r'export-calendar/$', views.CalendarExport.as_view(), name='export-calendar'), diff --git a/course/migrations/0016_auto_20160124_2214.py b/course/migrations/0016_auto_20160124_2214.py new file mode 100644 index 000000000..8e265b718 --- /dev/null +++ b/course/migrations/0016_auto_20160124_2214.py @@ -0,0 +1,34 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import models, migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('course', '0015_auto_20160121_1544'), + ] + + operations = [ + migrations.RemoveField( + model_name='courseinstance', + name='submission_access', + ), + migrations.RemoveField( + model_name='courseinstance', + name='view_access', + ), + migrations.AddField( + model_name='courseinstance', + name='enrollment_audience', + field=models.IntegerField(choices=[(1, 'Internal users'), (2, 'External users'), (3, 'Internal and external users')], default=1), + preserve_default=True, + ), + migrations.AddField( + model_name='courseinstance', + name='view_content_to', + field=models.IntegerField(choices=[(1, 'Enrolled students'), (2, 'Enrollment audience'), (3, 'All registered users'), (4, 'Public to internet')], default=2), + preserve_default=True, + ), + ] diff --git a/course/models.py b/course/models.py index 46b004186..4eaa301b1 100644 --- a/course/models.py +++ b/course/models.py @@ -102,17 +102,17 @@ class CourseInstance(models.Model): validators=[RegexValidator(regex="^[\w\-\.]*$")], help_text=_("Input an URL identifier for this course instance.")) visible_to_students = models.BooleanField(default=True) - view_access = models.IntegerField(choices=( - (0, _('Public to internet')), - (1, _('Internal and external users')), - (2, _('Only external users')), - (3, _('Only internal users')), - ), default=3) - submission_access = models.IntegerField(choices=( - (1, _('Internal and external users')), - (2, _('Only external users')), - (3, _('Only internal users')), - ), default=3) + enrollment_audience = models.IntegerField(choices=( + (1, _('Internal users')), + (2, _('External users')), + (3, _('Internal and external users')), + ), default=1) + view_content_to = models.IntegerField(choices=( + (1, _('Enrolled students')), + (2, _('Enrollment audience')), + (3, _('All registered users')), + (4, _('Public to internet')), + ), default=2) starting_time = models.DateTimeField() ending_time = models.DateTimeField() image = models.ImageField(blank=True, null=True, upload_to=build_upload_dir) @@ -170,16 +170,6 @@ def save(self, *args, **kwargs): if self.image: resize_image(self.image.path, (800,600)) - def has_submission_access(self, user): - if not user or not user.is_authenticated(): - return False, _("You need to login to submit exercises to this course.") - if self.submission_access == 2: - return user.userprofile.is_external, \ - _("You need to login as external student to submit exercises to this course.") - if self.submission_access > 2: - return not user.userprofile.is_external, \ - _("You need to login as internal student to submit exercises to this course.") - def is_assistant(self, user): return user and user.is_authenticated() \ and self.assistants.filter(id=user.userprofile.id).exists() @@ -190,12 +180,31 @@ def is_teacher(self, user): def is_course_staff(self, user): return self.is_teacher(user) or self.is_assistant(user) + def is_student(self, user): + return user and user.is_authenticated() \ + and self.students.filter(id=user.userprofile.id).exists() + + def is_enrollable(self, user): + if user and user.is_authenticated(): + if self.enrollment_audience == 1: + return not user.userprofile.is_external + if self.enrollment_audience == 2: + return user.userprofile.is_external + return True + return False + + def enroll_student(self, user): + if user and user.is_authenticated() and not self.is_course_staff(user): + self.students.add(user.userprofile) + def get_course_staff_profiles(self): return UserProfile.objects.filter(Q(teaching_courses=self.course) | Q(assisting_courses=self))\ .distinct() def get_student_profiles(self): - # TODO: enrollment should be designed + return self.students.all() + + def get_submitted_profiles(self): return UserProfile.objects.filter(submissions__exercise__course_module__course_instance=self)\ .distinct()\ .exclude(assisting_courses=self)\ diff --git a/course/templates/course/index.html b/course/templates/course/index.html index fc1be7c9a..4d70d710b 100644 --- a/course/templates/course/index.html +++ b/course/templates/course/index.html @@ -38,6 +38,13 @@

{{ instance.starting_time|date:"N j, Y" }} – {{ instance.ending_time|date:"N j, Y" }} +
+ {% if instance.enrollment_audience == 1 or instance.enrollment_audience == 3 %} + {{ internal_user_label|safe }} + {% endif %} + {% if instance.enrollment_audience == 2 or instance.enrollment_audience == 3 %} + {{ external_user_label|safe }} + {% endif %}

diff --git a/course/templates/course/toc.html b/course/templates/course/toc.html index d5108727c..9cd0fe451 100644 --- a/course/templates/course/toc.html +++ b/course/templates/course/toc.html @@ -8,9 +8,31 @@ {% block breadcrumb %}{% endblock %} {% block coursecontent %} + +{% if not enrolled %} +
+{% if enrollable %} +
+ {% csrf_token %} + {% if instance.view_content_to > 1 %} + {% trans "In order to submit exercises, you must enroll to the course." %} + {% else %} + {% trans "In order to submit exercises and see material, you must enroll to the course." %} + {% endif %} + +
+{% elif profile %} +{% trans "Unfortunately, you cannot enroll to this course, but you may explore the course material." %} +{% else %} +{% trans "Login is required to submit exercises, but you may anonymously explore the course material." %} +{% endif %} +
+{% endif %} +
{{ instance.description|safe }}
+ {% if instance.index_mode == 1 %}
    {% for module in instance.course_modules.all %} @@ -39,9 +61,11 @@

    {% endif %} {% endfor %}

+ {% else %} {% user_results %} {% endif %} +
{{ instance.footer|safe }}
diff --git a/course/tree.py b/course/tree.py index 0f585f35b..41f4f947a 100644 --- a/course/tree.py +++ b/course/tree.py @@ -13,7 +13,7 @@ def children(self, parent_id=None, show_hidden=False): if show_hidden: return [o for o in self.objects if o.parent_id == parent_id] return [o for o in self.objects if o.parent_id == parent_id \ - and o.status != 'hidden' and o.status != 'unlisted'] + and o.status in ('ready', 'maintenance')] def parent(self, ref): if ref.parent_id: diff --git a/course/viewbase.py b/course/viewbase.py index 45a1ecba4..c25d3991b 100644 --- a/course/viewbase.py +++ b/course/viewbase.py @@ -48,11 +48,13 @@ def get_resource_objects(self): self.is_course_staff = self.is_teacher or self.is_assistant self.note("instance", "is_assistant", "is_course_staff") + def access_control(self): + # Loosen the access mode if instance is public. - if self.instance.view_access == 0 and self.access_mode == ACCESS.STUDENT: + if self.instance.view_content_to == 4 and \ + self.access_mode in (ACCESS.STUDENT, ACCESS.ENROLL): self.access_mode = ACCESS.ANONYMOUS - def access_control(self): super().access_control() if self.access_mode >= ACCESS.ASSISTANT: if not self.is_course_staff: @@ -65,19 +67,34 @@ def access_control(self): _("The resource is not currently visible.")) raise PermissionDenied() - # View access. - if self.instance.view_access == 2 and not self.profile.is_external: - messages.error(self.request, _("This course is only for external students.")) - raise PermissionDenied() - if self.instance.view_access > 2 and self.profile.is_external: - messages.error(self.request, _("This course is only for internal students.")) - raise PermissionDenied() + # View content access. + if not self.is_course_staff: + if self.instance.view_content_to == 1 and self.access_mode > ACCESS.ENROLL: + if not self.instance.is_student(self.request.user): + messages.error(self.request, _("Only enrolled students shall pass.")) + raise PermissionDenied() + elif self.instance.view_content_to < 3: + if self.instance.enrollment_audience == 1 and self.profile.is_external: + messages.error(self.request, _("This course is only for internal students.")) + raise PermissionDenied() + if self.instance.enrollment_audience == 2 and not self.profile.is_external: + messages.error(self.request, _("This course is only for external students.")) + raise PermissionDenied() class CourseInstanceBaseView(CourseInstanceMixin, BaseTemplateView): pass +class EnrollableViewMixin(CourseInstanceMixin): + access_mode = ACCESS.ENROLL + + def get_common_objects(self): + self.enrolled = self.profile and self.instance.is_student(self.profile.user) + self.enrollable = self.profile and self.instance.is_enrollable(self.profile.user) + self.note('enrolled', 'enrollable') + + class CourseModuleMixin(CourseInstanceMixin): module_kw = "module" diff --git a/course/views.py b/course/views.py index 7582ad89b..a818c910d 100644 --- a/course/views.py +++ b/course/views.py @@ -11,11 +11,12 @@ from django.utils.translation import ugettext_lazy as _ from django.views.generic.base import View +from exercise.models import LearningObject from lib.helpers import settings_text -from lib.viewbase import BaseRedirectView +from lib.viewbase import BaseTemplateView, BaseRedirectView from userprofile.viewbase import ACCESS, UserProfileView from .viewbase import CourseBaseView, CourseInstanceBaseView, \ - CourseModuleBaseView, CourseInstanceMixin + CourseModuleBaseView, CourseInstanceMixin, EnrollableViewMixin from .models import CourseInstance @@ -29,8 +30,10 @@ class HomeView(UserProfileView): def get_common_objects(self): super().get_common_objects() self.welcome_text = settings_text(self.request, 'WELCOME_TEXT') + self.internal_user_label = settings_text(self.request, 'INTERNAL_USER_LABEL') + self.external_user_label = settings_text(self.request, 'EXTERNAL_USER_LABEL') self.instances = CourseInstance.objects.get_active(self.request.user) - self.note("welcome_text", "instances") + self.note("welcome_text", "internal_user_label", "external_user_label", "instances") class ArchiveView(UserProfileView): @@ -51,10 +54,28 @@ def get_common_objects(self): self.note("instances") -class InstanceView(CourseInstanceBaseView): +class InstanceView(EnrollableViewMixin, BaseTemplateView): template_name = "course/toc.html" +class Enroll(EnrollableViewMixin, BaseRedirectView): + + def post(self, request, *args, **kwargs): + self.handle() + + if self.enrolled or not self.enrollable: + messages.error(self.request, _("You cannot enroll, or have already enrolled, to this course.")) + raise PermissionDenied() + + # Support enrollment questionnaires. + exercise = LearningObject.objects.find_enrollment_exercise(self.instance) + if exercise: + return self.redirect(exercise.get_absolute_url()) + + self.instance.enroll_student(self.request.user) + return self.redirect(self.instance.get_absolute_url()) + + class ModuleView(CourseModuleBaseView): template_name = "course/module.html" diff --git a/edit_course/course_forms.py b/edit_course/course_forms.py index 9d30cfbcb..0ba836573 100644 --- a/edit_course/course_forms.py +++ b/edit_course/course_forms.py @@ -71,8 +71,8 @@ class Meta: 'language', 'starting_time', 'ending_time', - 'view_access', - 'submission_access', + 'enrollment_audience', + 'view_content_to', 'assistants', 'technical_error_emails', ] diff --git a/exercise/exercise_models.py b/exercise/exercise_models.py index 241fe543e..278ca3414 100644 --- a/exercise/exercise_models.py +++ b/exercise/exercise_models.py @@ -26,17 +26,31 @@ from .protocol.exercise_page import ExercisePage +class LearningObjectManager(models.Manager): + + def get_queryset(self): + return super().get_queryset().defer('description', 'content') + + def find_enrollment_exercise(self, course_instance): + return self.filter( + course_module__course_instance=course_instance, + status='enrollment' + ).first() + + class LearningObject(ModelWithInheritance): """ All learning objects inherit this model. """ STATUS_READY = 'ready' STATUS_UNLISTED = 'unlisted' + STATUS_ENROLLMENT = 'enrollment' STATUS_HIDDEN = 'hidden' STATUS_MAINTENANCE = 'maintenance' STATUS_CHOICES = ( (STATUS_READY, _("Ready")), (STATUS_UNLISTED, _("Unlisted in table of contents")), + (STATUS_ENROLLMENT, _("Enrollment questions")), (STATUS_HIDDEN, _("Hidden from non course staff")), (STATUS_MAINTENANCE, _("Maintenance")), ) @@ -58,6 +72,7 @@ class LearningObject(ModelWithInheritance): content = models.TextField(blank=True) content_head = models.TextField(blank=True) content_time = models.DateTimeField(blank=True, null=True) + objects = LearningObjectManager() class Meta: app_label = "exercise" @@ -180,6 +195,15 @@ def load(self, request, students, url_name="exercise"): return page +class LearningObjectDisplay(models.Model): + """ + Records views of learning objects. + """ + learning_object = models.ForeignKey(LearningObject) + profile = models.ForeignKey(UserProfile) + timestamp = models.DateTimeField(auto_now_add=True) + + class CourseChapter(LearningObject): """ Chapters can offer and organize learning material as one page chapters. @@ -217,16 +241,14 @@ def clean(self): def is_submittable(self): return True - def all_can_submit(self, students): - instance = self.course_instance + def all_have_enrolled(self, students): if not students: - return instance.has_submission_access(None) + return False for profile in students: - # TODO: check all students are enrolled - ok, warning = instance.has_submission_access(profile.user) - if not ok: - return ok, warning - return True, "" + if not (self.course_instance.is_student(profile.user) \ + or self.course_instance.is_course_staff(profile.user)): + return False + return True def one_has_access(self, students, when=None): """ @@ -287,43 +309,51 @@ def is_submission_allowed(self, students): @return: (success_flag, warning_message_list) """ + success, warnings = self._check_submission_allowed(students) + return success, list(str(w) for w in warnings) + + def _check_submission_allowed(self, students): warnings = [] + if self.course_instance.ending_time < timezone.now(): warnings.append(_('The course is archived. Exercises are offline.')) - success = False - else: - check, message = self.all_can_submit(students) - if not check: - warnings.append(message) - success = False - else: - if not self.one_has_access(students): - warnings.append( - _('This exercise is not open for submissions.')) - if not (self.min_group_size <= len(students) <= self.max_group_size): - warnings.append( - _('This exercise can be submitted in groups of %(min)d to %(max)d students.' - 'The size of your current group is %(size)d.') % { - 'min': self.min_group_size, - 'max': self.max_group_size, - 'size': len(students), - }) - if not self.one_has_submissions(students): - warnings.append( - _('You have used the allowed amount of submissions for this exercise.')) - success = len(warnings) == 0 \ - or all(self.course_instance.is_course_staff(p.user) for p in students) - - # If late submission is open, notify the student about point reduction. - if self.course_module.is_late_submission_open(): - warnings.append( - _('Deadline for the exercise has passed. Late submissions are allowed until' - '{date} but points are only worth {percent:d}%.').format( - date=date_format(self.course_module.late_submission_deadline), - percent=self.course_module.get_late_submission_point_worth(), - )) - - warnings = list(str(warning) for warning in warnings) + return False, warnings + + if self.status == LearningObject.STATUS_ENROLLMENT: + if not len(students) == 1 and \ + not self.course_instance.is_enrollable(students[0].user): + warnings.append(_('You cannot enroll to the course.')) + return False, warnings + elif not self.all_have_enrolled(students): + warnings.append(_('You must enroll at course home to submit exercises.')) + return False, warnings + + # Check different submission limits. + if not self.one_has_access(students): + warnings.append(_('This exercise is not open for submissions.')) + if not (self.min_group_size <= len(students) <= self.max_group_size): + warnings.append( + _('This exercise can be submitted in groups of %(min)d to %(max)d students.' + 'The size of your current group is %(size)d.') % { + 'min': self.min_group_size, + 'max': self.max_group_size, + 'size': len(students), + }) + if not self.one_has_submissions(students): + warnings.append( + _('You have used the allowed amount of submissions for this exercise.')) + + success = len(warnings) == 0 \ + or all(self.course_instance.is_course_staff(p.user) for p in students) + + # If late submission is open, notify the student about point reduction. + if self.course_module.is_late_submission_open(): + warnings.append( + _('Deadline for the exercise has passed. Late submissions are allowed until' + '{date} but points are only worth {percent:d}%.').format( + date=date_format(self.course_module.late_submission_deadline), + percent=self.course_module.get_late_submission_point_worth(), + )) return success, warnings def get_total_submitter_count(self): diff --git a/exercise/migrations/0015_auto_20160124_2139.py b/exercise/migrations/0015_auto_20160124_2139.py new file mode 100644 index 000000000..af15e49eb --- /dev/null +++ b/exercise/migrations/0015_auto_20160124_2139.py @@ -0,0 +1,33 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import models, migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('userprofile', '0002_auto_20150427_1717'), + ('exercise', '0014_ltiexercise'), + ] + + operations = [ + migrations.CreateModel( + name='LearningObjectDisplay', + fields=[ + ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), + ('timestamp', models.DateTimeField(auto_now_add=True)), + ('learning_object', models.ForeignKey(to='exercise.LearningObject')), + ('profile', models.ForeignKey(to='userprofile.UserProfile')), + ], + options={ + }, + bases=(models.Model,), + ), + migrations.AlterField( + model_name='learningobject', + name='status', + field=models.CharField(choices=[('ready', 'Ready'), ('unlisted', 'Unlisted in table of contents'), ('enrollment', 'Enrollment questions'), ('hidden', 'Hidden from non course staff'), ('maintenance', 'Maintenance')], max_length=32, default='ready'), + preserve_default=True, + ), + ] diff --git a/exercise/views.py b/exercise/views.py index 4da7ae71d..66ed7ee39 100644 --- a/exercise/views.py +++ b/exercise/views.py @@ -11,6 +11,8 @@ from course.viewbase import CourseInstanceBaseView from lib.viewbase import BaseRedirectMixin +from userprofile.viewbase import ACCESS +from .models import LearningObjectDisplay from .presentation.summary import UserExerciseSummary from .protocol.exercise_page import ExercisePage from .submission_models import SubmittedFile, Submission @@ -40,6 +42,11 @@ class ExerciseView(BaseRedirectMixin, ExerciseBaseView): def dispatch(self, request, *args, **kwargs): return super().dispatch(request, *args, **kwargs) + def access_control(self): + if self.exercise.status == 'enrollment' and self.access_mode == ACCESS.STUDENT: + self.access_mode = ACCESS.ENROLL + super().access_control() + def get_after_new_submission(self): self.submissions = self.exercise.get_submissions_for_student( self.profile) if self.profile else [] @@ -66,6 +73,11 @@ def get(self, request, *args, **kwargs): page = self.exercise.as_leaf_class().load(request, students, url_name=self.post_url_name) + + if self.profile: + LearningObjectDisplay.objects.get_or_create( + learning_object=self.exercise, profile=self.profile) + return self.response(page=page, students=students) def post(self, request, *args, **kwargs): @@ -86,6 +98,11 @@ def post(self, request, *args, **kwargs): page = self.exercise.grade(request, new_submission, url_name=self.post_url_name) + # Enroll after succesfull enrollment exercise. + if self.exercise.status == 'enrollment' \ + and new_submission.status == Submission.STATUS_READY: + self.instance.enroll_student(self.request.user) + # Redirect non AJAX normally to submission page. if not request.is_ajax() and "__r" not in request.GET: return self.redirect(new_submission.get_absolute_url() + diff --git a/templates/403.html b/templates/403.html index c7fd24d0d..720faed01 100644 --- a/templates/403.html +++ b/templates/403.html @@ -1,4 +1,5 @@ {% load i18n %} +{% load staticfiles %} @@ -6,6 +7,7 @@ 403 Forbidden +
diff --git a/userprofile/viewbase.py b/userprofile/viewbase.py index 2b1bcffe9..3dec08f93 100644 --- a/userprofile/viewbase.py +++ b/userprofile/viewbase.py @@ -23,7 +23,8 @@ def handle_no_permission(self): class ACCESS(object): ANONYMOUS = 0 - STUDENT = 1 + ENROLL = 1 + STUDENT = 3 ASSISTANT = 5 GRADING = 6 TEACHER = 10