diff --git a/a-plus/settings.py b/a-plus/settings.py index c89bac2e5..eb39a9fc2 100644 --- a/a-plus/settings.py +++ b/a-plus/settings.py @@ -156,14 +156,6 @@ "django.contrib.messages.context_processors.messages", ) -AUTHENTICATION_BACKENDS = [ - 'django.contrib.auth.backends.ModelBackend', -] -if 'shibboleth_login' in INSTALLED_APPS: - AUTHENTICATION_BACKENDS += 'shibboleth_login.auth_backend.ShibbolethAuthBackend' -if 'social.apps.django_app.default' in INSTALLED_APPS: - AUTHENTICATION_BACKENDS += 'social.backends.google.GoogleOAuth2' - FILE_UPLOAD_HANDLERS = ( #"django.core.files.uploadhandler.MemoryFileUploadHandler", "django.core.files.uploadhandler.TemporaryFileUploadHandler", @@ -256,3 +248,11 @@ pass INSTALLED_APPS = INSTALLED_LOGIN_APPS + INSTALLED_APPS + +AUTHENTICATION_BACKENDS = ( + 'django.contrib.auth.backends.ModelBackend', +) +if 'shibboleth_login' in INSTALLED_APPS: + AUTHENTICATION_BACKENDS += ('shibboleth_login.auth_backend.ShibbolethAuthBackend',) +if 'social.apps.django_app.default' in INSTALLED_APPS: + AUTHENTICATION_BACKENDS += ('social.backends.google.GoogleOAuth2',) diff --git a/apps/templatetags/apps.py b/apps/templatetags/apps.py index 0e8566980..3348082bc 100644 --- a/apps/templatetags/apps.py +++ b/apps/templatetags/apps.py @@ -17,11 +17,12 @@ def plugin_renderers(user, some_model, view_name=None): """ Builds the plugin renderers for a view. """ + profile = user.userprofile if user.is_authenticated() else None if isinstance(some_model, CourseInstance): return build_plugin_renderers( some_model.plugins.all(), view_name or "course_instance", - user_profile=user.userprofile, + user_profile=profile, course_instance=some_model, ) if isinstance(some_model, BaseExercise): @@ -29,7 +30,7 @@ def plugin_renderers(user, some_model, view_name=None): return build_plugin_renderers( course_instance.plugins.all(), view_name or "exercise", - user_profile=user.userprofile, + user_profile=profile, exercise=some_model, course_instance=course_instance, ) @@ -38,7 +39,7 @@ def plugin_renderers(user, some_model, view_name=None): return build_plugin_renderers( course_instance.plugins.all(), view_name or "submission", - user_profile=user.userprofile, + user_profile=profile, submission=some_model, exercise=some_model.exercise, course_instance=course_instance, diff --git a/course/migrations/0015_auto_20160121_1544.py b/course/migrations/0015_auto_20160121_1544.py new file mode 100644 index 000000000..7ef72667a --- /dev/null +++ b/course/migrations/0015_auto_20160121_1544.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'), + ('course', '0014_auto_20160106_1303'), + ] + + operations = [ + migrations.AddField( + model_name='courseinstance', + name='students', + field=models.ManyToManyField(blank=True, related_name='enrolled', to='userprofile.UserProfile'), + preserve_default=True, + ), + migrations.AddField( + model_name='courseinstance', + name='submission_access', + field=models.IntegerField(choices=[(1, 'Internal and external users'), (2, 'Only external users'), (3, 'Only internal users')], default=3), + preserve_default=True, + ), + migrations.AddField( + model_name='courseinstance', + name='view_access', + field=models.IntegerField(choices=[(0, 'Public to internet'), (1, 'Internal and external users'), (2, 'Only external users'), (3, 'Only internal users')], default=3), + preserve_default=True, + ), + ] diff --git a/course/models.py b/course/models.py index 874eedc9f..46b004186 100644 --- a/course/models.py +++ b/course/models.py @@ -102,6 +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) starting_time = models.DateTimeField() ending_time = models.DateTimeField() image = models.ImageField(blank=True, null=True, upload_to=build_upload_dir) @@ -125,6 +136,7 @@ class CourseInstance(models.Model): ), default=1) configure_url = models.URLField(blank=True) assistants = models.ManyToManyField(UserProfile, related_name="assisting_courses", blank=True) + students = models.ManyToManyField(UserProfile, related_name="enrolled", blank=True) technical_error_emails = models.CharField(max_length=255, blank=True, help_text=_("By default exercise errors are reported to teacher " "email addresses. Set this field as comma separated emails to " @@ -158,6 +170,16 @@ 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() diff --git a/course/viewbase.py b/course/viewbase.py index b96bd7cb5..45a1ecba4 100644 --- a/course/viewbase.py +++ b/course/viewbase.py @@ -48,6 +48,10 @@ def get_resource_objects(self): self.is_course_staff = self.is_teacher or self.is_assistant self.note("instance", "is_assistant", "is_course_staff") + # Loosen the access mode if instance is public. + if self.instance.view_access == 0 and self.access_mode == ACCESS.STUDENT: + self.access_mode = ACCESS.ANONYMOUS + def access_control(self): super().access_control() if self.access_mode >= ACCESS.ASSISTANT: @@ -55,11 +59,20 @@ def access_control(self): messages.error(self.request, _("Only course staff shall pass.")) raise PermissionDenied() - elif not self.instance.is_visible_to(self.request.user): + else: + if not self.instance.is_visible_to(self.request.user): messages.error(self.request, _("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() + class CourseInstanceBaseView(CourseInstanceMixin, BaseTemplateView): pass diff --git a/edit_course/course_forms.py b/edit_course/course_forms.py index c4c245550..9d30cfbcb 100644 --- a/edit_course/course_forms.py +++ b/edit_course/course_forms.py @@ -71,6 +71,8 @@ class Meta: 'language', 'starting_time', 'ending_time', + 'view_access', + 'submission_access', 'assistants', 'technical_error_emails', ] diff --git a/exercise/exercise_models.py b/exercise/exercise_models.py index 7fc45a0d2..241fe543e 100644 --- a/exercise/exercise_models.py +++ b/exercise/exercise_models.py @@ -160,8 +160,8 @@ def load(self, request, students, url_name="exercise"): """ Loads the learning object page. """ - if not self.exercise.service_url: - return ExercisePage(self.exercise) + if not self.service_url: + return ExercisePage(self) if self.id and self.content \ and self.course_instance.ending_time < timezone.now(): page = ExercisePage(self) @@ -217,6 +217,17 @@ def clean(self): def is_submittable(self): return True + def all_can_submit(self, students): + instance = self.course_instance + if not students: + return instance.has_submission_access(None) + 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, "" + def one_has_access(self, students, when=None): """ Checks if any of the users can submit taking the granted extra time @@ -281,31 +292,36 @@ def is_submission_allowed(self, students): warnings.append(_('The course is archived. Exercises are offline.')) success = False else: - if not self.one_has_access(students): + 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( - _('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(), - )) + _('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 success, warnings @@ -318,7 +334,7 @@ def get_total_submitter_count(self): def get_async_hash(self, students): student_str = "-".join( sorted(str(userprofile.id) for userprofile in students) - ) + ) if students else "-" identifier = "{}.{:d}".format(student_str, self.id) hash_key = hmac.new( settings.SECRET_KEY.encode('utf-8'), @@ -395,7 +411,7 @@ def _get_lti(self, user, host, add={}): def get_load_url(self, request, students, url_name="exercise"): url = super().get_load_url(request, students, url_name=url_name) - if self.lti_service: + if self.lti_service and students: lti = self._get_lti(students[0].user, request.get_host()) return lti.sign_get_query(url) return url diff --git a/exercise/migrations/0012_auto_20151218_0858.py b/exercise/migrations/0012_auto_20151218_0858.py index 2b352752c..b6af2516b 100644 --- a/exercise/migrations/0012_auto_20151218_0858.py +++ b/exercise/migrations/0012_auto_20151218_0858.py @@ -32,8 +32,4 @@ class Migration(migrations.Migration): field=models.CharField(validators=[django.core.validators.RegexValidator(regex='^[\\w\\-\\.]*$')], max_length=255, help_text='Input an URL identifier for this object.'), preserve_default=True, ), - migrations.AlterUniqueTogether( - name='learningobject', - unique_together=set([('course_module', 'parent', 'url')]), - ), ] diff --git a/exercise/migrations/0013_auto_20151222_1320.py b/exercise/migrations/0013_auto_20151222_1320.py index 1c79a162c..2af6e2401 100644 --- a/exercise/migrations/0013_auto_20151222_1320.py +++ b/exercise/migrations/0013_auto_20151222_1320.py @@ -11,6 +11,10 @@ class Migration(migrations.Migration): ] operations = [ + migrations.AlterUniqueTogether( + name='learningobject', + unique_together=set([('course_module', 'parent', 'url')]), + ), migrations.AlterField( model_name='learningobject', name='status', diff --git a/exercise/views.py b/exercise/views.py index 786a5a9d5..4da7ae71d 100644 --- a/exercise/views.py +++ b/exercise/views.py @@ -42,13 +42,12 @@ def dispatch(self, request, *args, **kwargs): def get_after_new_submission(self): self.submissions = self.exercise.get_submissions_for_student( - self.profile) + self.profile) if self.profile else [] self.summary = UserExerciseSummary(self.exercise, self.request.user) self.note("submissions", "summary") def get(self, request, *args, **kwargs): self.handle() - print('get', self.exercise) students = self.get_students() if self.exercise.is_submittable(): self.submission_check(students) @@ -106,7 +105,9 @@ def post(self, request, *args, **kwargs): def get_students(self): # TODO: group support - return (self.profile,) + if self.profile: + return (self.profile,) + return () def submission_check(self, students): ok, issues = self.exercise.is_submission_allowed(students) diff --git a/lib/email_messages.py b/lib/email_messages.py index ac04b395a..6873f75de 100644 --- a/lib/email_messages.py +++ b/lib/email_messages.py @@ -1,8 +1,11 @@ +import logging import traceback from django.conf import settings from django.core.mail import send_mail from django.core.urlresolvers import reverse +logger = logging.getLogger('lib.email_messages') + def email_course_error(request, exercise, message, exception=True): """ @@ -30,4 +33,7 @@ def email_course_error(request, exercise, message, exception=True): error_trace=error_trace, request_fields=repr(request)) if recipients: - send_mail(subject, body, settings.SERVER_EMAIL, recipients, True) + try: + send_mail(subject, body, settings.SERVER_EMAIL, recipients, True) + except Exception as e: + logger.exception('Failed to send error emails.') diff --git a/notification/views.py b/notification/views.py index 99d4e4a78..15e803eb1 100644 --- a/notification/views.py +++ b/notification/views.py @@ -1,5 +1,6 @@ from course.viewbase import CourseInstanceBaseView from lib.viewbase import PagerMixin +from userprofile.viewbase import ACCESS from .models import NotificationSet @@ -8,6 +9,12 @@ class NotificationsView(PagerMixin, CourseInstanceBaseView): template_name = "notification/notifications.html" ajax_template_name = "notification/_notifications_list.html" + def get_resource_objects(self): + super().get_resource_objects() + + # Always require logged in student + self.access_mode = ACCESS.STUDENT + def get_common_objects(self): super().get_common_objects() notifications_set = NotificationSet.get_course( diff --git a/userprofile/models.py b/userprofile/models.py index abb7ecf56..78e865158 100644 --- a/userprofile/models.py +++ b/userprofile/models.py @@ -55,9 +55,9 @@ def shortname(self): return self.user.username @property - def is_mooc(self): + def is_external(self): """ - Is this a mooc account rather than from school shibboleth login. + Is this an external rather than internal account. """ return self.user.social_auth.exists()