diff --git a/course/admin.py b/course/admin.py index a025637283e76a3971ddc64505d3ff0d9dab9764..6ba71b40ec51988424adff6958015967354acf07 100644 --- a/course/admin.py +++ b/course/admin.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- - +from __future__ import absolute_import __copyright__ = "Copyright (C) 2014 Andreas Kloeckner" __license__ = """ @@ -47,6 +47,8 @@ from course.constants import ( exam_ticket_states ) +from rules.contrib.admin import ObjectPermissionsModelAdmin + if False: from typing import Any # noqa @@ -101,7 +103,7 @@ class CourseAdminForm(forms.ModelForm): exclude = () -class CourseAdmin(admin.ModelAdmin): +class CourseAdmin(ObjectPermissionsModelAdmin): list_display = ( "identifier", "number", @@ -150,6 +152,7 @@ class CourseAdmin(admin.ModelAdmin): return _filter_courses_for_user(qs, request.user) # }}} + pass admin.site.register(Course, CourseAdmin) @@ -159,7 +162,7 @@ admin.site.register(Course, CourseAdmin) # {{{ events -class EventAdmin(admin.ModelAdmin): +class EventAdmin(ObjectPermissionsModelAdmin): list_display = ( "course", "kind", @@ -182,7 +185,7 @@ class EventAdmin(admin.ModelAdmin): if six.PY3: __str__ = __unicode__ - list_editable = ("ordinal", "time", "end_time", "shown_in_calendar") +# list_editable = ("ordinal", "time", "end_time", "shown_in_calendar") # {{{ permissions @@ -199,6 +202,23 @@ class EventAdmin(admin.ModelAdmin): # }}} + # Overrides for row-level permission {{{ + + def get_form(self, request, *args, **kwargs): + form = super(EventAdmin, self).get_form(request, *args, **kwargs) + form.base_fields['creator'].initial = request.user + form.base_fields['creator'].widget = forms.HiddenInput() + return form + + def save_model(self, request, obj, form, change): + obj.creator = request.user + super(EventAdmin, self).save_model(request, obj, form, change) + pass + + pass + + # }}} + admin.site.register(Event, EventAdmin) diff --git a/course/migrations/0102_add_event_creator.py b/course/migrations/0102_add_event_creator.py new file mode 100644 index 0000000000000000000000000000000000000000..3c1464af6ce666966752cf843222ed295890801b --- /dev/null +++ b/course/migrations/0102_add_event_creator.py @@ -0,0 +1,33 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.7 on 2017-05-30 02:06 +from __future__ import unicode_literals + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('course', '0101_add_ticket_facility_and_validity'), + ] + + operations = [ + migrations.AddField( + model_name='event', + name='creator', + field=models.ForeignKey(default=1, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL), + ), + migrations.AlterField( + model_name='participationpermission', + name='permission', + field=models.CharField(choices=[('edit_course', 'Edit course'), ('use_admin_interface', 'Use admin interface'), ('impersonate_role', 'Impersonate role'), ('set_fake_time', 'Set fake time'), ('set_pretend_facility', 'Pretend to be in facility'), ('edit_course_permissions', 'Edit course permissions'), ('view_hidden_course_page', 'View hidden course page'), ('view_calendar', 'View calendar'), ('send_instant_message', 'Send instant message'), ('access_files_for', 'Access files for'), ('included_in_grade_statistics', 'Included in grade statistics'), ('edit_exam', 'Edit exam'), ('issue_exam_ticket', 'Issue exam ticket'), ('batch_issue_exam_ticket', 'Batch issue exam ticket'), ('view_participant_masked_profile', "View participants' masked profile only"), ('view_flow_sessions_from_role', 'View flow sessions from role'), ('view_gradebook', 'View gradebook'), ('edit_grading_opportunity', 'Edit grading opportunity'), ('assign_grade', 'Assign grade'), ('view_grader_stats', 'View grader stats'), ('batch_import_grade', 'Batch-import grades'), ('batch_export_grade', 'Batch-export grades'), ('batch_download_submission', 'Batch-download submissions'), ('impose_flow_session_deadline', 'Impose flow session deadline'), ('batch_impose_flow_session_deadline', 'Batch-impose flow session deadline'), ('end_flow_session', 'End flow session'), ('batch_end_flow_session', 'Batch-end flow sessions'), ('regrade_flow_session', 'Regrade flow session'), ('batch_regrade_flow_session', 'Batch-regrade flow sessions'), ('recalculate_flow_session_grade', 'Recalculate flow session grade'), ('batch_recalculate_flow_session_grade', 'Batch-recalculate flow sesssion grades'), ('reopen_flow_session', 'Reopen flow session'), ('grant_exception', 'Grant exception'), ('view_analytics', 'View analytics'), ('preview_content', 'Preview content'), ('update_content', 'Update content'), ('use_markup_sandbox', 'Use markup sandbox'), ('use_page_sandbox', 'Use page sandbox'), ('test_flow', 'Test flow'), ('edit_events', 'Edit events'), ('query_participation', 'Query participation'), ('edit_participation', 'Edit participation'), ('preapprove_participation', 'Preapprove participation'), ('manage_instant_flow_requests', 'Manage instant flow requests')], db_index=True, max_length=200, verbose_name='Permission'), + ), + migrations.AlterField( + model_name='participationrolepermission', + name='permission', + field=models.CharField(choices=[('edit_course', 'Edit course'), ('use_admin_interface', 'Use admin interface'), ('impersonate_role', 'Impersonate role'), ('set_fake_time', 'Set fake time'), ('set_pretend_facility', 'Pretend to be in facility'), ('edit_course_permissions', 'Edit course permissions'), ('view_hidden_course_page', 'View hidden course page'), ('view_calendar', 'View calendar'), ('send_instant_message', 'Send instant message'), ('access_files_for', 'Access files for'), ('included_in_grade_statistics', 'Included in grade statistics'), ('edit_exam', 'Edit exam'), ('issue_exam_ticket', 'Issue exam ticket'), ('batch_issue_exam_ticket', 'Batch issue exam ticket'), ('view_participant_masked_profile', "View participants' masked profile only"), ('view_flow_sessions_from_role', 'View flow sessions from role'), ('view_gradebook', 'View gradebook'), ('edit_grading_opportunity', 'Edit grading opportunity'), ('assign_grade', 'Assign grade'), ('view_grader_stats', 'View grader stats'), ('batch_import_grade', 'Batch-import grades'), ('batch_export_grade', 'Batch-export grades'), ('batch_download_submission', 'Batch-download submissions'), ('impose_flow_session_deadline', 'Impose flow session deadline'), ('batch_impose_flow_session_deadline', 'Batch-impose flow session deadline'), ('end_flow_session', 'End flow session'), ('batch_end_flow_session', 'Batch-end flow sessions'), ('regrade_flow_session', 'Regrade flow session'), ('batch_regrade_flow_session', 'Batch-regrade flow sessions'), ('recalculate_flow_session_grade', 'Recalculate flow session grade'), ('batch_recalculate_flow_session_grade', 'Batch-recalculate flow sesssion grades'), ('reopen_flow_session', 'Reopen flow session'), ('grant_exception', 'Grant exception'), ('view_analytics', 'View analytics'), ('preview_content', 'Preview content'), ('update_content', 'Update content'), ('use_markup_sandbox', 'Use markup sandbox'), ('use_page_sandbox', 'Use page sandbox'), ('test_flow', 'Test flow'), ('edit_events', 'Edit events'), ('query_participation', 'Query participation'), ('edit_participation', 'Edit participation'), ('preapprove_participation', 'Preapprove participation'), ('manage_instant_flow_requests', 'Manage instant flow requests')], db_index=True, max_length=200, verbose_name='Permission'), + ), + ] diff --git a/course/models.py b/course/models.py index 6b4c0f32d21fcb0d26d53792fb26ffae2dbc87fc..e6b67a5367a4682311ae4cecc574b8b31e0a7c1c 100644 --- a/course/models.py +++ b/course/models.py @@ -40,6 +40,8 @@ from django.dispatch import receiver from django.conf import settings +from django import forms + from course.constants import ( # noqa user_status, USER_STATUS_CHOICES, participation_status, PARTICIPATION_STATUS_CHOICES, @@ -277,6 +279,17 @@ class Event(models.Model): shown_in_calendar = models.BooleanField(default=True, verbose_name=_('Shown in calendar')) + # Extra attribute for row-level permission + + creator = models.ForeignKey(settings.AUTH_USER_MODEL, default=1) + + def clean(self): + event_edit_staff = [i.user for i in Participation.objects + .filter(course=self.course) + if i.has_permission('edit_events', None)] + if (self.creator not in event_edit_staff): + raise forms.ValidationError("You cannot add event to this course.") + class Meta: verbose_name = _("Event") verbose_name_plural = _("Events") diff --git a/course/rules.py b/course/rules.py new file mode 100644 index 0000000000000000000000000000000000000000..924cc6c7ad9b0e4039376437d94f890ee9e1712b --- /dev/null +++ b/course/rules.py @@ -0,0 +1,42 @@ +from __future__ import absolute_import + +import rules +from course.models import Participation + +# Predicates + + +@rules.predicate +def has_edit_course_perm(user, course): + if not course: + return False + course_edit_staff = [i.user + for i in Participation.objects.filter(course=course) + if i.has_permission('edit_course', None)] + return user in course_edit_staff + + +@rules.predicate +def has_edit_event_perm(user, event): + if not event: + return False + course = event.course + event_edit_staff = [i.user + for i in Participation.objects.filter(course=course) + if i.has_permission('edit_events', None)] + return user in event_edit_staff +# Rules + + +rules.add_rule('change_course', has_edit_course_perm) +rules.add_rule('delete_course', has_edit_course_perm) +rules.add_rule('change_event', has_edit_event_perm) +rules.add_rule('delete_event', has_edit_event_perm) +# Permissions + +rules.add_perm('course.change_course', has_edit_course_perm) +rules.add_perm('course.delete_course', has_edit_course_perm) +rules.add_perm('course', rules.always_allow) + +rules.add_perm('course.delete_event', has_edit_event_perm) +rules.add_perm('course.change_event', has_edit_event_perm) diff --git a/course/views.py b/course/views.py index 6f1f14cd182bff5af861af60d582f4e66f2734ef..788f1a428a7631b28465ccb91c27280a03d35f7e 100644 --- a/course/views.py +++ b/course/views.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- -from __future__ import division +from __future__ import division, absolute_import __copyright__ = "Copyright (C) 2014 Andreas Kloeckner" @@ -1357,10 +1357,10 @@ class EditCourseForm(StyledModelForm): @course_view def edit_course(pctx): - if not pctx.has_permission(pperm.edit_course): - raise PermissionDenied() - request = pctx.request + if (not pctx.has_permission(pperm.edit_course) or + not request.user.has_perm('course.change_course', pctx.course)): + raise PermissionDenied() if request.method == 'POST': form = EditCourseForm(request.POST, instance=pctx.course) diff --git a/relate/settings.py b/relate/settings.py index 207cc6674b16c7b9ff5b247e1ebfdda80e775b53..44f876f83f2e75af6865ec011dc5440cceb88021 100644 --- a/relate/settings.py +++ b/relate/settings.py @@ -54,6 +54,7 @@ INSTALLED_APPS = ( "accounts", "course", + 'rules.apps.AutodiscoverRulesConfig' ) if local_settings["RELATE_SIGN_IN_BY_SAML2_ENABLED"]: @@ -87,8 +88,10 @@ AUTHENTICATION_BACKENDS = ( "course.auth.TokenBackend", "course.exam.ExamTicketBackend", "django.contrib.auth.backends.ModelBackend", + 'rules.permissions.ObjectPermissionBackend' ) + if local_settings["RELATE_SIGN_IN_BY_SAML2_ENABLED"]: AUTHENTICATION_BACKENDS = AUTHENTICATION_BACKENDS + ( # type: ignore 'course.auth.Saml2Backend', diff --git a/requirements.txt b/requirements.txt index b79796bb87dce3975286f8be2ac54be8e45da7ef..51f72729a9862015a331a1fadddde1bdfda908c2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -40,7 +40,7 @@ paramiko git+https://github.com/inducer/django-bootstrap3-datetimepicker.git # For in-class instant messaging -# dnspython # Py2 +#dnspython # Py2 dnspython3 # Py3 # Py2 broken was broken in 1.3.1 git+https://github.com/fritzy/SleekXMPP.git@6e27f28c854ce4ae1d9f0cc8ee407bda8de97d3b @@ -110,3 +110,6 @@ pytools typing # vim: foldmethod=marker + +# row-level permission +rules