diff --git a/backend/api/views.py b/backend/api/views.py index 6584996..33279b8 100644 --- a/backend/api/views.py +++ b/backend/api/views.py @@ -2,7 +2,7 @@ from applications.models import Application, ApplicationAttachment from applications.serialisers import ApplicationSerialiser, AttachmentSerialiser -from django.db.models import F, Window +from django.db.models import BooleanField, Exists, F, OuterRef, Value, Window from django.db.models.functions import RowNumber from processes.models import AuthorisationProcess from processes.serialisers import AuthorisationProcessSerialiser @@ -154,4 +154,30 @@ class AuthorisationProcessViewSet(viewsets.ReadOnlyModelViewSet): "head", ] + def get_queryset(self): + """Annotate each process with whether the current user can review it.""" + queryset = super().get_queryset() + + # Compute ``can_review`` at queryset level so the database can answer it + # for the whole process list in one query. Doing this in the serializer + # would push request-aware permission logic into presentation code and + # risks per-row checks / N+1 queries. + if not self.request.user.is_authenticated: + # Anonymous users can never review; annotate a constant False so the + # serializer still receives a stable field on every process row. + return queryset.annotate( + can_review=Value(False, output_field=BooleanField()) + ) + + # Build an EXISTS subquery against the process<->group join table. If + # any linked reviewer group matches one of the current user's groups, + # the process is reviewable for that user. + reviewer_group_links = AuthorisationProcess.reviewer_groups.through.objects.filter( + authorisationprocess_id=OuterRef("pk"), + group_id__in=self.request.user.groups.values("id"), + ) + + # Expose the result as a boolean annotation for direct serialisation. + return queryset.annotate(can_review=Exists(reviewer_group_links)) + diff --git a/backend/applications/models.py b/backend/applications/models.py index 3831b38..8764f21 100644 --- a/backend/applications/models.py +++ b/backend/applications/models.py @@ -14,10 +14,14 @@ class ApplicationStatus(models.TextChoices): DRAFT = "DRAFT" DISCARDED = "DISCARDED" SUBMITTED = "SUBMITTED" + # WITHDRAWN = "WITHDRAWN" UNDER_REVIEW = "UNDER_REVIEW" ACTION_REQUIRED = "ACTION_REQUIRED" PROCESSING = "PROCESSING" + # UNDER_ASSESSMENT = "UNDER_ASSESSMENT" APPROVED = "APPROVED" + # APPROVED_WITH_CONDITIONS = "APPROVED_WITH_CONDITIONS" + # DEFERRED = "DEFERRED" REJECTED = "REJECTED" diff --git a/backend/processes/admin.py b/backend/processes/admin.py index 18123a0..de4f1af 100644 --- a/backend/processes/admin.py +++ b/backend/processes/admin.py @@ -7,7 +7,8 @@ class AuthorisationProcessAdmin(SortableAdminMixin, admin.ModelAdmin): list_display = ("sort_order", "name", "slug", "created_at", "updated_at") ordering = ("sort_order", "name") - search_fields = ("name", "slug") + search_fields = ("slug", "name", "description") + filter_horizontal = ("reviewer_groups",) def has_add_permission(self, request): return request.user.is_superuser diff --git a/backend/processes/migrations/0002_authorisationprocess_reviewer_groups.py b/backend/processes/migrations/0002_authorisationprocess_reviewer_groups.py new file mode 100644 index 0000000..d802cc1 --- /dev/null +++ b/backend/processes/migrations/0002_authorisationprocess_reviewer_groups.py @@ -0,0 +1,19 @@ +# Generated by Django 5.2.9 on 2026-03-20 08:41 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('auth', '0012_alter_user_first_name_max_length'), + ('processes', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='authorisationprocess', + name='reviewer_groups', + field=models.ManyToManyField(blank=True, help_text='Reviewer groups responsible for this authorisation process.', related_name='+', to='auth.group'), + ), + ] diff --git a/backend/processes/models.py b/backend/processes/models.py index 38c7aef..e483a6f 100644 --- a/backend/processes/models.py +++ b/backend/processes/models.py @@ -27,6 +27,12 @@ class AuthorisationProcess(models.Model): db_index=True, help_text="Controls display order in UI; lower values appear first.", ) + reviewer_groups = models.ManyToManyField( + "auth.Group", + related_name="+", + blank=True, + help_text="Reviewer groups responsible for this authorisation process.", + ) created_at = models.DateTimeField(auto_now_add=True, editable=False) updated_at = models.DateTimeField(auto_now=True, editable=False) diff --git a/backend/processes/serialisers.py b/backend/processes/serialisers.py index f631a43..8bb75ea 100644 --- a/backend/processes/serialisers.py +++ b/backend/processes/serialisers.py @@ -6,6 +6,8 @@ class AuthorisationProcessSerialiser(serializers.ModelSerializer): """Serializer for the AuthorisationProcess model.""" + can_review = serializers.BooleanField(read_only=True) + class Meta: model = AuthorisationProcess fields = ( @@ -13,6 +15,7 @@ class Meta: "name", "description", "sort_order", + "can_review", "created_at", "updated_at", ) diff --git a/backend/questionnaires/admin.py b/backend/questionnaires/admin.py index f3e81b1..7647782 100644 --- a/backend/questionnaires/admin.py +++ b/backend/questionnaires/admin.py @@ -5,7 +5,30 @@ from django.db.models.functions import RowNumber from questionnaires.forms import QuestionnaireForm -from questionnaires.models import Questionnaire +from questionnaires.models import ( + Questionnaire, + QuestionnaireMembership, + QuestionnairePermission, +) + + +@admin.register(QuestionnairePermission) +class QuestionnairePermissionAdmin(admin.ModelAdmin): + list_display = ("codename", "name", "created_at") + search_fields = ("codename", "name", "description") + ordering = ("codename",) + + +@admin.register(QuestionnaireMembership) +class QuestionnaireMembershipAdmin(admin.ModelAdmin): + list_display = ("user", "questionnaire", "created_at") + search_fields = ( + "user__email", + "user__username", + "questionnaire__name", + "questionnaire__slug", + ) + filter_horizontal = ("permissions",) @admin.register(Questionnaire) diff --git a/backend/questionnaires/models.py b/backend/questionnaires/models.py index 0766529..39afe13 100644 --- a/backend/questionnaires/models.py +++ b/backend/questionnaires/models.py @@ -50,6 +50,14 @@ class Questionnaire(models.Model): on_delete=models.PROTECT, editable=False, ) + members = models.ManyToManyField( + "users.User", + through="QuestionnaireMembership", + related_name="+", + blank=True, + null=True, + help_text="Users who participate in managing (or reporting) this questionnaire.", + ) class Meta: ordering = ( @@ -70,6 +78,76 @@ def __str__(self): return f'Questionnaire "{self.name}" (v{self.version}) for {self.process.slug}' +class QuestionnairePermission(models.Model): + """ + Declarative permissions for questionnaire membership. + + - codename: stable machine-readable identifier (used in code checks) + - name: human-friendly label shown in the Django admin + - description: free text explaining what this permission allows + """ + + id = models.BigAutoField(primary_key=True) + codename = models.SlugField( + max_length=100, + unique=True, + help_text="Machine-readable permission identifier, e.g. 'view_applications'.", + ) + name = models.CharField(max_length=255, help_text="Human readable permission name.") + description = models.TextField( + blank=True, default="", help_text="Optional description/help text." + ) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + ordering = ("codename",) + verbose_name = "Questionnaire permission" + verbose_name_plural = "Questionnaire permissions" + + def __str__(self): + return f"{self.name} ({self.codename})" + + +class QuestionnaireMembership(models.Model): + """ + Through model for Questionnaire.members -> users.User. + + Stores role permissions and other metadata about the member relationship. + """ + + id = models.BigAutoField(primary_key=True) + questionnaire = models.ForeignKey( + Questionnaire, + on_delete=models.CASCADE, + related_name="+", + ) + user = models.ForeignKey( + "users.User", + on_delete=models.CASCADE, + related_name="+", + ) + permissions = models.ManyToManyField( + QuestionnairePermission, + blank=False, + null=False, + related_name="+", + help_text="Permissions assigned to this user for this questionnaire.", + ) + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + # ensure a user has at most one membership row per questionnaire + unique_together = ("questionnaire", "user") + ordering = ("-created_at",) + + def __str__(self): + # avoid referring to a non-existing role field; show a concise summary + return f"{self.user} member for {self.questionnaire}" + + class QuestionnaireSerialiser(JsonSchemaSerialiserMixin, serializers.ModelSerializer): """Serializer for the Questionnaire model. nb: putting this into serialisers.py will cause circular import issue