diff --git a/apps/api/permissions.py b/apps/api/permissions.py index f50a71191..0ca22f719 100644 --- a/apps/api/permissions.py +++ b/apps/api/permissions.py @@ -1,26 +1,35 @@ from oauth2_provider.contrib.rest_framework import TokenHasScope -from rest_framework.permissions import DjangoObjectPermissions +from rest_framework.permissions import DjangoModelPermissions, DjangoObjectPermissions + +_perms_map_with_view_perms = { + "GET": ["%(app_label)s.view_%(model_name)s"], + "OPTIONS": [], + "HEAD": [], + "POST": ["%(app_label)s.add_%(model_name)s"], + "PUT": ["%(app_label)s.change_%(model_name)s"], + "PATCH": ["%(app_label)s.change_%(model_name)s"], + "DELETE": ["%(app_label)s.delete_%(model_name)s"], +} class TokenHasScopeOrUserHasObjectPermissionsOrWriteOnly( DjangoObjectPermissions, TokenHasScope ): - """ - Allow anyone to write to this endpoint, but only the ones with the required scope to read. - """ - - perms_map = { - "GET": ["%(app_label)s.view_%(model_name)s"], - "OPTIONS": [], - "HEAD": [], - "POST": ["%(app_label)s.add_%(model_name)s"], - "PUT": ["%(app_label)s.change_%(model_name)s"], - "PATCH": ["%(app_label)s.change_%(model_name)s"], - "DELETE": ["%(app_label)s.delete_%(model_name)s"], - } + perms_map = _perms_map_with_view_perms def has_permission(self, request, view): + if request.method == "POST": + return True + + return super().has_permission(request, view) + +class TokenHasScopeOrUserHasModelPermissionsOrWriteOnly( + DjangoModelPermissions, TokenHasScope +): + perms_map = _perms_map_with_view_perms + + def has_permission(self, request, view): if request.method == "POST": return True diff --git a/apps/approval/admin.py b/apps/approval/admin.py index 3e031dabf..312d242b6 100644 --- a/apps/approval/admin.py +++ b/apps/approval/admin.py @@ -1,24 +1,33 @@ # -*- coding: utf-8 -*- from django.contrib import admin -from django.contrib.admin import register from apps.approval.models import ( CommitteeApplication, + CommitteeApplicationPeriod, CommitteePriority, MembershipApproval, ) +@admin.register(CommitteeApplicationPeriod) +class CommitteeApplicationPeriodAdmin(admin.ModelAdmin): + model = CommitteeApplicationPeriod + list_display = ["title", "year", "start", "deadline"] + search_fields = ["title", "start", "deadline"] + list_filter = ["committees"] + + class CommitteePriorityInline(admin.TabularInline): model = CommitteePriority +@admin.register(CommitteeApplication) class CommitteeApplicationAdmin(admin.ModelAdmin): model = CommitteeApplication inlines = [CommitteePriorityInline] list_display = ["__str__", "get_name", "created", "prioritized"] - list_filter = ["prioritized", "committees"] + list_filter = ["application_period", "prioritized", "committees"] search_fields = [ "name", "email", @@ -28,7 +37,7 @@ class CommitteeApplicationAdmin(admin.ModelAdmin): ] -@register(MembershipApproval) +@admin.register(MembershipApproval) class MembershipApprovalAdmin(admin.ModelAdmin): model = MembershipApproval list_display = ( @@ -46,6 +55,3 @@ class MembershipApprovalAdmin(admin.ModelAdmin): "applicant__username", "applicant__ntnu_username", ) - - -admin.site.register(CommitteeApplication, CommitteeApplicationAdmin) diff --git a/apps/approval/api/filters.py b/apps/approval/api/filters.py new file mode 100644 index 000000000..8634511a2 --- /dev/null +++ b/apps/approval/api/filters.py @@ -0,0 +1,23 @@ +import django_filters as filters + +from ..models import CommitteeApplicationPeriod + + +class CommitteeApplicationPeriodFilter(filters.FilterSet): + accepting_applications = filters.BooleanFilter(field_name="accepting_applications") + actual_deadline__lte = filters.DateTimeFilter( + field_name="actual_deadline", lookup_expr="lte" + ) + actual_deadline__gte = filters.DateTimeFilter( + field_name="actual_deadline", lookup_expr="lte" + ) + + class Meta: + model = CommitteeApplicationPeriod + fields = { + "start": ["gte", "lte"], + "deadline": ["gte", "lte"], + "accepting_applications": "exact", + "actual_deadline__lte": "exact", + "actual_deadline__gte": "exact", + } diff --git a/apps/approval/api/serializers.py b/apps/approval/api/serializers.py index 9fe20f086..b6f560019 100644 --- a/apps/approval/api/serializers.py +++ b/apps/approval/api/serializers.py @@ -1,7 +1,12 @@ -from django.core.exceptions import ValidationError as DjangoValidationError +from django.core.exceptions import ValidationError +from django.utils import timezone from rest_framework import serializers -from apps.approval.models import CommitteeApplication, CommitteePriority +from apps.approval.models import ( + CommitteeApplication, + CommitteeApplicationPeriod, + CommitteePriority, +) from apps.authentication.serializers import UserReadOnlySerializer @@ -13,12 +18,61 @@ class Meta: fields = ("group", "group_name", "priority") def get_group_name(self, instance): - return instance.group.name + return instance.group.name_long + + +class CommitteeApplicationPeriodSerializer(serializers.ModelSerializer): + # Define annotated fields manually since they can't be inferred from the class + actual_deadline = serializers.DateTimeField(read_only=True) + accepting_applications = serializers.BooleanField(read_only=True) + + def validate(self, attrs: dict): + # Validate on a class instance instead of on the attrs themselves. + # This lets us use instance properties and default values correctly. + # Use request data without the 'many'-related properties, as the can't be added directly in the class. + data = attrs.copy() + data.pop("committees") + period = CommitteeApplicationPeriod(**data) + + minimum_duration = timezone.timedelta(days=1) + if period.start + minimum_duration >= period.deadline: + raise serializers.ValidationError( + "En opptaksperiode må vare i minst én dag" + ) + + actual_deadline = period.deadline + period.deadline_delta + overlapping_periods = CommitteeApplicationPeriod.objects.filter_overlapping( + period.start, actual_deadline + ) + + if overlapping_periods.exists(): + raise serializers.ValidationError( + "Opptaksperioder kan ikke overlappe med hverandre" + ) + + return attrs + + class Meta: + model = CommitteeApplicationPeriod + fields = ( + "id", + "title", + "start", + "deadline", + "deadline_delta", + "actual_deadline", + "committees", + "accepting_applications", + "year", + ) class CommitteeApplicationSerializer(serializers.ModelSerializer): committees = CommitteeSerializer(many=True, source="committeepriority_set") applicant = UserReadOnlySerializer(read_only=True) + application_period = serializers.PrimaryKeyRelatedField( + required=True, queryset=CommitteeApplicationPeriod.objects.all() + ) class Meta: model = CommitteeApplication @@ -29,20 +83,66 @@ class Meta: "application_text", "prioritized", "committees", + "application_period", ) + def _is_authenticated(self): + request = self.context.get("request") + return request.user.is_authenticated + + def validate_application_period( + self, application_period: CommitteeApplicationPeriod + ): + if not application_period.accepting_applications: + raise ValidationError( + f"Opptaksperioden {application_period} tar ikke lenger imot søknader. " + f"Opptaket stengte {application_period.deadline}" + ) + return application_period + + def validate_committees(self, committees): + committees.sort(key=lambda c: c.get("priority")) + for i in range(len(committees)): + committee = committees[i] + if i + 1 != committee.get("priority"): + raise serializers.ValidationError( + "Prioriteringer er feilformatert. " + "Prioriteringer må være en rekke med tall mellom 1 og 3. " + "Alle gruper som sendes på ha forkjsellig prioritering." + ) + + return committees + + def validate(self, attrs: dict): + email = attrs.get("email") + name = attrs.get("name") + + if not self._is_authenticated() and not (email and name): + raise ValidationError( + "Enten en brukerkonto (søker) eller navn og e-postadresse er påkrevd." + ) + + committees_priorities = attrs.get("committeepriority_set") + application_period = attrs.get("application_period") + + committees = [c.get("group") for c in committees_priorities] + allowed_committees = application_period.committees.all() + + for committee in committees: + if committee not in allowed_committees: + raise serializers.ValidationError( + f"En av de valgte komiteene er ikke del av dette opptaket, {allowed_committees}, {committees}" + ) + + return super().validate(attrs) + def create(self, validated_data): committees = validated_data.pop("committeepriority_set") - application = CommitteeApplication(**validated_data) - try: - application.clean() - except DjangoValidationError as django_error: - raise serializers.ValidationError(django_error.message) - application.save() + application = super().create(validated_data) for committee in committees: CommitteePriority.objects.create( committee_application=application, **committee ) - return CommitteeApplication.objects.get(pk=application.pk) + return application diff --git a/apps/approval/api/urls.py b/apps/approval/api/urls.py index 0654615b8..bbff84dde 100644 --- a/apps/approval/api/urls.py +++ b/apps/approval/api/urls.py @@ -1,6 +1,6 @@ from apps.api.utils import SharedAPIRootRouter -from .views import CommitteeApplicationViewSet +from .views import CommitteeApplicationPeriodViewSet, CommitteeApplicationViewSet urlpatterns = [] @@ -11,3 +11,8 @@ CommitteeApplicationViewSet, basename="committeeapplications", ) +router.register( + "committee-application-periods", + CommitteeApplicationPeriodViewSet, + basename="committee-application-periods", +) diff --git a/apps/approval/api/views.py b/apps/approval/api/views.py index ebb583e70..4ff68b331 100644 --- a/apps/approval/api/views.py +++ b/apps/approval/api/views.py @@ -1,10 +1,21 @@ -from oauth2_provider.contrib.rest_framework import OAuth2Authentication -from rest_framework.authentication import SessionAuthentication +from rest_framework.permissions import DjangoModelPermissionsOrAnonReadOnly from rest_framework.viewsets import ModelViewSet -from apps.api.permissions import TokenHasScopeOrUserHasObjectPermissionsOrWriteOnly -from apps.approval.api.serializers import CommitteeApplicationSerializer -from apps.approval.models import CommitteeApplication +from apps.api.permissions import TokenHasScopeOrUserHasModelPermissionsOrWriteOnly +from apps.approval.models import CommitteeApplication, CommitteeApplicationPeriod + +from .filters import CommitteeApplicationPeriodFilter +from .serializers import ( + CommitteeApplicationPeriodSerializer, + CommitteeApplicationSerializer, +) + + +class CommitteeApplicationPeriodViewSet(ModelViewSet): + serializer_class = CommitteeApplicationPeriodSerializer + queryset = CommitteeApplicationPeriod.objects.all() + filter_class = CommitteeApplicationPeriodFilter + permission_classes = [DjangoModelPermissionsOrAnonReadOnly] class CommitteeApplicationViewSet(ModelViewSet): @@ -15,9 +26,7 @@ class CommitteeApplicationViewSet(ModelViewSet): serializer_class = CommitteeApplicationSerializer queryset = CommitteeApplication.objects.all() - authentication_classes = [OAuth2Authentication, SessionAuthentication] - permission_classes = [TokenHasScopeOrUserHasObjectPermissionsOrWriteOnly] - required_scopes = ["approval"] + permission_classes = [TokenHasScopeOrUserHasModelPermissionsOrWriteOnly] def perform_create(self, serializer): if self.request.user and self.request.user.is_authenticated: diff --git a/apps/approval/dashboard/forms.py b/apps/approval/dashboard/forms.py new file mode 100644 index 000000000..868f10ad1 --- /dev/null +++ b/apps/approval/dashboard/forms.py @@ -0,0 +1,38 @@ +from django import forms +from django.core.exceptions import ValidationError +from django.utils import timezone + +from apps.dashboard.widgets import DatetimePickerInput, multiple_widget_generator + +from ..models import CommitteeApplicationPeriod + + +class CommitteeApplicationPeriodForm(forms.ModelForm): + def clean(self): + cleaned_data = super().clean() + data = cleaned_data.copy() + data.pop("committees") + period = CommitteeApplicationPeriod(**data) + + minimum_duration = timezone.timedelta(days=1) + if period.start + minimum_duration >= period.deadline: + raise ValidationError("En opptaksperiode må vare i minst én dag") + + actual_deadline = period.deadline + period.deadline_delta + overlapping_periods = CommitteeApplicationPeriod.objects.filter_overlapping( + period.start, actual_deadline + ) + + if overlapping_periods.exists(): + raise ValidationError("Opptaksperioder kan ikke overlappe med hverandre") + + return cleaned_data + + class Meta: + model = CommitteeApplicationPeriod + fields = ("title", "start", "deadline", "deadline_delta", "committees") + + dtp_fields = (("start", {}), ("deadline", {})) + widgetlist = [(DatetimePickerInput, dtp_fields)] + + widgets = multiple_widget_generator(widgetlist) diff --git a/apps/approval/dashboard/urls.py b/apps/approval/dashboard/urls.py index cb2bfa5fe..a2752e1da 100644 --- a/apps/approval/dashboard/urls.py +++ b/apps/approval/dashboard/urls.py @@ -1,6 +1,7 @@ # -*- encoding: utf-8 -*- from django.conf.urls import url +from django.urls import path from apps.approval.dashboard import views @@ -16,4 +17,29 @@ views.decline_application, name="approval_decline_application", ), + path( + "application-periods/", + views.ApplicationPeriodList.as_view(), + name="application-periods-list", + ), + path( + "application-periods/create/", + views.ApplicationPeriodCreate.as_view(), + name="application-periods-create", + ), + path( + "application-periods//", + views.ApplicationPeriodDetail.as_view(), + name="application-periods-detail", + ), + path( + "application-periods//update", + views.ApplicationPeriodUpdate.as_view(), + name="application-periods-update", + ), + path( + "application-periods//delete", + views.ApplicationPeriodDelete.as_view(), + name="application-periods-delete", + ), ] diff --git a/apps/approval/dashboard/views.py b/apps/approval/dashboard/views.py index cdaecb3f3..02c65f11c 100644 --- a/apps/approval/dashboard/views.py +++ b/apps/approval/dashboard/views.py @@ -6,14 +6,24 @@ from django.core.exceptions import PermissionDenied from django.http import Http404, HttpResponse from django.shortcuts import render +from django.urls import reverse from django.utils import timezone from django.utils.translation import ugettext as _ from django.views.decorators.csrf import ensure_csrf_cookie +from django.views.generic import ( + CreateView, + DeleteView, + DetailView, + TemplateView, + UpdateView, +) from guardian.decorators import permission_required -from apps.approval.models import MembershipApproval from apps.authentication.models import AllowedUsername -from apps.dashboard.tools import get_base_context, has_access +from apps.dashboard.tools import DashboardPermissionMixin, get_base_context, has_access + +from ..models import CommitteeApplicationPeriod, MembershipApproval +from .forms import CommitteeApplicationPeriodForm @ensure_csrf_cookie @@ -178,3 +188,52 @@ def decline_application(request): return HttpResponse(status=200) raise Http404 + + +class ApplicationPeriodList(DashboardPermissionMixin, TemplateView): + model = CommitteeApplicationPeriod + template_name = "approval/dashboard/application_period/index.html" + permission_required = "approval.view_committeeapplicationperiod" + + def get_context_data(self, *args, **kwargs): + context = super().get_context_data(*args, **kwargs) + context["application_periods"] = CommitteeApplicationPeriod.objects.all() + return context + + +class ApplicationPeriodCreate(DashboardPermissionMixin, CreateView): + model = CommitteeApplicationPeriod + template_name = "approval/dashboard/application_period/create.html" + permission_required = "approval.add_committeeapplicationperiod" + form_class = CommitteeApplicationPeriodForm + + def get_success_url(self): + return reverse("application-periods-list") + + +class ApplicationPeriodDetail(DashboardPermissionMixin, DetailView): + model = CommitteeApplicationPeriod + template_name = "approval/dashboard/application_period/detail.html" + permission_required = "approval.change_committeeapplicationperiod" + context_object_name = "application_period" + + +class ApplicationPeriodUpdate(DashboardPermissionMixin, UpdateView): + model = CommitteeApplicationPeriod + template_name = "approval/dashboard/application_period/create.html" + permission_required = "approval.delete_committeeapplicationperiod" + form_class = CommitteeApplicationPeriodForm + context_object_name = "application_period" + + def get_success_url(self): + return reverse("application-periods-detail", kwargs={"pk": self.object.pk}) + + +class ApplicationPeriodDelete(DashboardPermissionMixin, DeleteView): + model = CommitteeApplicationPeriod + template_name = "approval/dashboard/application_period/delete.html" + permission_required = "approval.delete_committeeapplicationperiod" + context_object_name = "application_period" + + def get_success_url(self): + return reverse("application-periods-list") diff --git a/apps/approval/migrations/0010_auto_20200321_1924.py b/apps/approval/migrations/0010_auto_20200321_1924.py new file mode 100644 index 000000000..d39be7a8c --- /dev/null +++ b/apps/approval/migrations/0010_auto_20200321_1924.py @@ -0,0 +1,65 @@ +# Generated by Django 2.2.11 on 2020-03-21 18:24 + +import datetime +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentication", "0045_auto_20200222_1436"), + ("approval", "0009_auto_20200229_1046"), + ] + + operations = [ + migrations.CreateModel( + name="CommitteeApplicationPeriod", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("title", models.CharField(max_length=128, verbose_name="Tittel")), + ("start", models.DateTimeField(verbose_name="Starttid")), + ("deadline", models.DateTimeField(verbose_name="First")), + ( + "deadline_delta", + models.DurationField( + default=datetime.timedelta(1), + help_text="Hvor lenge etter fristen skal det være mulig å søke?", + verbose_name="Slingringsmonn", + ), + ), + ( + "committees", + models.ManyToManyField( + related_name="application_periods", + to="authentication.OnlineGroup", + verbose_name="Komiteer", + ), + ), + ], + options={ + "verbose_name": "Opptaksperiode", + "verbose_name_plural": "Opptaksperioder", + "ordering": ("-start", "-deadline"), + }, + ), + migrations.AddField( + model_name="committeeapplication", + name="application_period", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="applications", + to="approval.CommitteeApplicationPeriod", + verbose_name="Opptaksperiode", + ), + ), + ] diff --git a/apps/approval/migrations/0011_auto_20200328_1032.py b/apps/approval/migrations/0011_auto_20200328_1032.py new file mode 100644 index 000000000..f99d0dbbd --- /dev/null +++ b/apps/approval/migrations/0011_auto_20200328_1032.py @@ -0,0 +1,30 @@ +# Generated by Django 2.2.11 on 2020-03-28 09:32 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [("approval", "0010_auto_20200321_1924")] + + operations = [ + migrations.AlterField( + model_name="committeeapplication", + name="committees", + field=models.ManyToManyField( + through="approval.CommitteePriority", + to="authentication.OnlineGroup", + verbose_name="komiteer", + ), + ), + migrations.AlterField( + model_name="committeepriority", + name="group", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="authentication.OnlineGroup", + verbose_name="komite", + ), + ), + ] diff --git a/apps/approval/models.py b/apps/approval/models.py index 66d0147d1..88b75641a 100644 --- a/apps/approval/models.py +++ b/apps/approval/models.py @@ -1,14 +1,15 @@ # -*- coding: utf-8 -*- from django.conf import settings -from django.contrib.auth.models import Group -from django.core.exceptions import ValidationError from django.db import models +from django.db.models import Case, ExpressionWrapper, F, Q, When from django.urls import reverse +from django.utils import timezone from django.utils.translation import ugettext as _ from apps.approval import settings as approval_settings from apps.authentication.constants import FieldOfStudyType +from apps.authentication.models import OnlineGroup User = settings.AUTH_USER_MODEL @@ -90,6 +91,80 @@ class Meta: default_permissions = ("add", "change", "delete") +class CommitteeApplicationPeriodManager(models.Manager): + def get_queryset(self): + now = timezone.now() + return ( + super() + .get_queryset() + .annotate( + actual_deadline=ExpressionWrapper( + F("deadline") + F("deadline_delta"), + output_field=models.DateTimeField(), + ) + ) + .annotate( + accepting_applications=Case( + When(Q(start__lte=now, actual_deadline__gte=now), then=True), + default=False, + output_field=models.BooleanField(), + ) + ) + ) + + def filter_overlapping(self, start: timezone.datetime, deadline: timezone.datetime): + return ( + self.get_queryset() + .filter( + Q(start__range=[start, deadline]) + | Q(deadline__range=[start, deadline]) + | ( + Q(start__lte=start, deadline__gte=start) + & Q(start__lte=deadline, deadline__gte=deadline) + ) + ) + .distinct() + ) + + +class CommitteeApplicationPeriod(models.Model): + objects = CommitteeApplicationPeriodManager() + + title = models.CharField(_("Tittel"), max_length=128) + start = models.DateTimeField(_("Starttid")) + deadline = models.DateTimeField(_("First")) + # We have a deadline delta because we often accept applications after the actual deadline. + deadline_delta = models.DurationField( + _("Slingringsmonn"), + help_text="Hvor lenge etter fristen skal det være mulig å søke?", + default=timezone.timedelta(days=1), + ) + committees = models.ManyToManyField( + to=OnlineGroup, verbose_name=_("Komiteer"), related_name="application_periods" + ) + + def accepting_applications_at_time(self, time: timezone.datetime) -> bool: + is_after_start = time >= self.start + is_before_deadline = time <= self.actual_deadline + return is_after_start and is_before_deadline + + @property + def year(self) -> str: + start_year = self.start.year + end_year = self.deadline.year + if start_year == end_year: + return str(start_year) + return f"{start_year} - {end_year}" + + def __str__(self): + return f"{self.title} ({self.year})" + + class Meta: + verbose_name = _("Opptaksperiode") + verbose_name_plural = _("Opptaksperioder") + ordering = ("-start", "-deadline") + + class CommitteeApplication(models.Model): created = models.DateTimeField("opprettet", auto_now_add=True) modified = models.DateTimeField("endret", auto_now=True) @@ -107,7 +182,15 @@ class CommitteeApplication(models.Model): application_text = models.TextField("søknadstekst") prioritized = models.BooleanField("prioriter komitevalg", default=False) committees = models.ManyToManyField( - Group, verbose_name="komiteer", through="CommitteePriority" + OnlineGroup, verbose_name="komiteer", through="CommitteePriority" + ) + application_period = models.ForeignKey( + to=CommitteeApplicationPeriod, + verbose_name=_("Opptaksperiode"), + related_name="applications", + on_delete=models.SET_NULL, + null=True, + blank=False, ) def get_name(self): @@ -121,12 +204,6 @@ def get_email(self): def get_absolute_url(self): return reverse("admin:approval_committeeapplication_change", args=(self.pk,)) - def clean(self): - if not (self.applicant or (self.email and self.name)): - raise ValidationError( - "Enten en brukerkonto (søker) eller navn og e-postadresse er påkrevd." - ) - def __str__(self): return "{created}: {applicant}".format( applicant=self.get_name(), created=self.created.strftime("%Y-%m-%d") @@ -145,7 +222,7 @@ class CommitteePriority(models.Model): CommitteeApplication, verbose_name="søknad", on_delete=models.deletion.CASCADE ) group = models.ForeignKey( - Group, verbose_name="komite", on_delete=models.deletion.CASCADE + OnlineGroup, verbose_name="komite", on_delete=models.deletion.CASCADE ) priority = models.SmallIntegerField("prioritet", choices=valid_priorities) diff --git a/apps/approval/tests.py b/apps/approval/tests.py index 94751c73c..a8aabe0af 100644 --- a/apps/approval/tests.py +++ b/apps/approval/tests.py @@ -3,15 +3,22 @@ import logging from django.core import mail -from django.core.exceptions import ValidationError from django.test import TestCase +from django.urls import reverse from django.utils import timezone from django_dynamic_fixture import G +from guardian.shortcuts import assign_perm +from rest_framework import status -from apps.approval.models import CommitteeApplication, MembershipApproval +from apps.approval.models import ( + CommitteeApplication, + CommitteeApplicationPeriod, + MembershipApproval, +) from apps.approval.tasks import send_approval_status_update -from apps.authentication.models import Email +from apps.authentication.models import Email, OnlineGroup from apps.authentication.models import OnlineUser as User +from apps.online_oidc_provider.test import OIDCTestCase class ApprovalTest(TestCase): @@ -133,31 +140,276 @@ def testEmailWhenMembershipAccepted(self): ) -class CommitteeApplicationTestCase(TestCase): - def test_anon_can_not_apply(self): - application = CommitteeApplication() - application.application_text = "something" - with self.assertRaises(ValidationError): - application.clean() +class CommitteeApplicationPeriodTestCase(OIDCTestCase): + def setUp(self): + self.now = timezone.now() + self.one_week_ago = self.now - timezone.timedelta(days=7) + self.two_days_ago = self.now - timezone.timedelta(days=2) + self.two_days_from_now = self.now + timezone.timedelta(days=2) + self.one_week_from_now = self.now + timezone.timedelta(days=7) + + assign_perm("approval.add_committeeapplicationperiod", self.user) + + self.committees = [G(OnlineGroup).id, G(OnlineGroup).id] + + def get_list_url(self): + return reverse("committee-application-periods-list") - def test_user_can_apply(self): - applicant = G(User) - application = G( - CommitteeApplication, applicant=applicant, name=None, email=None + def get_detail_url(self, _id: int): + return reverse("committee-application-periods-detail", args=[_id]) + + def test_anyone_can_get_application_period_list(self): + response = self.client.get(self.get_list_url()) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + def test_anyone_can_get_application_period_detail(self): + period = G(CommitteeApplicationPeriod) + response = self.client.get(self.get_detail_url(period.id)) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + def test_create_application_period(self): + response = self.client.post( + self.get_list_url(), + { + "title": "Hovedopptak", + "start": self.now, + "deadline": self.one_week_from_now, + "committees": self.committees, + }, + **self.headers, ) - self.assertEqual( - application, CommitteeApplication.objects.get(pk=application.pk) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + + def test_application_period_with_deadline_before_start(self): + response = self.client.post( + self.get_list_url(), + { + "title": "Hovedopptak", + "start": self.now, + "deadline": self.two_days_ago, + "committees": self.committees, + }, + **self.headers, ) - def test_name_email_can_apply(self): - application = G( - CommitteeApplication, - name="Ola Nordmann", - email="test@example.org", - user=None, + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + def test_application_period_with_deadline_too_close_to_start(self): + response = self.client.post( + self.get_list_url(), + { + "title": "Hovedopptak", + "start": self.now, + "deadline": timezone.now() + timezone.timedelta(hours=23), + "committees": self.committees, + }, + **self.headers, ) - self.assertEqual( - application, CommitteeApplication.objects.get(pk=application.pk) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + def test_overlapping_application_periods(self): + CommitteeApplicationPeriod.objects.create( + title="Hovedopptak", + start=self.two_days_ago, + deadline=self.two_days_from_now, ) + + response = self.client.post( + self.get_list_url(), + { + "title": "Suppleringsopptak", + "start": self.now, + "deadline": self.one_week_from_now, + "committees": self.committees, + }, + **self.headers, + ) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + def test_overlapping_application_periods_inside_another_period(self): + CommitteeApplicationPeriod.objects.create( + title="Hovedopptak", + start=self.one_week_ago, + deadline=self.one_week_from_now, + ) + + response = self.client.post( + self.get_list_url(), + { + "title": "Suppleringsopptak", + "start": self.two_days_ago, + "deadline": self.two_days_from_now, + "committees": self.committees, + }, + **self.headers, + ) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + def test_non_overlapping_application_periods(self): + CommitteeApplicationPeriod.objects.create( + title="Hovedopptak", start=self.two_days_ago, deadline=self.now + ) + + response = self.client.post( + self.get_list_url(), + { + "title": "Suppleringsopptak", + "start": self.two_days_from_now, + "deadline": self.one_week_from_now, + "committees": self.committees, + }, + **self.headers, + ) + + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + + +class CommitteeApplicationTestCase(OIDCTestCase): + def setUp(self): + self.now = timezone.now() + self.one_week_ago = self.now - timezone.timedelta(days=7) + self.two_days_ago = self.now - timezone.timedelta(days=2) + self.two_days_from_now = self.now + timezone.timedelta(days=2) + self.one_week_from_now = self.now + timezone.timedelta(days=7) + + self.committee1: OnlineGroup = G(OnlineGroup) + self.committee2: OnlineGroup = G(OnlineGroup) + self.committee3: OnlineGroup = G(OnlineGroup) + + self.committees_data = [ + {"group": self.committee1.id, "priority": 1}, + {"group": self.committee2.id, "priority": 2}, + ] + + self.application_period: CommitteeApplicationPeriod = G( + CommitteeApplicationPeriod, + start=self.one_week_ago, + deadline=self.two_days_from_now, + ) + self.application_period.committees.add(self.committee1) + self.application_period.committees.add(self.committee2) + + def get_list_url(self): + return reverse("committeeapplications-list") + + def get_detail_url(self, _id: int): + return reverse("committeeapplications-detail", args=[_id]) + + def test_non_authenticated_user_cannot_get_applications(self): + response = self.client.get(self.get_list_url()) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_authenticated_without_perms_cannot_get_applications(self): + response = self.client.get(self.get_list_url(), **self.headers) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_permitted_user_can_get_applications_list(self): + assign_perm("approval.view_committeeapplication", self.user) + response = self.client.get(self.get_list_url(), **self.headers) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + def test_permitted_user_can_get_applications_detail(self): + assign_perm("approval.view_committeeapplication", self.user) + application = G(CommitteeApplication) + response = self.client.get(self.get_detail_url(application.id), **self.headers) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + def test_anyone_can_create_an_application(self): + response = self.client.post( + self.get_list_url(), + { + "application_text": "--text--", + "committees": self.committees_data, + "application_period": self.application_period.id, + "name": "Test Testesen", + "email": "test@example.com", + }, + **self.bare_headers, + ) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + + def test_non_login_application_fails_without_name_and_email(self): + response = self.client.post( + self.get_list_url(), + { + "application_text": "--text--", + "application_period": self.application_period.id, + "committees": self.committees_data, + }, + **self.bare_headers, + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + def test_non_login_application_fails_without_name(self): + response = self.client.post( + self.get_list_url(), + { + "application_text": "--text--", + "committees": self.committees_data, + "application_period": self.application_period.id, + "email": "test@example.com", + }, + **self.bare_headers, + ) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + def test_non_login_application_fails_without_email(self): + response = self.client.post( + self.get_list_url(), + { + "application_text": "--text--", + "committees": self.committees_data, + "application_period": self.application_period.id, + "name": "Test Testesen", + }, + **self.bare_headers, + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + def test_users_are_assigned_when_creating_an_application(self): + response = self.client.post( + self.get_list_url(), + { + "application_text": "--text--", + "committees": self.committees_data, + "application_period": self.application_period.id, + }, + **self.headers, + ) + + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(response.json().get("applicant").get("id"), self.user.id) + + def test_cannot_apply_when_application_period_has_expired(self): + self.application_period.deadline = self.two_days_ago + self.application_period.save() + + response = self.client.post( + self.get_list_url(), + { + "application_text": "--text--", + "committees": self.committees_data, + "application_period": self.application_period.id, + }, + **self.headers, + ) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + def test_cannot_apply_with_committee_not_allowed_in_period(self): + response = self.client.post( + self.get_list_url(), + { + "application_text": "--text--", + "committees": [{"group": self.committee3.id, "priority": 1}], + "application_period": self.application_period.id, + }, + **self.headers, + ) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) diff --git a/templates/approval/dashboard/application_period/create.html b/templates/approval/dashboard/application_period/create.html new file mode 100644 index 000000000..ba212dc8e --- /dev/null +++ b/templates/approval/dashboard/application_period/create.html @@ -0,0 +1,40 @@ +{% extends 'dashboard_base.html' %} +{% load render_bundle from webpack_loader %} +{% load crispy_forms_tags %} + +{% block title %}Opptakerperiode - Legg til{% endblock %} + +{% block styles %} + {{ block.super }} +{% endblock %} + +{% block js %} + {{ block.super }} +{% endblock %} + +{% block page-header %} +{% endblock %} + +{% block breadcrumbs %} +
  • Opptaksperioder
  • +{% endblock %} + +{% block content %} +
    +
    +
    + +
    +
    +
    +
    +
    +

    Opptaksperiode

    +
    +
    + {% csrf_token %} + {{ form | crispy }} + +
    +
    +{% endblock %} diff --git a/templates/approval/dashboard/application_period/delete.html b/templates/approval/dashboard/application_period/delete.html new file mode 100644 index 000000000..7959837a0 --- /dev/null +++ b/templates/approval/dashboard/application_period/delete.html @@ -0,0 +1,32 @@ +{% extends 'dashboard_base.html' %} +{% load render_bundle from webpack_loader %} +{% load crispy_forms_tags %} + +{% block title %}Opptakerperiode - Legg til{% endblock %} + +{% block styles %} + {{ block.super }} +{% endblock %} + +{% block js %} + {{ block.super }} +{% endblock %} + +{% block page-header %} +{% endblock %} + +{% block breadcrumbs %} +
  • Opptaksperioder
  • +{% endblock %} + +{% block content %} +
    +
    +

    Er du helt sikker på at du ønsker å slette "{{ object }}"?

    +
    + +
    + {% csrf_token %} +
    +
    +{% endblock %} diff --git a/templates/approval/dashboard/application_period/detail.html b/templates/approval/dashboard/application_period/detail.html new file mode 100644 index 000000000..a6d062ccf --- /dev/null +++ b/templates/approval/dashboard/application_period/detail.html @@ -0,0 +1,76 @@ +{% extends 'dashboard_base.html' %} +{% load render_bundle from webpack_loader %} +{% load crispy_forms_tags %} + +{% block title %}{{ application_period }}{% endblock %} + +{% block styles %} + {{ block.super }} +{% endblock %} + +{% block js %} + {{ block.super }} +{% endblock %} + +{% block page-header %} + {{ application_period }} +{% endblock %} + +{% block breadcrumbs %} +
  • Opptaksperioder
  • +
  • {{ application_period }}
  • +{% endblock %} + +{% block content %} +
    +
    +
    +
    +

    + Søkere +

    +
    +
    +
    +

    Dette opptaket har mottat {{ application_period.applications.all | length }} søknader

    + Endre + Slett +
    + + + + + + + + + + + {% for application in application_period.applications.all %} + + + + + + + {% endfor %} + +
    NavnSøkte grupperPrioritert rekkefølgeTidspunkt
    + {% if application.applicant %} + {{ application.applicant.get_full_name }} + {% else %} +

    {{ application.name }} ({{ application.email }})

    + {% endif %} +
    +

    {{ application.committees.all | join:"; " }} +

    + + + {{ application.created | date:"Y-m-j H:i"}} +
    +
    +
    +
    +
    + +{% endblock %} diff --git a/templates/approval/dashboard/application_period/index.html b/templates/approval/dashboard/application_period/index.html new file mode 100644 index 000000000..c92018cd8 --- /dev/null +++ b/templates/approval/dashboard/application_period/index.html @@ -0,0 +1,47 @@ +{% extends 'dashboard_base.html' %} +{% block title %}Opptaksperioder{% endblock %} + +{% block page-header %} +Grupper +{% endblock %} + +{% block breadcrumbs %} +
  • Grupper
  • +{% endblock %} + +{% block content %} +
    +
    +

    + + + + + + + + + + + + {% for application_period in application_periods %} + + + + + + + + {% endfor %} + +
    TittelStarttidFristTar imot søknader nåAntall søknader
    {{ application_period }}{{ application_period.start | date:"Y-m-j H:i"}}{{ application_period.deadline | date:"Y-m-j H:i"}} + + {{ application_period.applications.count }}
    +
    +
    +{% endblock %} + +{% block js %} + {{ block.super }} + +{% endblock %} diff --git a/templates/dashboard_base.html b/templates/dashboard_base.html index 9a50fd3a3..569f41164 100644 --- a/templates/dashboard_base.html +++ b/templates/dashboard_base.html @@ -246,11 +246,23 @@

    Velkommen
    {{ request.user.first_name {% if 'approval.view_membershipapproval' in user_permissions %}
  • - Søknader + Medlemsskapsøknader {% if approval_pending %}{{ approval_pending }}{% endif %}
  • {% endif %} + {% if 'approval.view_committeeapplicationperiod' in user_permissions and 'approval.view_committeeapplication' in user_permissions %} +
  • + + Komitésøknader + + + +
  • + {% endif %} {% if 'feedback.view_feedback' in user_permissions %}