# Fund Administration gap-closure — remaining items: phased plan

Status: planning document, no code written yet. Written 2026-07-07 after reading the current
codebase in full (via research agents) plus the old RIMS system at
`/Users/rtv-lpt-403/Desktop/Projects/RUFORUM/rims_ruforum_old-main`.

## Scope

Three independent items, tackled as three separate pieces of work (separate branches/commits
recommended, since they touch different files and have no shared dependency):

| # | Item | App | Size |
|---|------|-----|------|
| A | AM028 — persist conflict-resolution outcome | `apps/rims/grants` | Small |
| B | GPS027 — funding/budget revision workflow UI | `apps/rims/grants` | Medium |
| C | Psychometric test — faithful port from old system | `apps/rims/grants` | Large |

**Item 4 ("Implementation & Progress Monitoring") is dropped from scope entirely**, per your
decision — it was a misreading of an existing MEL cross-link (`ProgramDirectorDashboardView`'s
link to `apps.mel.indicators.models.LogFrame`), not a real Fund Administration FR. No further
action needed; nothing in this plan touches it.

**Recommended order:** A → B → C (smallest/most isolated first, largest/most novel last). All
three can also be done in parallel by different people since they don't share files beyond
`apps/rims/grants/models.py` / `services.py` / `views.py` / `urls.py` (textually distant regions
in each, but real files — coordinate migration numbers if run in parallel; see each item's
migration-number note).

## Testing protocol (applies to all three items)

Per `CLAUDE.md`: one `pytest` invocation at a time via Docker Compose.

```
docker compose exec -T web sh -c "python -m pytest <path> > /tmp/res.txt 2>&1; echo EXIT $?; tail -3 /tmp/res.txt"
```

Before assuming a hang, check for stale `test_iilmp` locks:

```
docker compose exec -T postgres sh -c 'psql -U $POSTGRES_USER -d postgres -c "select pid,state,backend_start from pg_stat_activity where datname='"'"'test_iilmp'"'"';"'
```

and clear per CLAUDE.md's recipe (`pg_terminate_backend` + `DROP DATABASE IF EXISTS`) before
re-running.

Use `force_login(user)` in tests, never `Client.login()` (django-axes rejects it — see
project memory `feedback_axes_client_login`).

---

## ITEM A — AM028: persist conflict-resolution outcome

### Current state (verified by reading the code, not the PRD)

- `apps/rims/grants/review_conflicts.py:105` `resolve_conflict(application)` is pure/read-only —
  no `.save()` anywhere in the module. Returns a frozen dataclass
  `ConflictResolutionOutcome(policy, has_conflict, resolved_code, rationale, reviews)`
  (review_conflicts.py:42-59).
- Three protocols, keyed off `application.call.conflict_resolution`
  (`GrantCall.ConflictResolution`: `MAJORITY` / `AVERAGE_THRESHOLD` / `ESCALATE`,
  models.py:459-468):
  - `MAJORITY` (review_conflicts.py:66-80): strict majority of `recommendation_code` (needs
    `count*2 > total`); else `resolved_code=None`.
  - `AVERAGE_THRESHOLD` (review_conflicts.py:83-102): average of `Review.total_score` vs
    `call.review_score_threshold`; resolves only to APPROVE or REJECT.
  - `ESCALATE` (review_conflicts.py:148-154): always `resolved_code=None`, rationale
    `"Escalated to call manager."`.
- **Only call site**: `ManagerApplicationDetailView.get_context_data()` (views.py:1674-1746,
  read-only `DetailView`), which does `ctx["reviewer_conflict_outcome"] = resolve_conflict(...)`
  (views.py:1719) purely for template rendering. Recomputed fresh on every GET.
- **The only POST action**, `ReviewConflictAckView` (views.py:2553-2561), does not call
  `resolve_conflict()` at all — it just sets `Application.review_conflict_acknowledged = True`
  (models.py:847-850, the *only* conflict-related field that exists today) and saves. No audit
  log call despite a docstring claiming "audit trail via updated timestamp."
- Template `grants/manager_application_detail.html:222-272` shows the live-computed outcome only
  while `review_conflict_acknowledged` is False; once acknowledged, the whole card (and the
  outcome) disappears — there is no persisted historical record of what was actually decided.
- Existing tests (`test_review_conflicts.py`, 218 lines) cover `resolve_conflict()` policy logic
  directly but have **zero coverage of `ReviewConflictAckView`** or of persistence.
- Do not confuse this with `ConflictOfInterest` (models.py:1024-1038) — a separate, unrelated
  reviewer-recusal model (FRFA-AM029), already fully built.

### Design decision: shape of the persisted record

Recommend **flat fields directly on `Application`**, not a new model — this is a single
per-application decision event (one conflict resolution per application, not a repeating list
like amendments/revisions), so it mirrors `AwardCloseoutRecord`'s flat
`approval_status`/`pd_decided_by`/`pd_decided_at`/`pd_rejection_reason` pattern
(models.py:1246-1273) rather than `AwardAmendment`'s standalone-model pattern. New fields on
`Application`:

```python
conflict_resolution_policy = models.CharField(max_length=32, blank=True)   # snapshot of GrantCall.ConflictResolution value at decision time
conflict_resolved_code = models.CharField(max_length=16, blank=True)       # Review.RecommendationCode value, or "" if manual/escalated
conflict_resolution_rationale = models.TextField(blank=True)              # resolve_conflict()'s .rationale, snapshotted
conflict_resolved_by = models.ForeignKey(AUTH_USER_MODEL, null=True, blank=True, on_delete=models.SET_NULL, related_name="conflicts_resolved")
conflict_resolved_at = models.DateTimeField(null=True, blank=True)
```

Keep `review_conflict_acknowledged` as-is (still gates the template's "is this open" check) —
the new fields are the durable *what was decided* record; the boolean remains the *is this
closed* flag. Do not collapse them into one; they answer different questions and existing
template logic already depends on the boolean.

### Phase A1 — Model + migration

- Add the 5 fields above to `Application` in `apps/rims/grants/models.py` (near
  `review_conflict_acknowledged` at line ~847 for locality).
- Migration: next number is **`0052_...`** (last existing is
  `0051_awardcloseoutrecord_approval_status_and_more.py`, untracked — coordinate if Item B or C
  also add migrations in parallel; run `makemigrations` last, right before this item's tests).

### Phase A2 — View wiring

- In `ReviewConflictAckView.post()` (views.py:2553-2561): call `resolve_conflict(app)` **once**,
  persist its result into the 5 new fields (plus `conflict_resolved_by=request.user`,
  `conflict_resolved_at=timezone.now()`), *then* set `review_conflict_acknowledged=True`, save
  all fields together in one `update_fields=[...]` list.
- Add the missing `audit_rims(...)` call immediately after, following the exact convention at
  `RequestApplicationRevisionView.post` (views.py:2564-2589):
  ```python
  from apps.rims.grants.audit_helper import action_update, audit_rims
  audit_rims(
      actor=request.user, action=action_update(), target_model="Application",
      object_id=app.pk, object_repr=str(app),
      changes={
          "conflict_resolution_policy": app.conflict_resolution_policy,
          "conflict_resolved_code": app.conflict_resolved_code,
      },
      request=request,
  )
  ```
- In `ManagerApplicationDetailView.get_context_data()` (views.py:1674-1746): change the
  behavior so that once `review_conflict_acknowledged` is True, the context serves the
  **persisted** fields instead of recomputing `resolve_conflict()` fresh (guards against the
  scenario the user flagged: scores changing after the fact and silently diverging from what was
  actually decided). Simplest approach: keep computing `reviewer_conflict_outcome` for the
  *unacknowledged* case (as today), but add `ctx["conflict_decision"]` sourced from the model's
  persisted fields whenever `review_conflict_acknowledged` is True, and branch the template on
  that.

### Phase A3 — Template

- `grants/manager_application_detail.html:222-272`: after acknowledgement, replace the
  currently-empty state with a small "Conflict resolved" summary card reading from
  `conflict_decision` (policy, resolved code, rationale, who/when) — reuses the same card
  markup already in that block, just a second branch instead of nothing.

### Phase A4 — Tests

New test module `apps/rims/grants/tests/test_am028_conflict_resolution_persistence.py`:
1. POST to `application_ack_review_conflict` with a genuine majority-conflict setup → assert the
   5 new fields are populated correctly (policy/code/rationale/actor/timestamp) and
   `review_conflict_acknowledged=True`.
2. A follow-up GET to `manager_application_detail` after acknowledgement shows the **persisted**
   decision, not a fresh recomputation — mutate a `Review.total_score` after acknowledgement and
   confirm the displayed outcome does **not** change (this is the core regression this item
   exists to prevent).
3. Confirm `audit_rims`/`AuditLog` gets a row for the action (check via
   `apps.core.audit.models.AuditLog.objects.filter(...)`, following whatever existing pattern
   other `audit_rims` tests use in this app — grep an existing example first).
4. Escalate-policy case: `resolved_code` persists as empty string, not None-crash, when template
   renders it.

### Acceptance criteria

- `ReviewConflictAckView` no longer silently discards the computed outcome.
- Re-GETting the page after acknowledgement shows a stable, previously-decided outcome even if
  underlying review scores change afterward.
- An `AuditLog` row exists for every acknowledgement.

---

## ITEM B — GPS027: funding/budget revision workflow UI

### Current state (verified)

- `FundingRecordBudgetRevision` (models.py:1963-2023) already exists, FSM-gated
  (`django_fsm.FSMField`, `protected=True`, transitions `REQUESTED→APPROVED`/`REQUESTED→REJECTED`
  only — terminal states cannot be re-decided). Migration `0025_fundingrecordbudgetrevision.py`
  already applied/committed.
- `create_funding_revision()` / `approve_funding_revision()` / `reject_funding_revision()`
  (services.py:2826-2917) are correct and already unit-tested
  (`tests/test_funding_revision.py`, 5 tests, all calling the service layer directly — **no view
  test exists**, which is exactly the blind spot the user warned about).
- **Confirmed zero UI wiring**: 0 matches for "revision" (this model) in `urls.py`, `views.py`,
  `funding_views.py`, or any template. Do not change the service functions' logic — they're
  correct as-is.
- Service functions do **not** call `audit_rims` or send notifications — that must happen at the
  view layer (see Phase B2).
- A `budget_revision` status-badge kind is **already defined and unused**:
  `rims_tags.py:165-169,227` — `BUDGET_REVISION_BADGES = {"requested": ("amber", "Pending
  approval"), "approved": ("green", "Approved"), "rejected": ("red", "Rejected")}`. Use
  `{% rims_status_badge rev 'budget_revision' %}` directly — no new badge work needed.

### Reusable pattern: clone `AwardAmendment`'s end-to-end flow

`AwardAmendment` (models.py:1511-1577) has an identical field shape
(`change_summary`/`justification`/`previous_terms`/`new_terms`/`requested_by`/`approver`/
`rejection_reason`/`decided_at`, same FSM shape) and a complete, working UI already built:

- `AwardAmendmentCreateView(GrantsManagerMixin, View)` (views.py:3034-3050)
- `AwardAmendmentDetailView(RimsAccessMixin, DetailView)` (views.py:3220-3255) — computes
  `can_decide` / `can_resubmit` flags in `get_context_data`, the segregation-of-duties gate
  pattern to copy verbatim.
- `AwardAmendmentApproveView` / `AwardAmendmentRejectView` (`ProgramDirectorMixin`,
  views.py:3258-3292)
- Template `award_amendment_detail.html` (already built, untracked) — `page_header`,
  `ai_callout` explaining the SoD rule, a `decision-rail` aside with approve/reject forms, and a
  reusable `_amendment_diff.html` partial (takes generic `previous_terms`/`new_terms` context —
  **reuse this partial as-is**, just pass `funding_revision`'s terms into it).
- Discovery today is per-record (a link on the award's own amendment timeline), **not** a global
  queue — mirror this for revisions too (a link/button on `funding_detail.html`), unless you
  decide a queue is worth adding (see decision below).

### Decisions to confirm before Phase B1

1. **Approver gate — do NOT reuse `FundingApproveMixin` as-is.** It pairs
   `allowed_roles=(ADMIN, SYSTEM_ADMIN, PROGRAM_DIRECTOR, FINANCE_DIRECTOR)` with
   `rims_permissions=(APPROVE_GRANTS,)` — per the in-code warning at views.py:3099-3107, pairing
   role + permission means a Grants Manager holding `MANAGE_CALLS` could also satisfy this gate
   through the permission branch, defeating the "GM cannot approve their own request" guarantee
   that `AwardAmendment` has via role-only `ProgramDirectorMixin`. **Recommendation**: add a new
   role-only mixin, e.g. `FundingRevisionApproverMixin(RimsAccessMixin)` with
   `allowed_roles=(UserRole.ADMIN, UserRole.PROGRAM_DIRECTOR, UserRole.FINANCE_DIRECTOR)` and
   **no** `rims_permissions`, defined in `funding_views.py` near the other Funding mixins. Flag
   this choice in your PR description since it's a judgment call, not something explicit in the
   PRD.
2. **Discovery: per-record link only, or also add to a global queue?** `FundingApprovalQueueView`
   (funding_views.py:194-233) already exists for `PENDING_APPROVAL` funding records — you could
   extend it to also list pending revisions, or keep this scoped to a link on
   `funding_detail.html` only (matching how amendments work today, which also lack a global
   queue). **Recommendation**: keep it scoped to `funding_detail.html` for this pass — matches
   the existing amendment precedent and avoids scope creep; note in the report that a future
   queue-view addition would be trivial given the existing `FundingApprovalQueueView` scaffold.
3. **New-terms input UX**: `AwardAmendmentForm` takes raw `new_terms_json`/`previous_terms_json`
   textareas (per existing convention). Match this for `FundingRevisionForm` for consistency
   rather than building a friendlier structured form (amount/dates/frequencies as separate
   fields) — flag the friendlier version as an optional future enhancement, out of scope here.

### Phase B1 — Views + URLs

New views in `apps/rims/grants/funding_views.py` (not `views.py` — this module already owns
`FundingRecord`'s views/mixins):
- `FundingRevisionCreateView(FundingManagerMixin, View)` — POST-only, validates a new
  `FundingRevisionForm`, calls `create_funding_revision(...)`, `audit_rims(...)`, redirect to
  `funding_detail`.
- `FundingRevisionDetailView(FundingReadMixin, DetailView)` — decision page, `can_decide`
  computed as `status == REQUESTED and role in (PROGRAM_DIRECTOR, FINANCE_DIRECTOR, ADMIN)`.
- `FundingRevisionApproveView(FundingRevisionApproverMixin, View)` — calls
  `approve_funding_revision(...)`, then `audit_rims(...)`.
- `FundingRevisionRejectView(FundingRevisionApproverMixin, View)` — requires `reason` from POST,
  calls `reject_funding_revision(...)`, then `audit_rims(...)`.

URLs in `apps/rims/grants/urls.py` (near the existing `funding/` routes):
```
path("manage/funding/<int:pk>/revisions/new/", views.FundingRevisionCreateView.as_view(), name="funding_revision_create"),
path("manage/funding/revisions/<int:pk>/", views.FundingRevisionDetailView.as_view(), name="funding_revision_detail"),
path("manage/funding/revisions/<int:pk>/approve/", views.FundingRevisionApproveView.as_view(), name="funding_revision_approve"),
path("manage/funding/revisions/<int:pk>/reject/", views.FundingRevisionRejectView.as_view(), name="funding_revision_reject"),
```
(Check the exact existing prefix convention for `FundingRecord` URLs — grep `"manage/funding"`
in `urls.py` first and match it precisely rather than guessing.)

### Phase B2 — Form

`FundingRevisionForm` in `apps/rims/grants/forms.py`: `change_summary`, `justification`,
`new_terms_json` (mirrors `AwardAmendmentForm`'s shape — check that class first and copy its
JSON-parsing `clean_new_terms_json`-style validation if it has one).

### Phase B3 — Templates

- New `grants/funding_revision_detail.html`, structurally cloned from
  `award_amendment_detail.html` (swap `award`→`funding_record`, `amendment`→`revision`), reusing
  `_amendment_diff.html` for the previous/new terms diff and `{% rims_status_badge rev
  'budget_revision' %}` for the badge.
- `funding_detail.html`: insert a "Budget revisions" card after the Budget Lines card (ends line
  ~103, before the Programme close-out card at line ~105), listing `funding.budget_revisions.all`
  with badges + a "Request revision" button gated on a new `can_request_funding_revision` context
  flag (mirrors `can_submit_funding_closeout` at funding_views.py:341).

### Phase B4 — Tests

New `apps/rims/grants/tests/test_funding_revision_views.py` (view-layer, not service-layer, to
close the exact blind-spot class the user flagged):
1. GM can reach the create form and submit a revision request; non-GM roles get 403/redirect.
2. PD/FD sees the decision page with working approve/reject actions; GM (the requester) does
   **not** get a decide action even when viewing the same page (SoD check).
3. Approve applies new terms to the `FundingRecord` and redirects correctly; reject requires a
   reason and preserves existing terms.
4. `funding_detail.html` renders the revision list + correct badge state end-to-end (this is the
   "does the UI actually 500 or render" check — the class of bug the previous round's audit found
   4 times).
5. `AuditLog` row exists after create/approve/reject.

### Acceptance criteria

- A Grants Manager can request a funding revision from the funding record's detail page.
- A Programme/Finance Director (not the requester) can approve or reject it from a dedicated
  decision page, with terms applied/preserved correctly.
- Every transition is audit-logged.
- View-layer tests (not just service-layer) pass.

---

## ITEM C — Psychometric test: faithful port from the old system

**Read this whole section before writing code — the port must match old-system *behavior*
exactly (fields, choices, labels, session-timer soft-degrade, duplicate-submission message),
while allowed to modernize non-behavioral things (class name, template markup/design system,
audit-logging mechanism, exact URL slug).** Do not build the PRD's 3-hour/3-attempt/reminder
version — that's explicitly out of scope for this port (see optional-enhancement note at the
end).

### Placement (resolved — no need to ask)

Lives in **`apps/rims/grants`**, not `apps/rims/scholarships`. Verified by grep:
`Application`, `ScholarshipApplicationProfile` (models.py:2233-2371),
`psychometric_score`/`psychometric_recorded_by`/`psychometric_recorded_at` (models.py:878-893,
already the coarse stand-in this replaces) all live in `apps/rims/grants`.
`apps/rims/scholarships` is a separate, later-stage app for **post-award** scholar lifecycle
management (`Scholarship`, `Scholar`, `Stipend`, `ProgressReport` — models.py has none of the
application-time concepts). The new questionnaire is an application-time artifact, so it belongs
next to `Application`/`ScholarshipApplicationProfile`.

### Known old-system quirks — port faithfully, flag, do not silently fix

1. **`ZERO_To_TEN_CHOICES` is actually 1–10, not 0–10** (`common/choices.py:344-349` — no `(0,
   0)` entry exists, despite the field/section naming and template copy claiming "scale of 0 to
   10"). Port the tuple exactly as `(1,1)…(10,10)` — this is what real applicants have always
   seen; changing it would alter the actual response scale.
2. **Section B2 form/model choice mismatch**: the model declares `earn_good_money_job` …
   `help_others_job` with `choices=IMPORTANT_CHOICES`, but `ApplicantHouseHoldSurveyForm`
   redeclares those same 5 fields with `choices=LIKE_ME_CHOICES` instead. The form's explicit
   `ChoiceField` wins at render/validation time, so what applicants actually saw for B.6–B.10 is
   LIKE_ME_CHOICES labels ("Not/Somewhat/More/Extremely Like Me"), not IMPORTANT_CHOICES
   ("Not/Somewhat/Very/Extremely Important"). **Port the form's actual runtime behavior**
   (LIKE_ME_CHOICES for B2) since that's the true historical behavior — do not "correct" it to
   IMPORTANT_CHOICES. Flag this explicitly in your final report as a pre-existing bug you
   preserved on instruction, not something you introduced.
3. Old system's `__str__` has a literal `" "` string-concatenation bug and `submitted_on` uses
   `auto_now` (rewrites on every save, though there's no edit flow so it behaves like
   `auto_now_add` in practice) — safe to silently clean these up since they're pure
   implementation detail, not user-visible behavior. Use `auto_now_add` in the new model.
4. Old system's `get_initial()` maps `surname ← application.first_name` and
   `other_names ← application.last_name` (swapped naming) — port the *mapping intent* (surname
   field pre-filled from applicant's actual surname, other_names from actual given names) using
   this repo's `Application` model's real field names, not the old system's confusingly-named
   swap. This is a naming-only artifact, not a behavior worth preserving.

### Naming decision

Recommend renaming the model class from `ApplicantHouseHoldSurvey` (old system; confusingly
overlaps with the *unrelated* old-system `homevalidation` app's household/socioeconomic survey —
see note below) to **`PsychometricTestSubmission`** in the new codebase. This is purely an
internal identifier — all user-facing labels/choices/behavior are ported exactly regardless of
class name. Flag this rename in your final report since the user's instructions named the old
class; confirm it's acceptable or revert to the old name if preferred.

**Do not confuse with**: the old repo's separate `homevalidation` app
(`EnumeratorHouseHoldSurvey`, `applicant_household_detail.html`, fields like
`able_to_locate_house_hold`, `interview_date`, livestock/assets/village-of-birth) — that's an
enumerator's in-person home-visit survey, a *different* PRD concept, not part of this port. The
research agent initially pulled some of its templates while grepping "household survey" broadly;
confirmed via the model/form/view files that the actual psychometric test is the 80-Likert-item
`ApplicantHouseHoldSurvey` in `scholarships/models.py:919-1067`, unrelated to `homevalidation`.

### Exact inventory to port (verified field-by-field)

**118 model fields total** (116 form-visible; `application` FK + `submitted_on` excluded from
the form), grouped:

| Section | Choice set | # fields |
|---|---|---|
| Header (identity, pre-filled) | — | 6 editable (surname, other_names, gender, age, university, course) |
| B1 | `LIKE_ME_CHOICES` (0–3) | 5 |
| B2 | `LIKE_ME_CHOICES` in practice — see quirk #2 above | 5 |
| B3 | `TRUE_CHOICES` (0–3) | 5 |
| C | free text (2 fields) | 2 |
| D | `OFTEN_CHOICES` (0–10, 11 options) | 30 |
| E | `AGREE_CHOICES` (-1 to 3) | 25 |
| F | `ZERO_To_TEN_CHOICES` (actually 1–10) | 9 |
| F2 | `ZERO_To_TEN_CHOICES` | 6 |
| G | `ZERO_To_TEN_CHOICES` (20) + free text (3) | 23 |

Exact choice tuples, exact field names, and exact `label=_(...)` text for all 105 radio fields
were captured verbatim by the research pass and must be copied into the new form's
`label=` kwargs unchanged (down to typos/double-spaces like "D.29.  I make progress..." and
"E.10... for people from poor backgrounds." — these are cosmetic, low-risk to preserve exactly
and safer than risking a transcription drift on ~80 labels). Do not re-derive labels from memory
during implementation — re-read `scholarships/forms.py` (old repo) directly, field by field,
while writing the new form.

### Phase C1 — Choices + Model

- Add the 6 choice tuples (`LIKE_ME_CHOICES`, `IMPORTANT_CHOICES` — keep even though B2 doesn't
  use it, for documentation/model-declaration fidelity — `TRUE_CHOICES`, `OFTEN_CHOICES`,
  `AGREE_CHOICES`, `ZERO_To_TEN_CHOICES`) wherever this repo keeps shared choice constants for
  `apps/rims/grants` (check `apps/rims/grants/models.py` top or a `choices.py` if one exists in
  this app first — match existing convention rather than inventing a new location).
- New model `PsychometricTestSubmission` in `apps/rims/grants/models.py`:
  `OneToOneField(Application, on_delete=CASCADE, related_name="psychometric_test")` +
  `unique_together`-equivalent is redundant given OneToOne, but keep a `UniqueConstraint` on
  `(application, surname, other_names)` only if you want the exact belt-and-suspenders the old
  system had — optional, the OneToOne alone is sufficient for single-submission enforcement.
  All 118 fields per the table above, exact names/choices/null-blank per the captured inventory.
- Migration number: **next available after whatever Items A/B leave behind** — run
  `makemigrations` last, after confirming no numbering collision.

### Phase C2 — Form + widget

- Check first whether this repo already has a radio/Likert-scale widget convention elsewhere in
  `apps/rims/grants` or `apps/rims/scholarships` forms (per the original task's instruction) —
  grep for existing `RadioSelect` subclasses or `.radio-group`/`.likert` CSS classes before
  introducing a new widget. If none exists, port `CustomRadioSelect` (old system's
  `common/widgets.py`) as a small new widget class, but style it with this repo's design-system
  radio markup (not the old system's raw Bootstrap classes).
- `PsychometricTestSubmissionForm`: mirror `ApplicantHouseHoldSurveyForm`'s structure —
  `Meta.exclude = ["application"]`, the same `clean()` override that nulls empty-string numeric
  fields, all 105 `ChoiceField`s with verbatim labels + verbatim choice tuples per section.

### Phase C3 — View + URLs + session timer

New view in `apps/rims/grants/views.py` (or a dedicated module if this app already splits
applicant-facing flows out — check convention first), e.g. `PsychometricTestCreateView`:
- `LoginRequiredMixin, CreateView` (no extra RBAC mixin needed beyond ownership — old system
  didn't gate by role either, just login; but do add an object-level check that
  `request.user == application.applicant` so one applicant cannot submit another's survey,
  since the old system's lack of this check is a security gap worth closing silently — this is
  a non-behavioral hardening, not a feature change).
- `get()`: sets `request.session["form_start_time"] = timezone.now().timestamp()` on every GET
  (port exactly).
- `get_initial()`: pre-fill from the new system's `Application` fields (surname/other_names/
  gender/age/university/course), using **correct** field mapping (not the old system's swapped
  naming — see quirk #4).
- `post()`: exact 2400-second (40 min) soft-degrade — if `elapsed > 2400`, set every
  `form.fields[f].required = False` before validating; if no `form_start_time` in session, return
  `HttpResponseForbidden("Session expired or invalid.")` (exact message, port verbatim).
- `form_valid()`: catch `IntegrityError` specifically (from the OneToOne/unique constraint),
  show `"HouseHold Survey already submitted."` (port verbatim so behavior/message matches
  exactly what the user asked to preserve) — replace `django-reversion` calls with this repo's
  `audit_helper.audit_rims(...)` convention instead (per the task's explicit instruction).
  Success message: `"HouseHold Survey submitted successfully."` (verbatim). Redirect to a list
  view (see below) regardless of success/duplicate, matching old-system behavior.
- URLs: create-view at `<pk>/psychometric-test/start/` (pk = Application pk — clearer than the
  old system's `home-validation` slug, per the task's own suggestion), plus a list view
  (`psychometric-test/`) and detail view (`<pk>/psychometric-test/`, pk = submission pk) if this
  repo's convention favors always having both — check whether a lighter approach (just linking
  from `application_detail.html`) is more consistent with how similar one-off applicant actions
  are surfaced elsewhere in this app before building a full list/detail pair old-system-style.

### Phase C4 — Templates

- Section-grouped questionnaire template using this repo's own `page_header`, card, and
  status-pill conventions (per CLAUDE.md's non-negotiable UI-polish standard) — NOT the old
  system's raw `<fieldset><legend>Section B</legend>` Bootstrap markup. Structure: one card per
  section (Header/B1/B2/B3/C/D/E/F/F2/G), each field rendered via whatever this repo's existing
  form-field partial convention is (check `templates/components/` for an existing
  radio-field/likert partial pattern first).
- Consider whether a single long page or the old system's multi-step wizard (`.tab` divs + JS
  next/prev) is more appropriate — a single scrollable page with section anchors is simpler and
  avoids porting the old system's `multi-form.js`; recommend the simpler single-page approach
  unless there's a strong reason to replicate the wizard, given ~80 questions.
- A client-side countdown mirroring the 40-minute soft limit is a nice-to-have (old system had
  one) but not required for functional parity, since the *server* already handles the soft-degrade
  correctly regardless of what the client shows.

### Phase C5 — Wiring into the post-submission flow

Per `setup/PRD.md` Table 7 step 17: the link to start this survey should appear immediately after
a scholarship application is submitted. Old system trigger was a bulk-CSV admin action flipping
`state="home_validation"` + an email — **that whole bulk-validation-CSV mechanism is out of scope
here** (it's a separate old-system feature, `ScholarshipapplicationValidationView`, not requested
in this port). Instead, wire the "take the psychometric test" call-to-action into whatever
next-steps UI already appears to a scholarship applicant right after `Application.status` flips
to `Submitted` in the new system (check `application_detail.html` /
`applicant_pipeline.html`, both already modified in the current working tree per `git status`, for
the existing "what happens next" section) — add a conditional block: only for
`call.call_type == "scholarship"` applications, only before `psychometric_test` exists, showing a
"Complete your psychometric test" call-to-action linking to the new create-view URL. This mirrors
the old system's UI-gate condition
(`state == "home_validation" and not scholarship.household_survey`) translated to the new
system's status/relation model.

### Phase C6 — Tests

New test module(s) under `apps/rims/grants/tests/`:
1. **Model**: OneToOne enforcement — second `PsychometricTestSubmission.objects.create(...)` for
   the same application raises `IntegrityError`.
2. **View — timer**: GET sets `session["form_start_time"]`; POST immediately after (elapsed <
   2400s) requires all fields (submitting incomplete data fails validation); POST with a
   time-traveled session timestamp (mock `form_start_time` to `now - 2500`) accepts a partial
   submission with previously-required fields now optional.
3. **View — duplicate submission**: second POST for an application that already has a submission
   shows the `"HouseHold Survey already submitted."` message and does not crash with an
   unhandled 500.
4. **View — session-expired**: POST with no `form_start_time` in session returns 403 with the
   exact "Session expired or invalid." body.
5. **Field/choice snapshot test**: assert the model has exactly 118 fields (or your final count
   if you added a UniqueConstraint or dropped a redundant one — document the actual number) and
   spot-check a handful of choice sets against the exact tuples above, to guard against silently
   dropping questions during the port.
6. **Template smoke test**: create-view GET (authenticated as the applicant, correct application
   ownership) renders 200 with the expected section count / a sample of question labels present
   in the rendered HTML.
7. **Ownership guard**: a different user attempting to GET/POST another applicant's create-view
   URL is rejected (403/404) — covers the object-level check added in Phase C3.

### Acceptance criteria

- All ~80 Likert questions plus the 5 identity fields and 3 leadership/entrepreneur/personality
  free-text fields exist, exactly matching the old system's field names, choices, and labels
  (including the two flagged quirks, preserved not fixed).
- Single-submission enforcement matches the old system's duplicate-message behavior.
- The 40-minute soft-degrade timer behavior matches exactly (partial-but-valid submission
  accepted after timeout, not blocked).
- A scholarship applicant sees a clear call-to-action to take the test right after submitting
  their application.

### Optional enhancement — explicitly NOT part of this port

The PRD's "Psychometric Test" row (Table 8, FRFA-AM) describes a 3-hour test with up to 3
attempts and email/dashboard reminders — a materially different feature (timed formal assessment
with retry semantics) from what's being built here (single-submission, 40-minute soft-limit
self-administered questionnaire, ported faithfully from the old system per explicit instruction).
If the PRD's 3-attempt/reminder concept is later wanted, it should be scoped as a **separate**
follow-on feature (attempt-tracking model, a Celery beat for reminders, a hard 3-hour cutoff) —
do not blend it into this item.

---

## Summary of what to build, by file

| Item | New/changed files (indicative — confirm exact paths against current app conventions before writing) |
|---|---|
| A | `models.py` (+5 fields), `views.py` (`ReviewConflictAckView`, `ManagerApplicationDetailView`), `manager_application_detail.html`, new migration `005X`, new test file |
| B | `funding_views.py` (+4 views, +1 mixin), `forms.py` (+1 form), `urls.py` (+4 routes), `funding_detail.html` (+card), new `funding_revision_detail.html`, new test file — **no new migration needed** |
| C | `models.py` (+1 model, ~118 fields, +6 choice tuples), `forms.py` (+1 form, +widget if needed), `views.py` (+1-3 views), `urls.py` (+2-3 routes), new template(s), `application_detail.html`/`applicant_pipeline.html` (next-steps CTA), new migration, new test file(s) |
