diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 2f98ddbe996bb240061e0c8dea6226bd23410d24..fb6ad2578ea1f5b4f5c2baeae94285a2f33c69d9 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -11,3 +11,5 @@ include: file: /ci/test/security.yml - project: "AlekSIS/official/AlekSIS" file: /ci/build/dist.yml + - project: "AlekSIS/official/AlekSIS" + file: /ci/publish/pypi.yml diff --git a/aleksis/apps/stoelindeling/forms.py b/aleksis/apps/stoelindeling/forms.py index e5f8c0edc2e0be6d8d7f6b43ec26f2669c409ebd..a5067b78a2f8bb07010989be18e2e2865ddcdb83 100644 --- a/aleksis/apps/stoelindeling/forms.py +++ b/aleksis/apps/stoelindeling/forms.py @@ -1,4 +1,5 @@ from django import forms +from django.utils.translation import gettext as _ from django_select2.forms import ModelSelect2Widget from material import Layout, Row @@ -23,11 +24,26 @@ class SeatingPlanCreateForm(forms.ModelForm): super().__init__(*args, **kwargs) qs = Group.objects.all() - if not self.request.user.has_perm("stoelindeling.view_seatingplan"): + if not self.request.user.has_perm("stoelindeling.add_seatingplan"): qs = qs.filter(owners=self.request.user.person) self.fields["group"].queryset = qs + def clean(self): + cleaned_data = super().clean() + if ( + cleaned_data["group"].subject + and cleaned_data["subject"] + and cleaned_data["group"].subject != cleaned_data["subject"] + ): + raise forms.ValidationError( + _( + "The group you selected has a fixed subject ({}). " + "You are not allowed to use another subject than it." + ).format(cleaned_data["group"].subject) + ) + return cleaned_data + class Meta: model = SeatingPlan fields = ["group", "subject", "room"] @@ -47,6 +63,59 @@ class SeatingPlanCreateForm(forms.ModelForm): } +class SeatingPlanCopyForm(forms.ModelForm): + layout = Layout( + Row( + "room", + "group", + "subject", + ) + ) + + def __init__(self, *args, **kwargs): + self.request = kwargs.pop("request") + super().__init__(*args, **kwargs) + + self.fields["room"].disabled = True + + qs = Group.objects.filter(parent_groups=self.instance.group) + if not self.request.user.has_perm("stoelindeling.add_seatingplan"): + qs = qs.filter(owners=self.request.user.person) + + self.fields["group"].queryset = qs + + def clean(self): + cleaned_data = super().clean() + if cleaned_data["group"] == self.instance.group: + raise forms.ValidationError(_("Group cannot be the same as the original.")) + if ( + cleaned_data["group"].subject + and cleaned_data["subject"] + and cleaned_data["group"].subject != cleaned_data["subject"] + ): + raise forms.ValidationError( + _( + "The group you selected has a fixed subject ({}). " + "You are not allowed to use another subject than it." + ).format(cleaned_data["group"].subject) + ) + return cleaned_data + + class Meta: + model = SeatingPlan + fields = ["room", "group", "subject"] + widgets = { + "group": ModelSelect2Widget( + search_fields=["name__icontains", "short_name__icontains"], + attrs={"data-minimum-input-length": 0, "class": "browser-default"}, + ), + "subject": ModelSelect2Widget( + search_fields=["name__icontains", "short_name__icontains"], + attrs={"data-minimum-input-length": 0, "class": "browser-default"}, + ), + } + + class SeatingPlanForm(forms.ModelForm): layout = Layout( Row( diff --git a/aleksis/apps/stoelindeling/menus.py b/aleksis/apps/stoelindeling/menus.py index 492015d6bfd382eb3c1065dec0feb7124bb180fb..fc2ce01268d08a8463936c232dd08a7afbcd5dbf 100644 --- a/aleksis/apps/stoelindeling/menus.py +++ b/aleksis/apps/stoelindeling/menus.py @@ -4,26 +4,14 @@ MENUS = { "NAV_MENU_CORE": [ { "name": _("Seating plans"), - "url": "#", - "root": True, - "svg_icon": "mdi:seat-outline", + "url": "seating_plans", + "svg_icon": "mdi:view-list-outline", "validators": [ - "menu_generator.validators.is_authenticated", - "aleksis.core.util.core_helpers.has_person", + ( + "aleksis.core.util.predicates.permission_validator", + "stoelindeling.view_seatingplans_rule", + ), ], - "submenu": [ - { - "name": _("All seating plans"), - "url": "seating_plans", - "svg_icon": "mdi:view-list-outline", - "validators": [ - ( - "aleksis.core.util.predicates.permission_validator", - "stoelindeling.view_seatingplans_rule", - ), - ], - }, - ], - } + }, ] } diff --git a/aleksis/apps/stoelindeling/migrations/0004_alter_seatingplan_subject.py b/aleksis/apps/stoelindeling/migrations/0004_alter_seatingplan_subject.py new file mode 100644 index 0000000000000000000000000000000000000000..4182b5547adb8a49adf14941c696a1249293e622 --- /dev/null +++ b/aleksis/apps/stoelindeling/migrations/0004_alter_seatingplan_subject.py @@ -0,0 +1,20 @@ +# Generated by Django 3.2.12 on 2022-04-06 17:54 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('chronos', '0010_remove_subject_unique_name_per_site'), + ('stoelindeling', '0003_seat_negative_x_y'), + ] + + operations = [ + migrations.AlterField( + model_name='seatingplan', + name='subject', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='chronos.subject', verbose_name='Subject'), + ), + ] diff --git a/aleksis/apps/stoelindeling/model_extensions.py b/aleksis/apps/stoelindeling/model_extensions.py new file mode 100644 index 0000000000000000000000000000000000000000..c61ae1583dbe7bedcd5ecf95e9d95aaca6b0e0f0 --- /dev/null +++ b/aleksis/apps/stoelindeling/model_extensions.py @@ -0,0 +1,55 @@ +from typing import Union + +from django.apps import apps + +from aleksis.apps.chronos.models import Room, Subject +from aleksis.apps.stoelindeling.models import SeatingPlan +from aleksis.core.models import Group + + +@Group.method +def get_seating_plan(self, room: Room, subject: Subject = None) -> Union[SeatingPlan, None]: + """Find a seating plan for a group.""" + qs = SeatingPlan.objects.filter(group=self, room=room) + if subject: + qs = qs.filter(subject=subject) + if qs.exists(): + return qs.first() + + qs = SeatingPlan.objects.filter(group=self, room=room) + if qs.exists(): + return qs.first() + + qs = SeatingPlan.objects.filter(group__child_groups=self, room=room) + if qs.exists(): + return qs.first() + + return None + + +if apps.is_installed("aleksis.apps.alsijil"): + from aleksis.apps.alsijil.models import Event, ExtraLesson, LessonPeriod + + def seating_plan_lesson_period(self): + """Get the seating plan for a specific lesson period.""" + if self.get_groups().count() == 1: + return self.get_groups().all()[0].get_seating_plan(self.get_room(), self.get_subject()) + return None + + LessonPeriod.property_(seating_plan_lesson_period, name="seating_plan") + + def seating_plan_extra_lesson(self): + """Get the seating plan for an extra lesson.""" + if self.groups.count() == 1: + return self.groups.all()[0].get_seating_plan(self.room, self.subject) + return None + + ExtraLesson.property_(seating_plan_extra_lesson, name="seating_plan") + + def seating_plan_event(self): + """Get the seating plan for an event.""" + if self.groups.count() == 1 and self.rooms.count() == 1: + return self.groups.all()[0].get_seating_plan(self.rooms.all()[0]) + return None + + Event.property_(seating_plan_event, name="seating_plan") diff --git a/aleksis/apps/stoelindeling/rules.py b/aleksis/apps/stoelindeling/rules.py index c11819bd32f67b69c50e26388a34e6c879460ec0..38dab4ce629d904183f145ea35151fd780e2ebe6 100644 --- a/aleksis/apps/stoelindeling/rules.py +++ b/aleksis/apps/stoelindeling/rules.py @@ -5,10 +5,11 @@ from aleksis.core.util.predicates import ( has_global_perm, has_object_perm, has_person, + is_group_owner, ) from .models import SeatingPlan -from .util.perms import is_group_owner +from .util.perms import is_plan_child_group_owner, is_plan_group_owner # View seating plan list view_seatingplans_predicate = has_person & ( @@ -22,28 +23,45 @@ add_perm("stoelindeling.view_seatingplans_rule", view_seatingplans_predicate) view_seatingplan_predicate = has_person & ( has_global_perm("stoelindeling.view_seatingplan") | has_object_perm("stoelindeling.view_seatingplan") - | is_group_owner + | is_plan_group_owner + | is_plan_child_group_owner ) add_perm("stoelindeling.view_seatingplan_rule", view_seatingplan_predicate) +# +view_seatingplan_for_group_predicate = has_person & ( + has_global_perm("stoelindeling.view_seatingplan") | is_group_owner +) +add_perm("stoelindeling.view_seatingplan_for_group_rule", view_seatingplan_for_group_predicate) + # Add seating plan -create_seatingplan_predicate = view_seatingplans_predicate & has_global_perm( - "stoelindeling.add_seatingplan" +create_seatingplan_predicate = has_person & ( + has_global_perm("stoelindeling.add_seatingplan") | is_group_owner | is_plan_group_owner ) add_perm("stoelindeling.create_seatingplan_rule", create_seatingplan_predicate) +# Copy seating plan +copy_seatingplan_for_group_predicate = ( + view_seatingplan_for_group_predicate & create_seatingplan_predicate +) +add_perm("stoelindeling.copy_seatingplan_for_group_rule", copy_seatingplan_for_group_predicate) + +# Copy seating plan +copy_seatingplan_predicate = view_seatingplan_predicate +add_perm("stoelindeling.copy_seatingplan_rule", copy_seatingplan_predicate) + # Edit seating plan -edit_seatingplan_predicate = view_seatingplans_predicate & ( +edit_seatingplan_predicate = view_seatingplan_predicate & ( has_global_perm("stoelindeling.change_seatingplan") - | is_group_owner + | is_plan_group_owner | has_object_perm("stoelindeling.change_seatingplan") ) add_perm("stoelindeling.edit_seatingplan_rule", edit_seatingplan_predicate) # Delete seating plan -delete_seatingplan_predicate = view_seatingplans_predicate & ( +delete_seatingplan_predicate = view_seatingplan_predicate & ( has_global_perm("stoelindeling.delete_seatingplan") - | is_group_owner + | is_plan_group_owner | has_object_perm("stoelindeling.delete_seatingplan") ) add_perm("stoelindeling.delete_seatingplan_rule", delete_seatingplan_predicate) diff --git a/aleksis/apps/stoelindeling/templates/stoelindeling/seating_plan/copy.html b/aleksis/apps/stoelindeling/templates/stoelindeling/seating_plan/copy.html new file mode 100644 index 0000000000000000000000000000000000000000..0c7ebf369252b2109e915ff68e250a392007a910 --- /dev/null +++ b/aleksis/apps/stoelindeling/templates/stoelindeling/seating_plan/copy.html @@ -0,0 +1,26 @@ +{# -*- engine:django -*- #} + +{% extends "core/base.html" %} +{% load material_form i18n any_js %} + +{% block browser_title %}{% blocktrans %}Copy seating plan{% endblocktrans %}{% endblock %} +{% block page_title %}{% blocktrans %}Copy seating plan{% endblocktrans %}{% endblock %} + +{% block extra_head %} + {{ form.media.css }} + {% include_css "select2-materialize" %} +{% endblock %} + +{% block content %} + <p class="flow-text"> + {% trans "Seating plan to copy" %}: {{ object }} + </p> + <form method="post"> + {% csrf_token %} + {% form form=form %}{% endform %} + {% include "core/partials/save_button.html" %} + </form> + + {% include_js "select2-materialize" %} + {{ form.media.js }} +{% endblock %} diff --git a/aleksis/apps/stoelindeling/urls.py b/aleksis/apps/stoelindeling/urls.py index 94c22cccb1bce8305d177d907726c3fdc33ecc80..0e1ce6b5ef8ec2ee8a78644c4647ec0b3217d556 100644 --- a/aleksis/apps/stoelindeling/urls.py +++ b/aleksis/apps/stoelindeling/urls.py @@ -19,6 +19,11 @@ urlpatterns = [ views.SeatingPlanEditView.as_view(), name="edit_seating_plan", ), + path( + "seating_plans/<int:pk>/copy/", + views.SeatingPlanCopyView.as_view(), + name="copy_seating_plan", + ), path( "seating_plans/<int:pk>/delete/", views.SeatingPlanDeleteView.as_view(), diff --git a/aleksis/apps/stoelindeling/util/perms.py b/aleksis/apps/stoelindeling/util/perms.py index c55d09bf904f2dc618c9adcefbe982aba1fa516b..2b00e287cf3fa95e4410a163604736b732fcb6bb 100644 --- a/aleksis/apps/stoelindeling/util/perms.py +++ b/aleksis/apps/stoelindeling/util/perms.py @@ -3,15 +3,35 @@ from django.db.models import Q from guardian.shortcuts import get_objects_for_user from rules import predicate +from aleksis.core.models import Group, Person + from ..models import SeatingPlan @predicate -def is_group_owner(user, seating_plan: SeatingPlan) -> bool: +def is_group_owner(user, group: Group) -> bool: + """Predicate which checks if the user is a owner of the group.""" + if not isinstance(group, Group): + return False + return user.person in group.owners.all() + + +@predicate +def is_plan_group_owner(user, seating_plan: SeatingPlan) -> bool: """Predicate which checks if the user is a owner of the seating plan's group.""" + if not isinstance(seating_plan, SeatingPlan): + return False return user.person in seating_plan.group.owners.all() +@predicate +def is_plan_child_group_owner(user, seating_plan: SeatingPlan) -> bool: + """Predicate which checks if the user is an owner of the seating plan's child groups.""" + if not isinstance(seating_plan, SeatingPlan): + return False + return user.person in Person.objects.filter(owner_of__in=seating_plan.group.child_groups.all()) + + def get_allowed_seating_plans(user): """Get all seating plans the user is allowed to see.""" if not user.has_perm("stoelindeling.view_seatingplan"): diff --git a/aleksis/apps/stoelindeling/views.py b/aleksis/apps/stoelindeling/views.py index 51b718935d482f2861e1dc57e52f4d5549c761a7..0b3f4b9c4c201667f73cf9591448f05680cbb697 100644 --- a/aleksis/apps/stoelindeling/views.py +++ b/aleksis/apps/stoelindeling/views.py @@ -1,6 +1,6 @@ from django.contrib import messages from django.shortcuts import redirect -from django.urls import reverse_lazy +from django.urls import reverse, reverse_lazy from django.utils.decorators import method_decorator from django.utils.translation import gettext as _ from django.views.decorators.cache import never_cache @@ -10,9 +10,15 @@ from django_tables2 import SingleTableView from reversion.views import RevisionMixin from rules.contrib.views import PermissionRequiredMixin -from aleksis.core.mixins import AdvancedCreateView, AdvancedDeleteView, AdvancedEditView +from aleksis.core.mixins import ( + AdvancedCreateView, + AdvancedDeleteView, + AdvancedEditView, + SuccessNextMixin, +) +from aleksis.core.views import LoginView -from .forms import SeatFormSet, SeatingPlanCreateForm, SeatingPlanForm +from .forms import SeatFormSet, SeatingPlanCopyForm, SeatingPlanCreateForm, SeatingPlanForm from .models import Seat, SeatingPlan from .tables import SeatingPlanTable from .util.perms import get_allowed_seating_plans @@ -39,7 +45,7 @@ class SeatingPlanDetailView(PermissionRequiredMixin, DetailView): @method_decorator(never_cache, name="dispatch") -class SeatingPlanCreateView(PermissionRequiredMixin, AdvancedCreateView): +class SeatingPlanCreateView(PermissionRequiredMixin, SuccessNextMixin, AdvancedCreateView): """Create view for seating plans.""" model = SeatingPlan @@ -52,11 +58,19 @@ class SeatingPlanCreateView(PermissionRequiredMixin, AdvancedCreateView): def get_form_kwargs(self): kwargs = super().get_form_kwargs() kwargs["request"] = self.request + initial = {} + if "room" in self.request.GET: + initial["room"] = self.request.GET["room"] + if "subject" in self.request.GET: + initial["subject"] = self.request.GET["subject"] + if "group" in self.request.GET: + initial["group"] = self.request.GET["group"] + kwargs["initial"] = initial return kwargs @method_decorator(never_cache, name="dispatch") -class SeatingPlanEditView(PermissionRequiredMixin, AdvancedEditView): +class SeatingPlanEditView(PermissionRequiredMixin, SuccessNextMixin, AdvancedEditView): """Edit view for seating plans.""" model = SeatingPlan @@ -65,6 +79,9 @@ class SeatingPlanEditView(PermissionRequiredMixin, AdvancedEditView): template_name = "stoelindeling/seating_plan/edit.html" success_message = _("The seating plan has been saved.") + def get_success_url(self): + return LoginView.get_redirect_url(self) or reverse("seating_plan", args=[self.object.pk]) + def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) @@ -112,13 +129,52 @@ class SeatingPlanEditView(PermissionRequiredMixin, AdvancedEditView): Seat.objects.bulk_update(objects_to_update, ["x", "y", "seated"]) messages.success(self.request, _("The seating plan has been updated.")) - return redirect("seating_plan", self.object.pk) + return redirect(self.get_success_url()) return super().form_invalid(form) @method_decorator(never_cache, name="dispatch") -class SeatingPlanDeleteView(PermissionRequiredMixin, RevisionMixin, AdvancedDeleteView): +class SeatingPlanCopyView(PermissionRequiredMixin, SuccessNextMixin, AdvancedEditView): + """Copy view for seating plans.""" + + model = SeatingPlan + form_class = SeatingPlanCopyForm + permission_required = "stoelindeling.copy_seatingplan_rule" + template_name = "stoelindeling/seating_plan/copy.html" + + def get_success_url(self): + return reverse("edit_seating_plan", args=[self.new_object.pk]) # FiXME NEXT URL + + def get_form_kwargs(self): + kwargs = super().get_form_kwargs() + kwargs["request"] = self.request + return kwargs + + def form_valid(self, form): + context = self.get_context_data() + + self.new_object = self.object + self.new_object.pk = None + self.new_object.group = form.cleaned_data["group"] + self.new_object.subject = form.cleaned_data["subject"] + self.new_object.save() + + for seat in self.object.seats.all(): + if seat.person in self.new_object.group.members.all(): + new_seat = seat + new_seat.pk = None + new_seat.seating_plan = self.new_object + new_seat.save() + + messages.success(self.request, _("The seating plan has been copied successfully.")) + return redirect(self.get_success_url()) + + +@method_decorator(never_cache, name="dispatch") +class SeatingPlanDeleteView( + PermissionRequiredMixin, RevisionMixin, SuccessNextMixin, AdvancedDeleteView +): """Delete view for seating plans.""" model = SeatingPlan