diff --git a/aleksis/apps/chronos/admin.py b/aleksis/apps/chronos/admin.py index 5145472fdbf9eff3349c81d0cbf10a59c24442f2..34b8df69b1effd3169e6b1557f0501674cd6734d 100644 --- a/aleksis/apps/chronos/admin.py +++ b/aleksis/apps/chronos/admin.py @@ -3,6 +3,8 @@ from django.contrib import admin from django.utils.html import format_html +from guardian.admin import GuardedModelAdmin + from .models import ( Absence, AbsenceReason, @@ -144,7 +146,7 @@ class LessonAdmin(admin.ModelAdmin): admin.site.register(Lesson, LessonAdmin) -class RoomAdmin(admin.ModelAdmin): +class RoomAdmin(GuardedModelAdmin): list_display = ("short_name", "name") list_display_links = ("short_name", "name") diff --git a/aleksis/apps/chronos/migrations/0005_add_permissions.py b/aleksis/apps/chronos/migrations/0005_add_permissions.py new file mode 100644 index 0000000000000000000000000000000000000000..9fcc2fba01d69514c27568e632e6ee1dba227672 --- /dev/null +++ b/aleksis/apps/chronos/migrations/0005_add_permissions.py @@ -0,0 +1,23 @@ +# Generated by Django 3.1.3 on 2020-12-22 12:22 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('chronos', '0004_substitution_extra_lesson_year'), + ] + + operations = [ + migrations.AlterModelOptions( + name='room', + options={'ordering': ['name', 'short_name'], + 'permissions': (('view_room_timetable', 'Can view room timetable'),), + 'verbose_name': 'Room', 'verbose_name_plural': 'Rooms'}, + ), + migrations.AlterModelOptions( + name='chronosglobalpermissions', + options={'managed': False, 'permissions': (('view_all_room_timetables', 'Can view all room timetables'), ('view_all_group_timetables', 'Can view all group timetables'), ('view_all_person_timetables', 'Can view all person timetables'), ('view_timetable_overview', 'Can view timetable overview'), ('view_lessons_day', 'Can view all lessons per day'))}, + ), + ] diff --git a/aleksis/apps/chronos/migrations/0005_indexes.py b/aleksis/apps/chronos/migrations/0006_indexes.py similarity index 97% rename from aleksis/apps/chronos/migrations/0005_indexes.py rename to aleksis/apps/chronos/migrations/0006_indexes.py index a00fefca15a12802d608a6494f6b5fded6e8ee66..9b86b6b7906ab3568f89190199eba02f88562d22 100644 --- a/aleksis/apps/chronos/migrations/0005_indexes.py +++ b/aleksis/apps/chronos/migrations/0006_indexes.py @@ -8,7 +8,7 @@ class Migration(migrations.Migration): dependencies = [ ('core', '0010_external_link_widget'), - ('chronos', '0004_substitution_extra_lesson_year'), + ('chronos', '0005_add_permissions'), ] operations = [ diff --git a/aleksis/apps/chronos/model_extensions.py b/aleksis/apps/chronos/model_extensions.py index 9bdf9fa8d9f183898873e5b22dc61722dcfd53d8..6cb98dad7d5c7ca732dfe800345abbf34f6320de 100644 --- a/aleksis/apps/chronos/model_extensions.py +++ b/aleksis/apps/chronos/model_extensions.py @@ -134,3 +134,12 @@ Announcement.field( ) Group.foreign_key("subject", Subject, related_name="groups") + +# Dynamically add extra permissions to Group and Person models in core +# Note: requires migrate afterwards +Group.add_permission( + "view_group_timetable", _("Can view group timetable"), +) +Person.add_permission( + "view_person_timetable", _("Can view person timetable"), +) diff --git a/aleksis/apps/chronos/models.py b/aleksis/apps/chronos/models.py index 9aaf8227c0b4639b2b3042af45fdc505bb7d5c4c..9842363ca301fb7bf3ddcc9a3f7bd96a51f7f375 100644 --- a/aleksis/apps/chronos/models.py +++ b/aleksis/apps/chronos/models.py @@ -331,6 +331,7 @@ class Room(ExtensibleModel): return reverse("timetable", args=["room", self.id]) class Meta: + permissions = (("view_room_timetable", _("Can view room timetable")),) ordering = ["name", "short_name"] verbose_name = _("Room") verbose_name_plural = _("Rooms") @@ -1069,7 +1070,9 @@ class ChronosGlobalPermissions(GlobalPermissionModel): class Meta: managed = False permissions = ( - ("view_all_timetables", _("Can view all timetables")), + ("view_all_room_timetables", _("Can view all room timetables")), + ("view_all_group_timetables", _("Can view all group timetables")), + ("view_all_person_timetables", _("Can view all person timetables")), ("view_timetable_overview", _("Can view timetable overview")), ("view_lessons_day", _("Can view all lessons per day")), ) diff --git a/aleksis/apps/chronos/rules.py b/aleksis/apps/chronos/rules.py index 7ee9d1e0dcfb7b937b24ef82bb1adf36fa10dc46..aec25bf3b7e773871d4e9428c7c5c8da46e551ba 100644 --- a/aleksis/apps/chronos/rules.py +++ b/aleksis/apps/chronos/rules.py @@ -8,19 +8,19 @@ from aleksis.core.util.predicates import ( ) from .models import LessonSubstitution -from .util.predicates import has_timetable_perm +from .util.predicates import has_any_timetable_object, has_timetable_perm # View timetable overview -view_timetable_overview_predicate = has_person & has_global_perm("chronos.view_timetable_overview") +view_timetable_overview_predicate = has_person & ( + has_any_timetable_object | has_global_perm("chronos.view_timetable_overview") +) add_perm("chronos.view_timetable_overview", view_timetable_overview_predicate) # View my timetable add_perm("chronos.view_my_timetable", has_person) # View timetable -view_timetable_predicate = has_person & ( - has_global_perm("chronos.view_all_timetables") | has_timetable_perm -) +view_timetable_predicate = has_person & has_timetable_perm add_perm("chronos.view_timetable", view_timetable_predicate) # View all lessons per day diff --git a/aleksis/apps/chronos/templates/chronos/timetable.html b/aleksis/apps/chronos/templates/chronos/timetable.html index 9201367e5373557fbd468e69403d77ccedf27edb..9f015f8f65e86a02d1736c4c8f7c7786ec1c04a5 100644 --- a/aleksis/apps/chronos/templates/chronos/timetable.html +++ b/aleksis/apps/chronos/templates/chronos/timetable.html @@ -36,7 +36,7 @@ {% endif %} </div> <div class="col s4 m6 l4 xl3 right align-right no-print"> - <a class="waves-effect waves-teal btn-flat btn-flat-medium right hide-on-small-and-down" href="{% url "timetable_print" type.value pk %}" id="print"> + <a class="waves-effect waves-teal btn-flat btn-flat-medium right hide-on-small-and-down" href="{% url "timetable_print" type.value pk %}"> <i class="material-icons center">print</i> </a> </div> diff --git a/aleksis/apps/chronos/util/chronos_helpers.py b/aleksis/apps/chronos/util/chronos_helpers.py index 797eefcd95a18d9a3a930f24e8a7c50af9c92642..f4bb7d2d1d0c0bf771d68076f429b3ca8dd348ee 100644 --- a/aleksis/apps/chronos/util/chronos_helpers.py +++ b/aleksis/apps/chronos/util/chronos_helpers.py @@ -1,13 +1,22 @@ -from typing import Optional +from typing import TYPE_CHECKING, Optional +from django.db.models import Count, Q from django.http import HttpRequest, HttpResponseNotFound from django.shortcuts import get_object_or_404 +from guardian.core import ObjectPermissionChecker + from aleksis.core.models import Group, Person +from aleksis.core.util.predicates import check_global_permission from ..managers import TimetableType from ..models import LessonPeriod, LessonSubstitution, Room +if TYPE_CHECKING: + from django.contrib.auth import get_user_model + + User = get_user_model() # noqa + def get_el_by_pk( request: HttpRequest, @@ -39,3 +48,85 @@ def get_substitution_by_id(request: HttpRequest, id_: int, week: int): return LessonSubstitution.objects.filter( week=wanted_week.week, year=wanted_week.year, lesson_period=lesson_period ).first() + + +def get_teachers(user: "User"): + """Get the teachers whose timetables are allowed to be seen by current user.""" + checker = ObjectPermissionChecker(user) + + teachers = ( + Person.objects.annotate(lessons_count=Count("lessons_as_teacher")) + .filter(lessons_count__gt=0) + .order_by("short_name", "last_name") + ) + + if not check_global_permission(user, "chronos.view_all_person_timetables"): + checker.prefetch_perms(teachers) + + wanted_teachers = set() + + for teacher in teachers: + if checker.has_perm("core.view_person_timetable", teacher): + wanted_teachers.add(teacher.pk) + + teachers = teachers.filter(Q(pk=user.person.pk) | Q(pk__in=wanted_teachers)) + + return teachers + + +def get_classes(user: "User"): + """Get the classes whose timetables are allowed to be seen by current user.""" + checker = ObjectPermissionChecker(user) + + classes = ( + Group.objects.for_current_school_term_or_all() + .annotate( + lessons_count=Count("lessons"), child_lessons_count=Count("child_groups__lessons"), + ) + .filter( + Q(lessons_count__gt=0, parent_groups=None) + | Q(child_lessons_count__gt=0, parent_groups=None) + ) + .order_by("short_name", "name") + ) + + if not check_global_permission(user, "chronos.view_all_group_timetables"): + checker.prefetch_perms(classes) + + wanted_classes = set() + + for _class in classes: + if checker.has_perm("core.view_group_timetable", _class): + wanted_classes.add(_class.pk) + + classes = classes.filter( + Q(pk__in=wanted_classes) | Q(members=user.person) | Q(owners=user.person) + ) + if user.person.primary_group: + classes = classes.filter(Q(pk=user.person.primary_group.pk)) + + return classes + + +def get_rooms(user: "User"): + """Get the rooms whose timetables are allowed to be seen by current user.""" + checker = ObjectPermissionChecker(user) + + rooms = ( + Room.objects.annotate(lessons_count=Count("lesson_periods")) + .filter(lessons_count__gt=0) + .order_by("short_name", "name") + ) + + if not check_global_permission(user, "chronos.view_all_room_timetables"): + checker.prefetch_perms(rooms) + + wanted_rooms = set() + + for room in rooms: + if checker.has_perm("chronos.view_room_timetable", room): + wanted_rooms.add(room.pk) + + rooms = rooms.filter(Q(pk__in=wanted_rooms)) + + return rooms diff --git a/aleksis/apps/chronos/util/predicates.py b/aleksis/apps/chronos/util/predicates.py index cdbe5e2c268579558b0f29a184ed82709eda764a..fb70edee23dbae9f2014634526bea4b7d867adf7 100644 --- a/aleksis/apps/chronos/util/predicates.py +++ b/aleksis/apps/chronos/util/predicates.py @@ -3,18 +3,77 @@ from django.db.models import Model from rules import predicate -from aleksis.apps.chronos.models import Room from aleksis.core.models import Group, Person +from aleksis.core.util.predicates import has_global_perm, has_object_perm + +from ..models import Room +from .chronos_helpers import get_classes, get_rooms, get_teachers @predicate def has_timetable_perm(user: User, obj: Model) -> bool: - """Predicate which checks whether the user is allowed to access the requested timetable.""" - if obj.model is Group: - return obj in user.person.member_of - elif obj.model is Person: - return user.person == obj - elif obj.model is Room: - return True + """ + Check if can access timetable. + + Predicate which checks whether the user is allowed + to access the requested timetable. + """ + if isinstance(obj, Group): + return has_group_timetable_perm(user, obj) + elif isinstance(obj, Person): + return has_person_timetable_perm(user, obj) + elif isinstance(obj, Room): + return has_room_timetable_perm(user, obj) else: return False + + +@predicate +def has_group_timetable_perm(user: User, obj: Group) -> bool: + """ + Check if can access group timetable. + + Predicate which checks whether the user is allowed + to access the requested group timetable. + """ + return ( + obj in user.person.member_of.all() + or user.person.primary_group == obj + or obj in user.person.owner_of.all() + or has_global_perm("chronos.view_all_group_timetables")(user) + or has_object_perm("core.view_group_timetable")(user, obj) + ) + + +@predicate +def has_person_timetable_perm(user: User, obj: Person) -> bool: + """ + Check if can access person timetable. + + Predicate which checks whether the user is allowed + to access the requested person timetable. + """ + return ( + user.person == obj + or has_global_perm("chronos.view_all_person_timetables")(user) + or has_object_perm("core.view_person_timetable")(user, obj) + ) + + +@predicate +def has_room_timetable_perm(user: User, obj: Room) -> bool: + """ + Check if can access room timetable. + + Predicate which checks whether the user is allowed + to access the requested room timetable. + """ + return has_global_perm("chronos.view_all_room_timetables")(user) or has_object_perm( + "chronos.view_room_timetable" + )(user, obj) + + +@predicate +def has_any_timetable_object(user: User) -> bool: + """Predicate which checks whether there are any timetables the user is allowed to access.""" + return get_classes(user).exists() or get_rooms(user).exists() or get_teachers(user).exists() diff --git a/aleksis/apps/chronos/views.py b/aleksis/apps/chronos/views.py index a13d65356ebb2ec4981eb375170f6b0c35aac5b8..4ddc0c73b7d8f24b5b6adc8d0a6a9aaeaf3e0d9f 100644 --- a/aleksis/apps/chronos/views.py +++ b/aleksis/apps/chronos/views.py @@ -1,7 +1,7 @@ from datetime import datetime, timedelta from typing import Optional -from django.db.models import Count, Q +from django.db.models import Q from django.http import HttpRequest, HttpResponse, HttpResponseNotFound from django.shortcuts import get_object_or_404, redirect, render from django.urls import reverse @@ -12,17 +12,23 @@ from django.views.decorators.cache import never_cache from django_tables2 import RequestConfig from rules.contrib.views import permission_required -from aleksis.core.models import Announcement, Group, Person +from aleksis.core.models import Announcement, Group from aleksis.core.util import messages from aleksis.core.util.core_helpers import get_site_preferences, has_person from aleksis.core.util.pdf import render_pdf from .forms import LessonSubstitutionForm from .managers import TimetableType -from .models import Absence, Holiday, LessonPeriod, LessonSubstitution, Room, TimePeriod +from .models import Absence, Holiday, LessonPeriod, LessonSubstitution, TimePeriod from .tables import LessonsTable from .util.build import build_substitutions_list, build_timetable, build_weekdays -from .util.chronos_helpers import get_el_by_pk, get_substitution_by_id +from .util.chronos_helpers import ( + get_classes, + get_el_by_pk, + get_rooms, + get_substitution_by_id, + get_teachers, +) from .util.date import CalendarWeek, get_weeks_for_year from .util.js import date_unix @@ -32,22 +38,8 @@ def all_timetables(request: HttpRequest) -> HttpResponse: """View all timetables for persons, groups and rooms.""" context = {} - teachers = ( - Person.objects.alias(lessons_count=Count("lessons_as_teacher")) - .filter(lessons_count__gt=0) - .order_by("short_name", "last_name") - ) - groups = Group.objects.for_current_school_term_or_all().alias( - lessons_count=Count("lessons"), child_lessons_count=Count("child_groups__lessons"), - ) - classes = groups.filter(lessons_count__gt=0, parent_groups=None) | groups.filter( - child_lessons_count__gt=0, parent_groups=None - ).order_by("short_name", "name") - rooms = ( - Room.objects.alias(lessons_count=Count("lesson_periods")) - .filter(lessons_count__gt=0) - .order_by("short_name", "name") - ) + user = request.user + teachers, classes, rooms = get_teachers(user), get_classes(user), get_rooms(user) context["teachers"] = teachers context["classes"] = classes