Skip to content
28 changes: 27 additions & 1 deletion backend/api/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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))


4 changes: 4 additions & 0 deletions backend/applications/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"


Expand Down
3 changes: 2 additions & 1 deletion backend/processes/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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'),
),
]
6 changes: 6 additions & 0 deletions backend/processes/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
3 changes: 3 additions & 0 deletions backend/processes/serialisers.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,16 @@
class AuthorisationProcessSerialiser(serializers.ModelSerializer):
"""Serializer for the AuthorisationProcess model."""

can_review = serializers.BooleanField(read_only=True)

class Meta:
model = AuthorisationProcess
fields = (
"slug",
"name",
"description",
"sort_order",
"can_review",
"created_at",
"updated_at",
)
Expand Down
25 changes: 24 additions & 1 deletion backend/questionnaires/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: The QuestionnaireMembershipAdmin search_fields references questionnaire__slug, but the slug field is removed from the Questionnaire model in a later migration, which will cause a FieldError.
Severity: HIGH

Suggested Fix

Remove the 'questionnaire__slug' entry from the search_fields tuple in the QuestionnaireMembershipAdmin class located in backend/questionnaires/admin.py. Replace it with a valid field from the Questionnaire model if search functionality on a related field is still desired.

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent.
Verify if this is a real issue. If it is, propose a fix; if not, explain why it's not
valid.

Location: backend/questionnaires/admin.py#L29

Potential issue: The `QuestionnaireMembershipAdmin` class defines `questionnaire__slug`
in its `search_fields`. However, the `slug` field is removed from the `Questionnaire`
model in migration
`0003_remove_questionnaire_qnaire_unique_slug_version_desc_and_more.py`. After
migrations are applied, any attempt to use the search bar in the
`QuestionnaireMembership` admin page will cause Django to query a non-existent field,
raising a `FieldError` and crashing the admin view.

Did we get this right? 👍 / 👎 to inform future reviews.

)
filter_horizontal = ("permissions",)


@admin.register(Questionnaire)
Expand Down
78 changes: 78 additions & 0 deletions backend/questionnaires/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = (
Expand All @@ -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)
Comment on lines +81 to +90
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: The new models QuestionnairePermission and QuestionnaireMembership are missing corresponding database migrations, which will cause the application to crash on startup.
Severity: CRITICAL

Suggested Fix

Generate the missing migrations by running python manage.py makemigrations questionnaires. Commit the newly created migration file and include it in the pull request.

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent.
Verify if this is a real issue. If it is, propose a fix; if not, explain why it's not
valid.

Location: backend/questionnaires/models.py#L81-L90

Potential issue: The new models `QuestionnairePermission` and `QuestionnaireMembership`,
along with the `Questionnaire.members` ManyToMany field, have been defined but the
necessary Django database migrations have not been generated. When the application
starts, Django's admin will attempt to register these models, as they are decorated with
`@admin.register`. This will trigger database queries for tables that do not exist,
causing the application to fail on startup with a database error like `OperationalError:
no such table`.

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
Expand Down