Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,20 @@ Entries should be concise, single-sentence summaries without excessive technical
- Added comprehensive feature development guide ([FEATURE-DEVELOPMENT.md](docs/FEATURE-DEVELOPMENT.md)) consolidating all mandatory best practices, testing requirements, code quality standards, and common commands in a single reference document for AI agents and developers.
- Added new frontend as well as E2E tests for comprehensive coverage of "New application" page functionality.
- Added formal specification of application status workflow ([STATUS-WORKFLOW.md](docs/STATUS-WORKFLOW.md)) documenting all 13 state transitions, permissions, and business rules with comprehensive test coverage across backend API (19 tests), E2E (6 tests), and frontend (10 statuses verified).
- Added submission modal displayed after successful application submission and on page load for read-only applications, providing confirmation and options to download application PDF or exit the application.
- Added technical officers review page workflow actions enabling reviewers to claim applications for review, reset applications to draft for applicant revision, and proceed applications to assessment stage with confirmation dialogs for each action.
- Added audit logging for reviewer and assessor actions, recording every application status change with user, timestamp, and status transition details in an immutable audit log accessible through the Django admin interface for regulatory compliance and investigation purposes.

### Changed

- Renamed "Assessment" terminology to "Review" throughout the application, including API endpoints (/api/assessment → /api/review), menu navigation ("Assessment Queue" → "Review Queue"), and related components and fixtures, to align with domain conventions.
- Standardised on npm for all frontend package management across development, CI, testing, and production environments to ensure identical dependency versions and predictable builds.
- Disabled questionnaire tabs when only a single questionnaire to prevent user confusion from clicking non-functional tabs.

### Fixed

- Fixed submit button allowing duplicate API submissions by adding loading indicator and disabled state during submission process.

### Removed

- Removed `ACTION_REQUIRED` status; applications now use concrete workflow states (DRAFT → SUBMITTED → UNDER_REVIEW → UNDER_ASSESSMENT → decision outcomes) with explicit transition rules and permission boundaries - REQUIRES DATABASE MIGRATION.
Expand Down
100 changes: 5 additions & 95 deletions backend/api/tests/conftest.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,14 @@
"""Shared fixtures for API endpoint test modules.

This package-local conftest keeps API-focused factories close to endpoint
behaviour tests while reusing core fixtures from backend/conftest.py.
This module defines API-specific fixtures. Common factories (questionnaire_factory,
application_factory, process_factory) are inherited from backend/conftest.py.
"""

from itertools import count

import pytest
from applications.models import Application, ApplicationAttachment, ApplicationStatus
from applications.models import ApplicationAttachment
from django.contrib.auth.models import Group
from processes.models import AuthorisationProcess
from questionnaires.models import Questionnaire
from django.core.files.uploadedfile import SimpleUploadedFile
from itertools import count


@pytest.fixture
Expand All @@ -29,97 +27,9 @@ def reviewer_user(db, reviewer_group):
return user


@pytest.fixture
def process_factory(db):
"""Return a factory that creates authorisation processes with deterministic defaults."""
sequence = count(1)

def _create(**overrides):
index = next(sequence)
values = {
"slug": f"proc-{index}",
"name": f"Process {index}",
"description": f"Process description {index}",
"sort_order": index,
}
values.update(overrides)
return AuthorisationProcess.objects.create(**values)

return _create


@pytest.fixture
def questionnaire_factory(db, process_factory, user):
"""Return a factory that creates questionnaires for list/retrieve and versioning tests."""
sequence = count(1)

def _create(**overrides):
index = next(sequence)
process = overrides.pop("process", process_factory())
values = {
"process": process,
"code": f"form-{index}",
"name": f"Questionnaire {index}",
"description": f"Questionnaire description {index}",
"version": 1,
"document": {
"schema_version": "2025.07-1",
"steps": [
{
"title": "Step 1",
"description": "",
"sections": [
{
"title": "Section 1",
"description": "",
"questions": [
{
"label": "Question 1",
"type": "text",
"is_required": False,
"description": "",
}
],
}
],
}
],
},
"sort_order": index,
"created_by": user,
}
values.update(overrides)
return Questionnaire.objects.create(**values)

return _create


@pytest.fixture
def application_factory(db, user, questionnaire_factory):
"""Return a factory that creates application rows with configurable ownership and status."""

def _create(**overrides):
values = {
"owner": user,
"questionnaire": questionnaire_factory(),
"status": ApplicationStatus.DRAFT,
"document": {
"schema_version": "2025.07-1",
"active_step": 0,
"steps": [{"is_valid": None, "answers": {}}],
},
}
values.update(overrides)
return Application.objects.create(**values)

return _create


@pytest.fixture
def attachment_factory(db, application_factory):
"""Return a factory that creates attachment records bound to application/question pairs."""
from django.core.files.uploadedfile import SimpleUploadedFile

sequence = count(1)

def _create(**overrides):
Expand Down
2 changes: 1 addition & 1 deletion backend/api/tests/test_api_endpoint_security.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
import pytest
from rest_framework import status

from applications.models import ApplicationStatus
from applications.statuses import ApplicationStatus


pytestmark = [pytest.mark.api, pytest.mark.security]
Expand Down
2 changes: 1 addition & 1 deletion backend/api/tests/test_applications_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
from rest_framework import status

import applications.serialisers as application_serialisers
from applications.models import ApplicationStatus
from applications.statuses import ApplicationStatus


pytestmark = [pytest.mark.api]
Expand Down
110 changes: 108 additions & 2 deletions backend/api/tests/test_reviewer_api.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"""API tests for reviewer queue list/retrieve/update endpoints."""

import pytest
from applications.models import ApplicationStatus
from applications.statuses import ApplicationStatus
from rest_framework import status

pytestmark = [pytest.mark.api]
Expand Down Expand Up @@ -169,6 +169,7 @@ def test_reviewer_retrieve_returns_404_for_unreviewable_process(


@pytest.mark.django_db
@pytest.mark.security
def test_reviewer_patch_allows_reviewer_settable_status(
api_client,
reviewer_user,
Expand All @@ -178,11 +179,15 @@ def test_reviewer_patch_allows_reviewer_settable_status(
application_factory,
):
"""Allow reviewers to move queue items to permitted reviewer statuses."""
from django.utils import timezone

process = process_factory(slug="review-process")
process.reviewer_groups.add(reviewer_group)
original_submitted_at = timezone.now()
application = application_factory(
questionnaire=questionnaire_factory(process=process),
status=ApplicationStatus.SUBMITTED,
submitted_at=original_submitted_at,
)

api_client.force_authenticate(user=reviewer_user)
Expand All @@ -195,9 +200,11 @@ def test_reviewer_patch_allows_reviewer_settable_status(
application.refresh_from_db()
assert response.status_code == status.HTTP_200_OK
assert application.status == ApplicationStatus.UNDER_REVIEW
assert application.submitted_at == original_submitted_at


@pytest.mark.django_db
@pytest.mark.security
def test_reviewer_patch_rejects_non_reviewer_settable_target_status(
api_client,
reviewer_user,
Expand All @@ -207,11 +214,14 @@ def test_reviewer_patch_rejects_non_reviewer_settable_target_status(
application_factory,
):
"""Verify reviewers can return an application to DRAFT via correct workflow."""
from django.utils import timezone

process = process_factory(slug="review-process")
process.reviewer_groups.add(reviewer_group)
application = application_factory(
questionnaire=questionnaire_factory(process=process),
status=ApplicationStatus.SUBMITTED,
submitted_at=timezone.now(),
)

api_client.force_authenticate(user=reviewer_user)
Expand All @@ -226,7 +236,7 @@ def test_reviewer_patch_rejects_non_reviewer_settable_target_status(
application.refresh_from_db()
assert application.status == ApplicationStatus.UNDER_REVIEW

# Then: Transition UNDER_REVIEW → DRAFT
# Then: Transition UNDER_REVIEW → DRAFT (should clear submitted_at)
response = api_client.patch(
f"/api/review/{application.key}",
{"status": ApplicationStatus.DRAFT},
Expand All @@ -235,6 +245,7 @@ def test_reviewer_patch_rejects_non_reviewer_settable_target_status(
assert response.status_code == status.HTTP_200_OK
application.refresh_from_db()
assert application.status == ApplicationStatus.DRAFT
assert application.submitted_at is None


@pytest.mark.django_db
Expand Down Expand Up @@ -498,3 +509,98 @@ def test_reviewer_list_includes_questionnaire_sort_order(
assert response.data[0]["questionnaire_sort_order"] == 3
assert "process_sort_order" in response.data[0]
assert response.data[0]["process_sort_order"] == 1


@pytest.mark.django_db
@pytest.mark.security
def test_reviewer_patch_non_reviewer_cannot_change_status(
api_client,
user,
reviewer_group,
process_factory,
questionnaire_factory,
application_factory,
):
"""Reject non-reviewer attempts to change application status via PATCH endpoint."""
process = process_factory(slug="non-reviewer-test")
process.reviewer_groups.add(reviewer_group)
application = application_factory(
questionnaire=questionnaire_factory(process=process),
status=ApplicationStatus.SUBMITTED,
)

api_client.force_authenticate(user=user)
response = api_client.patch(
f"/api/review/{application.key}",
{"status": ApplicationStatus.UNDER_REVIEW},
format="json",
)

assert response.status_code == status.HTTP_404_NOT_FOUND
application.refresh_from_db()
assert application.status == ApplicationStatus.SUBMITTED


@pytest.mark.django_db
@pytest.mark.security
def test_reviewer_patch_submitted_at_cleared_only_on_draft_transition(
api_client,
reviewer_user,
reviewer_group,
process_factory,
questionnaire_factory,
application_factory,
):
"""Verify submitted_at is cleared only when transitioning to DRAFT, not on other transitions."""
from django.utils import timezone

process = process_factory(slug="submitted-at-test")
process.reviewer_groups.add(reviewer_group)
original_submitted_at = timezone.now()
application = application_factory(
questionnaire=questionnaire_factory(process=process),
status=ApplicationStatus.SUBMITTED,
submitted_at=original_submitted_at,
)

api_client.force_authenticate(user=reviewer_user)

# Transition 1: SUBMITTED → UNDER_REVIEW (submitted_at should be preserved)
response = api_client.patch(
f"/api/review/{application.key}",
{"status": ApplicationStatus.UNDER_REVIEW},
format="json",
)
assert response.status_code == status.HTTP_200_OK
application.refresh_from_db()
assert application.status == ApplicationStatus.UNDER_REVIEW
assert application.submitted_at == original_submitted_at

# Transition 2: UNDER_REVIEW → UNDER_ASSESSMENT (submitted_at should still be preserved)
response = api_client.patch(
f"/api/review/{application.key}",
{"status": ApplicationStatus.UNDER_ASSESSMENT},
format="json",
)
assert response.status_code == status.HTTP_200_OK
application.refresh_from_db()
assert application.status == ApplicationStatus.UNDER_ASSESSMENT
assert application.submitted_at == original_submitted_at

# Create a new application to test DRAFT transition
application2 = application_factory(
questionnaire=questionnaire_factory(process=process),
status=ApplicationStatus.UNDER_REVIEW,
submitted_at=original_submitted_at,
)

# Transition 3: UNDER_REVIEW → DRAFT (submitted_at should be cleared)
response = api_client.patch(
f"/api/review/{application2.key}",
{"status": ApplicationStatus.DRAFT},
format="json",
)
assert response.status_code == status.HTTP_200_OK
application2.refresh_from_db()
assert application2.status == ApplicationStatus.DRAFT
assert application2.submitted_at is None
3 changes: 2 additions & 1 deletion backend/api/tests/test_status_workflow.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@
from datetime import timedelta

import pytest
from applications.models import Application, ApplicationStatus
from applications.models import Application
from applications.statuses import ApplicationStatus
from django.utils import timezone
from rest_framework import status

Expand Down
24 changes: 22 additions & 2 deletions backend/api/views.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,19 @@
import uuid

from applications.models import (
REVIEW_QUEUE_STATUSES,
Application,
ApplicationAttachment,
)
from applications.statuses import (
ApplicationStatus,
REVIEW_QUEUE_STATUSES,
)
from applications.serialisers import (
ApplicationSerialiser,
AttachmentSerialiser,
ReviewerSerialiser,
)
from audit.models import record_application_status_change
from django.db.models import BooleanField, Exists, F, OuterRef, Q, Value, Window
from django.db.models.functions import RowNumber
from django.utils import timezone
Expand Down Expand Up @@ -305,7 +308,24 @@ def partial_update(self, request, *args, **kwargs):
instance = self.get_object()
serializer = self.get_serializer(instance, data=request.data, partial=True)
serializer.is_valid(raise_exception=True)
serializer.save()

# Capture the status before the change for audit logging.
previous_status = instance.status

save_kwargs = {}
requested_status = serializer.validated_data.get("status")

# Clear submitted_at when returning to DRAFT (reviewer requests info or re-submission).
# This allows the application to be resubmitted with a fresh internal_id if needed.
if requested_status == ApplicationStatus.DRAFT:
save_kwargs["submitted_at"] = None

serializer.save(**save_kwargs)

# Log the status change for audit and regulatory compliance.
record_application_status_change(
instance, request.user, previous_status, instance.status
)

# Clear any prefetch cache so the response reflects the saved state.
if getattr(instance, "_prefetched_objects_cache", None):
Expand Down
Loading