From c2875c4986e07cebdc8bb2076996dbd70e2adc9a Mon Sep 17 00:00:00 2001 From: serkan Date: Thu, 4 Dec 2025 08:10:57 +0800 Subject: [PATCH 1/3] Initial model changes --- backend/applications/models.py | 7 -- backend/questionnaires/admin.py | 27 ++++++- ...ission_questionnairemembership_and_more.py | 52 +++++++++++++ backend/questionnaires/models.py | 78 +++++++++++++++++++ 4 files changed, 156 insertions(+), 8 deletions(-) create mode 100644 backend/questionnaires/migrations/0002_questionnairepermission_questionnairemembership_and_more.py diff --git a/backend/applications/models.py b/backend/applications/models.py index 138e1cb..3f3835f 100644 --- a/backend/applications/models.py +++ b/backend/applications/models.py @@ -73,13 +73,6 @@ class Meta: def __str__(self): return f"Application #{self.id} by {self.owner.username} for {self.questionnaire.name}" - # @staticmethod - # def has_access(user, key: str) -> bool: - # return Application.objects.filter( - # key=key, - # owner=user, - # ).exists() - # def certificate_path(instance, filename): # """Define the upload path for the certificate file.""" diff --git a/backend/questionnaires/admin.py b/backend/questionnaires/admin.py index 4572701..ff8c67e 100644 --- a/backend/questionnaires/admin.py +++ b/backend/questionnaires/admin.py @@ -1,7 +1,30 @@ from django.contrib import admin 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) @@ -107,3 +130,5 @@ def save_model(self, request, obj, form, change): def get_queryset(self, request): return super().get_queryset(request).distinct("slug") + + diff --git a/backend/questionnaires/migrations/0002_questionnairepermission_questionnairemembership_and_more.py b/backend/questionnaires/migrations/0002_questionnairepermission_questionnairemembership_and_more.py new file mode 100644 index 0000000..931800f --- /dev/null +++ b/backend/questionnaires/migrations/0002_questionnairepermission_questionnairemembership_and_more.py @@ -0,0 +1,52 @@ +# Generated by Django 5.2.7 on 2025-11-04 08:13 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('questionnaires', '0001_initial'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='QuestionnairePermission', + fields=[ + ('id', models.BigAutoField(primary_key=True, serialize=False)), + ('codename', models.SlugField(help_text="Machine-readable permission identifier, e.g. 'view_applications'.", max_length=100, unique=True)), + ('name', models.CharField(help_text='Human readable permission name.', max_length=255)), + ('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)), + ], + options={ + 'verbose_name': 'Questionnaire permission', + 'verbose_name_plural': 'Questionnaire permissions', + 'ordering': ('codename',), + }, + ), + migrations.CreateModel( + name='QuestionnaireMembership', + fields=[ + ('id', models.BigAutoField(primary_key=True, serialize=False)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('questionnaire', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='+', to='questionnaires.questionnaire')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='+', to=settings.AUTH_USER_MODEL)), + ('permissions', models.ManyToManyField(help_text='Permissions assigned to this user for this questionnaire.', related_name='+', to='questionnaires.questionnairepermission')), + ], + options={ + 'ordering': ('-created_at',), + 'unique_together': {('questionnaire', 'user')}, + }, + ), + migrations.AddField( + model_name='questionnaire', + name='members', + field=models.ManyToManyField(blank=True, help_text='Users who participate in managing (or reporting) this questionnaire.', null=True, related_name='+', through='questionnaires.QuestionnaireMembership', to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/backend/questionnaires/models.py b/backend/questionnaires/models.py index 80b11b7..6fe9834 100644 --- a/backend/questionnaires/models.py +++ b/backend/questionnaires/models.py @@ -38,6 +38,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: constraints = [ @@ -53,6 +61,76 @@ def __str__(self): return f'Questionnaire "{self.name}" (v{self.version})' +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 From a0a6a6d0af8c926c975ca4daeb7eecb92046737a Mon Sep 17 00:00:00 2001 From: serkan Date: Wed, 25 Feb 2026 11:38:49 +0800 Subject: [PATCH 2/3] Other application statuses --- backend/applications/models.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/backend/applications/models.py b/backend/applications/models.py index 3f3835f..cae9c45 100644 --- a/backend/applications/models.py +++ b/backend/applications/models.py @@ -12,10 +12,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" From 622692c0d87cc08780f6ef3d260e9f2bc8621b2d Mon Sep 17 00:00:00 2001 From: serkan Date: Fri, 20 Mar 2026 16:53:52 +0800 Subject: [PATCH 3/3] Add `can_update: boolean` field to the process API endpoint --- backend/api/views.py | 28 +++++++++- backend/processes/admin.py | 3 +- ...02_authorisationprocess_reviewer_groups.py | 19 +++++++ backend/processes/models.py | 6 +++ backend/processes/serialisers.py | 3 ++ ...ission_questionnairemembership_and_more.py | 52 ------------------- 6 files changed, 57 insertions(+), 54 deletions(-) create mode 100644 backend/processes/migrations/0002_authorisationprocess_reviewer_groups.py delete mode 100644 backend/questionnaires/migrations/0002_questionnairepermission_questionnairemembership_and_more.py 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/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/migrations/0002_questionnairepermission_questionnairemembership_and_more.py b/backend/questionnaires/migrations/0002_questionnairepermission_questionnairemembership_and_more.py deleted file mode 100644 index 931800f..0000000 --- a/backend/questionnaires/migrations/0002_questionnairepermission_questionnairemembership_and_more.py +++ /dev/null @@ -1,52 +0,0 @@ -# Generated by Django 5.2.7 on 2025-11-04 08:13 - -import django.db.models.deletion -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('questionnaires', '0001_initial'), - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] - - operations = [ - migrations.CreateModel( - name='QuestionnairePermission', - fields=[ - ('id', models.BigAutoField(primary_key=True, serialize=False)), - ('codename', models.SlugField(help_text="Machine-readable permission identifier, e.g. 'view_applications'.", max_length=100, unique=True)), - ('name', models.CharField(help_text='Human readable permission name.', max_length=255)), - ('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)), - ], - options={ - 'verbose_name': 'Questionnaire permission', - 'verbose_name_plural': 'Questionnaire permissions', - 'ordering': ('codename',), - }, - ), - migrations.CreateModel( - name='QuestionnaireMembership', - fields=[ - ('id', models.BigAutoField(primary_key=True, serialize=False)), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('updated_at', models.DateTimeField(auto_now=True)), - ('questionnaire', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='+', to='questionnaires.questionnaire')), - ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='+', to=settings.AUTH_USER_MODEL)), - ('permissions', models.ManyToManyField(help_text='Permissions assigned to this user for this questionnaire.', related_name='+', to='questionnaires.questionnairepermission')), - ], - options={ - 'ordering': ('-created_at',), - 'unique_together': {('questionnaire', 'user')}, - }, - ), - migrations.AddField( - model_name='questionnaire', - name='members', - field=models.ManyToManyField(blank=True, help_text='Users who participate in managing (or reporting) this questionnaire.', null=True, related_name='+', through='questionnaires.QuestionnaireMembership', to=settings.AUTH_USER_MODEL), - ), - ]