In-house platform for delivering TestMu AI professional certifications — booking, exam delivery, grading, and credential issuance. Replaces our current external vendor.
Status: five journeys work end to end.
- Candidate — book, reschedule and cancel (until 10 minutes before), calendar invites, dashboard, and My Assessments lists that follow the clock, not only the stored status.
- Admin authoring — Subject Center and Exam Center: create and edit subjects and exams (objective, subjective, or both rounds), with derived slugs, derived marks, and draft/publish.
- Question Center — write questions with answer options and media, browse the bank, or bulk-import from CSV with a preview step.
- Objective exam — join inside the booked window, T&C, a full-screen timed player with autosave, review flags and resume after a dropped connection, submit or time-out, automatic grading, and a completion page showing the score.
- Subjective exam — join inside the 36-hour window, submit a GitHub repository, a pull request in it and Test Manager test IDs; on a two-round exam it follows the objective round automatically.
The examiner side is next: the examiner dashboard is a shell, so subjective grading, combined two-round results and credentials are not started. Reminder emails and the scheduled job behind them are planned. See
docs/master-spec.md.
| Layer | Choice |
|---|---|
| Backend | Django 5.2 |
| Database | PostgreSQL 17 — everywhere, including local development |
| Frontend | Django templates + Tailwind + Alpine.js (both from CDN, no build step yet) |
| Background jobs | One management command run by cron every minute: reminder emails 60 and 30 minutes before each exam, plus the sweep of missed bookings and abandoned papers (planned); Celery + Redis later, for regrades and heavier work (not wired up — commented out in requirements.txt) |
| SMTP (credentials available; not wired up yet) | |
| Hosting | AWS — EC2/Elastic Beanstalk + RDS (not set up yet) |
Postgres first — there is no SQLite fallback. docker-compose.yml has it pinned to the
version RDS runs:
docker compose up -d # start (also brings up Redis, unused for now)
docker compose down # stop, data survives
docker compose down -v # stop and wipe the databaseThen:
python3 -m venv .venv
.venv/bin/pip install -r requirements.txt
cp .env.example .env
.venv/bin/python manage.py migrate
.venv/bin/python manage.py runserverThen http://127.0.0.1:8000/book/.
For the Django admin, create a superuser — note it takes external_id, not a username, since
that's our USERNAME_FIELD:
.venv/bin/python manage.py createsuperuser --external_id adminA fresh database has no exams. Add them through Exam Center → Add Exam — the
seed_certifications command is currently broken, see below.
Database: Postgres everywhere, including local development. There is deliberately no SQLite
fallback. SQLite differs on partial unique indexes (one_open_booking_per_exam), on
timestamptz, on CheckConstraint enforcement, on the UniqueConstraints guarding an exam
sheet, and on concurrency — it has one writer for the whole database, and select_for_update(),
which autosave and submit rely on, is silently ignored. SubjectiveSubmission.test_ids is also an
ArrayField, which exists only on Postgres (django.contrib.postgres.fields). A
silent fallback means code that passes locally can behave differently in production — so if the
connection fails, start Postgres rather than working around it.
Tailwind and Alpine both load from a CDN so there's no build step. Before production, build
Tailwind with the standalone CLI (no Node needed) and swap the script tag in
templates/base.html:
tailwindcss -i static/css/input.css -o static/css/output.css --minifyAlpine being a CDN script matters more than it looks: several admin pages put their state in
x-data, so if that script fails to load the page still renders but stops reacting. Nothing
user-facing may depend on it for correctness — see rule 2.
config/ settings, root URLconf, wsgi/asgi
apps/
home/ accounts and cross-cutting — User model, roles, dashboards, decorators
exam/ the assessment domain — subjects, exams, questions, bookings, exam sheets,
and to come: grading and credentials
imports.py CSV parsing for the question importer — plain functions, no request, no forms
calendar.py .ics generation
timezones.py the one place a wall-clock time becomes a UTC instant
templates/ base.html (public) + base_staff.html (admin shell) + per-app templates
static/ css/input.css, js/booking.js, imports/questions-template.csv
media/ runtime uploads — question images, audio, video. Gitignored.
docs/ specs — see below
archived/ the previous Next.js implementation, kept as UX reference
TRACKER.md deferred work. Gitignored on purpose — it never ships.
Two apps, deliberately. exam is one cohesive domain: subjects, exams, questions, bookings,
exam sheets, grading, and credentials all constrain one another. home holds what isn't part of
that — accounts, roles, and the dashboards.
Dependencies point one way. exam reaches the user only through settings.AUTH_USER_MODEL
(a string, so no import); home may import from exam, never the reverse.
When exam/models.py outgrows a single file, split it into a models/ package — not into
another app. App boundaries are baked into migrations and are expensive to move.
User.Role is candidate · examiner · admin, defaulting to candidate. Access is enforced
with one decorator, apps/home/decorators.py:
@role_required(User.Role.ADMIN)
def add_exam(request): ...It applies @login_required internally, so it runs first, and raises Http404 rather than
returning 403 — an admin URL shouldn't confirm it exists to someone who may not use it.
/dashboard/ branches on role and renders one of dashboard_candidate.html,
dashboard_examiner.html, or dashboard_admin.html. Admin pages extend base_staff.html,
which carries the accordion sidebar; candidate pages extend base.html.
Grading is blind. The examiner sees a booking reference and the submitted answers — never
the candidate's name or email. Identity is resolvable only by an admin. Keep it that way when
building the grading screens: nothing in an examiner-facing view should select_related its way
to a candidate.
An exam is objective, subjective, or both. exam_type is objective, subjective or
both; a "both" exam is sat as two rounds — see Two-round exams.
The house rules live on the Exam model as constants, not scattered through forms and templates:
| Constant | Value | Meaning |
|---|---|---|
MARKS_PER_QUESTION |
5 |
Every objective question is worth the same, as on the vendor platform |
SUBJECTIVE_QUESTION_COUNT |
1 |
A subjective round is one task |
SUBJECTIVE_MARKS |
50 |
What that task is worth |
DEFAULT_PASS_PERCENTAGE |
70 |
Applied when an author leaves the pass mark blank |
ROUND_GAP_MINUTES |
30 |
Gap between submitting the objective round and the subjective round opening |
The pass mark is a percentage (pass_percentage, whole percent), not an absolute number. On a
two-round exam the pass is 70% of both rounds together, and subjective questions in a pool need
not all carry the same weight — an absolute figure would be right for some candidates and wrong
for the rest.
Maximum marks is derived, never typed. Exam.total_marks_for(exam_type, question_count) is
the single definition — count × 5, 50, or count × 5 + 50 — and both Exam.save() and
ExamForm.clean() call it, so the number validated is exactly the number stored. It is
deliberately not a form field — a readonly input would still post its value and can be edited in
devtools.
Duration is derived too, from Exam.DURATION_BY_TYPE — 45 minutes objective, 36 hours
subjective, and the sum for "both" — and a CheckConstraint enforces the pair, so a mismatched row
can't be written by any route. The "both" figure is a total for display only: the rounds are
separate sittings with their own clocks, so anything that times a sitting uses the round's
duration, never exam.duration_minutes.
Every paper is a random draw. question_selection is commented out of ExamForm, not removed
from the model: manual selection was never used on the vendor platform and its picker was never
built.
The question count is checked at Start Test, not at publish.
_start_or_resumerefuses to draw when the subject's active pool is smaller than the paper, with a message to contact support. An exam can still be published promising 40 questions from a subject holding 12 — a publish-time check is still to build, and it would not replace this one, because the bank keeps changing after an exam is saved.
Questions belong to a subject, not to an exam. Any exam on that subject draws from the pool
automatically, which is why there is nothing to attach by hand and no Exam ↔ Question table.
Three pages under Question Center:
| Page | What it does |
|---|---|
| Question Bank | Read-only list. Cards expand to show options, media and which exams can draw the question |
| Add Question | One question and its answer options in a single post |
| Import Questions | Bulk CSV import with a preview step |
The bank is read-only on purpose. A question that has been sat is the record of what a
candidate was asked, so editing its wording after the fact rewrites history that grading and
appeals depend on. There is no edit page and no plan for one. That immutability is also what lets
ExamSheetQuestion reference a question by foreign key instead of snapshotting its text.
Retiring, not deleting. Question.status is active or retired. Retiring drops a question
out of the bank listing and out of random draws without losing the row. Both DELETE buttons are
still href="#" and delete_question is a stub — see TRACKER.md for the gated-delete design.
Answer options are an inline formset. AnswerOptionFormSet renders six slots and shows four;
blank ones are skipped rather than saved as empty rows. The rules that span rows — at least two
options, exactly one correct, none at all on a subjective question — live in the formset's
clean(), because a single option knows nothing about its siblings and Postgres can only check a
row against itself.
AnswerOptions.position records the order the author wrote them, assigned by the formset's
save(). Without it the options come back in whatever order Postgres returns, and that order can
differ between two reads — a candidate would watch the answers rearrange between page loads.
Media is uploaded, not chosen. Question.associated_image and friends are foreign keys to
Image, Audio and Video, so a plain ModelForm would render dropdowns of existing rows. The
forms declare FileFields under separate names instead and create the row on save. One model per
type rather than one generic Media, so the accepted extensions are declared on the field and
validate themselves.
The extension validators are re-declared on the form.
Model.save()never callsfull_clean(), soImage.objects.create(...)runs no validation at all — a model-field validator only fires when a ModelForm validates that model. Size is checked only on the form: Django applies no upload ceiling of its own.
Uploads land under MEDIA_ROOT (media/, gitignored), served by Django in development only.
Before production this becomes S3 via a storage backend — an EC2 instance that gets replaced
loses every upload, and two instances disagree about what exists.
CSV import is two-phase. Uploading parses and shows what would happen; a second submit commits. A bulk import that writes on the first click gives an author no way to notice they picked last month's file until two hundred questions are in the bank, and there is no bulk undo. The parsed rows wait in the session between the two requests — not the cache, which is per-process.
Parsing lives in apps/exam/imports.py: plain functions taking data and returning data, no
request and no form. That is what lets the same code serve a management command when the vendor's
bank has to be migrated across.
There is no test for "is this really a CSV", because there isn't one to write. CSV has no magic bytes and no header — any text file is a valid CSV of one column. What
read_csvchecks is that the bytes decode as text, contain no NUL, and carry the columns we need. The decode and NUL checks exist purely for the error message: without them, uploading a spreadsheet reportsline contains NULinstead of "save it as CSV".
Imports are deliberately partial — bad rows are skipped and reported, the rest go in. One typo in five hundred rows should not cost the other 499, because an author facing a full re-upload deletes the awkward row rather than fixing it.
import_rows feeds QuestionForm and AnswerOptionFormSet rather than calling
Question.objects.create(), so there is one definition of a valid question. Marks forcing and tag
normalisation apply to imported rows for free.
ExamSheet is the paper one candidate sat — a OneToOneField to their booking, plus
started_at, expires_at, current_position, submitted_at and submission_status (self or
timedout). ExamSheetQuestion is one served question: position, a marks snapshot,
selected_option, flagged (the candidate's review mark) and marks_awarded.
SubjectiveSubmission is a subjective round's answer, one-to-one with its
ExamSheetQuestion: github_repo, github_pr, test_ids (an ArrayField of Test Manager IDs)
and submitted_at. It is a separate model rather than columns on ExamSheetQuestion so its fields
can be genuinely required — on the shared row they would have to be optional, because forty
objective rows per paper never have them. The question slot, its marks and marks_awarded stay on
ExamSheetQuestion for both kinds of round.
There is a join window. ExamBooking.join_closes_at is scheduled_at plus the round's
duration; ExamBooking.stage reads upcoming before the window, underway inside it and lapsed
after. A booking for 10:00 PM on a 45-minute round can be started from 10:00 to 10:45.
- Join exam (assessment page and dashboard) is a live link only while
underway; otherwise a disabled button. Reschedule and Cancel close earlier,BOOKING_CHANGE_CUTOFF_MINUTES(10) before the start (ExamBooking.can_change); Add to calendar works until the window has passed. Once lapsed, all of them are disabled. - The server enforces the same window.
_start_or_resumerefuses to draw a new paper beforescheduled_ator afterjoin_closes_at. The buttons are display; this check is what holds. - Whoever starts inside the window gets the full duration from the moment they press begin — a 10:44 start runs to 11:29.
- Getting back in after the window has closed is by the link in the reminder emails (planned, 60 and 30 minutes before the start), which leads to the T&C page; begin there resumes an existing paper whatever the time.
The T&C page is reachable by GET at any time on purpose: reading the rules changes nothing, and the
begin POST is where the window is checked. After begin it sends each round to its own player —
exam_player for objective, exam_player_subjective for subjective — and each player redirects a
round of the other kind to the right one, so a bookmarked or typed URL cannot open a paper in the
wrong player.
Durations are per round on every booking page. ExamBooking.duration_display shows "45 min"
or "36 hrs" and the pages label a booking with its round, not the exam's type — on a two-round exam
Exam.duration_display ("45 min + 36 hrs") and the type "Both" describe the whole exam and appear
only in the exam catalogue. Calendar invites (.ics and Google Calendar) use the round's duration
too: a 45-minute block for an objective round, a capped one-hour block plus a "Submit by" deadline
for a subjective one.
The paper is fixed when the candidate presses begin, never at booking. Between booking and sitting the bank changes, and a paper drawn weeks ahead could serve a question since retired. Drawing lazily as the candidate presses Next is worse still: it re-randomises on a reload, makes "question 3 of 20" a promise that cannot be kept, and turns a double-clicked Next into a race.
The draw filters on the round's type, booking.round_type, not the exam's — an exam can be
"both", a question never is.
A reconnect resumes, it does not restart — and it does not stop the clock. expires_at is
fixed at begin and never moves; the player computes time left from it on every load. Rejoining
always returns the same paper, even after the join window has closed, but someone who drops at 15
minutes left and returns 5 minutes later has 10. After expires_at the page submits itself as a
timeout. A deadline the browser can report is a deadline a candidate can extend, so the browser's
countdown is display only.
Autosave. Every answer, clear, flag and move posts to save_answer (…/save/), which writes
selected_option, flagged and current_position and answers in JSON:
- One request at a time, in order, through a queue — two answers sent in parallel can land in either order, and the server would keep whichever arrived last.
- "Saved" shows only on the server's
{"ok": true}; a failure after three tries says "Not saved" instead. SaveAnswerFormis a plainForm, not aModelForm: the browser can say "question 3, option 812, flagged" and nothing else. The view resolves the sheet and question from the candidate's own booking and refuses an option belonging to another question.- It refuses once the paper is closed — submitted, past
expires_at, or the booking no longerbooked— with 409, and locks the sheet row (select_for_update) so a save cannot land after the final submit. - Absent and empty are different. The view checks
"option_id" in request.POSTand"flagged" in request.POSTrather thancleaned_data, which turns both intoNone/False— otherwise every answer save would unflag its question.
Rejoining hands back each saved option and flag, so the paper reappears as it was left.
Lockdown is deterrent, not enforcement. The objective player runs full screen, blocks the context menu, copy and cut, stops text selection on the question pane, and hides the paper behind an opaque screen when full screen is left. All of it is defeatable from devtools. The page tells candidates exits are recorded — nothing records them yet.
The answer key never reaches the browser. The player's payload is built field by field — option
id and text, never is_correct — and save_answer replies the same way whether an answer is
right or wrong.
submit_exam is POST-only and runs in one transaction, holding a lock on the sheet:
- Refuses a repeat (already submitted) or a booking that is not
booked. - For a subjective round, validates and saves the answer first — see The subjective round. A rejected or late answer stops here, so the round stays open.
- Stamps
submitted_at = min(now, expires_at), so a late POST records when the exam actually ended.submission_statusisselfonly for a submission made before the deadline (the form sendsaction=submit_action); the timer's own POST, or anything afterexpires_at, istimedout. - Runs
grade_exam. The booking is alreadyattended— beginning set it. - On the objective round of a "both" exam, creates the subjective booking.
Every round, objective or subjective, is finished in this one function.
The Submit button opens a "Ready to submit?" dialog with answered, unanswered and flagged counts. Both Submit and the timer wait (up to 5 seconds) for queued autosaves before posting, so a last answer in flight is not refused as late.
submitted_at is one timestamp, not a state machine. Pressing Submit, running out of time and
being stopped by an admin are all "this paper is finished", and the score is the same in each case.
Grading. grade_exam marks objective rounds at submit: each question gets its marks snapshot
if the chosen option is correct and 0 otherwise (unanswered included), and the booking's
marks_obtained is set to the total — assigned, not added, so rerunning it is safe. Subjective
rounds are graded by an examiner and stay None until then; None means "not graded", which is
why it is not defaulted to 0.
The completion page (exam_completed.html) shows "You Scored: N" for an objective round, and
after the first round of a two-round exam, a pointer to My Assessments for the second. It also
collects LinkedIn and GitHub (required) plus three 1–5 ratings and suggestions — none of which is
saved yet. submit_exam redirects to it (…/<booking_id>/completed/) rather than rendering it
as the reply to the POST, so a refresh is a harmless GET. The page reads everything from the
booking, is owned by the candidate, and refuses while the paper is still open.
A "both" exam is two bookings. ExamBooking.round_type says which round a booking is;
parent_booking (on the subjective booking, one-to-one) links it to its objective round — that
link is what lets the two be treated as one attempt, since the pass is 70% of both together.
- The candidate books once; that booking is the objective round.
- Submitting it creates the subjective booking, scheduled
ROUND_GAP_MINUTES(30) after the objective was submitted, in the same timezone. - The subjective round is a 36-hour window, joined from My Assessments.
BookingForm.save() sets round_type to subjective for a subjective-only exam and objective
otherwise.
A separate page, exam_player_subjective.html at exam/player/subjective/<booking_id>/:
instructions, the task shown in full on the page (not a downloadable PDF as on the vendor platform),
and three required fields — the GitHub repository (private, shared with the admin address), a pull
request in that repository, and the Test Manager test IDs. The red Submit enables only when all
three are valid. There is no full-screen or copy lockdown: it is a 36-hour task done in an editor
and on GitHub, and blocking copy would stop the candidate pasting their links. There is no autosave
either — the answer is three links written once, not work in progress.
The page is display only (@require_GET); the form posts to submit_exam. There the answer is
validated and saved inside the same transaction that closes the round, before anything is stamped:
- an answer after
expires_atis refused — unlike an objective paper, this POST is the answer; - a rejected answer is rendered back into the same template with the bound form, so each field
shows its server error and the candidate's values are put back (seeded through
data-*attributes, sincex-modelowns the inputs); - a valid answer is saved with
form.save(commit=False)andentryset by the view —entryis never a form field, so the browser cannot attach an answer to another paper.
ExamSheetFormSubjective repeats every browser check: GitHub-only URL patterns (with or without
www.), the PR belonging to the repository, and test IDs split on commas or new lines, blanks and
repeats dropped, returned as a list for the ArrayField. Links are stored normalised — no www.,
no trailing slash, no /files tab — so one repository is always one string. Subjective rounds are
not auto-graded: marks_obtained stays None until an examiner marks them, and the completion
page shows no score.
on_delete=PROTECT on ExamSheetQuestion.question is load-bearing. Once a question appears on
any sheet, deleting it raises at the database level — not because a view remembered to check. That
is the delete gate, enforced structurally; questions never served stay freely deletable. Two unique
constraints do the same job for the draw: no two questions in one slot, and no question twice on
one paper.
Options are not shuffled per candidate. Drawing 20 questions from a pool of several hundred already means two candidates share barely one question, and shuffling breaks any option that depends on where it sits — "All of the above" being the obvious one. Every candidate sees the authored order. Autosave sends an option's id, not its position in the list, so the saved answer cannot change even if the order did.
1. Timezone conversion happens in one place. apps/exam/timezones.py owns every
conversion between a candidate's wall-clock choice and the stored UTC instant. Times are stored
UTC and displayed in the zone the candidate booked in, always with the offset labelled. A
candidate who misreads their booking time misses their exam, and it is unrecoverable.
2. The client is display only. The date picker disables past days and caps the horizon; Join exam greys out outside the join window; the subjective Submit stays disabled until all three links are valid. All of these are explanations, not enforcement — a form post can be made directly, and an Alpine binding never applies at all if the CDN script fails to load. Every rule that must hold of a stored row is re-checked server-side.
3. A pre-converted datetime does not survive a template filter. With USE_TZ = True, Django's
date filter converts aware datetimes to settings.TIME_ZONE — UTC — before formatting, silently
undoing any conversion done in Python. Render the raw field inside {% timezone %} instead. See
docs/conventions.md.
docs/conventions.md also sets out the four places a rule can live — database constraint, model
clean(), form clean(), view check — and which to reach for. Constraints are the only real
guarantee; bulk_create bypasses save().
Self-scheduled, not slot-based. Candidates pick their own date and time. There are no
pre-defined slots and no capacity, so there is no seat contention. Rules live in settings.py:
BOOKING_MIN_DAYS_AHEAD = 1— no same-day bookingBOOKING_MAX_MONTHS_AHEAD = 3BOOKING_GAP_MINUTES = 60— clear time required either side of an exam a candidate has already booked. An objective exam occupies its full duration; a subjective one is a 36-hour window with a deadline, so it only blocks around its start.BOOKING_CHANGE_CUTOFF_MINUTES = 10— Reschedule and Cancel close this long before the start.ExamBooking.can_changeis the one rule: it disables the buttons and the reschedule and cancel views refuse on it. A paper can only be begun from the start time, so a started paper can never be moved or cancelled out from under itself.
A candidate may hold only one open booking per exam and round, enforced by a partial unique
index (one_open_booking_per_exam, over candidate, exam and round_type) rather than a view check.
It counts booked, under_review and attended rows.
Status means attendance; the sheet means completion. "I'm ready to begin" marks the booking
attended (in _start_or_resume, in the same transaction that draws the paper) — the candidate
turned up. Whether they finished is ExamSheet.submitted_at. So attended covers papers finished,
in progress and abandoned alike, and save_answer and submit_exam treat attended as the state
of a live paper. A submitted subjective round then moves to under_review — the examiner's
queue, which the admin dashboard's "awaiting grading" count reads — while an objective round stays
attended, graded at submit. attended() lists all of attended, under_review and graded. From begin on, Reschedule and Cancel are closed for good and the booking leaves
Upcoming and the dashboard; a candidate who drops out returns through the reminder email's link to
the T&C page, which accepts an attended booking with an unsubmitted paper and resumes it.
The one thing the stored status still misses is a miss. A booking nobody began stays booked
after its window has closed. ExamBookingQuerySet (ExamBooking.objects) works the candidate's
buckets out in the database from the clock as well as the status:
| Method | Matches |
|---|---|
upcoming() |
booked and the join window not yet closed |
no_show() |
marked no_show, or still booked with the window closed |
attended() |
attended — begun, whether finished or not |
My Assessments uses them for Upcoming (soonest first), No show and Attended; Cancelled stays a plain
status filter, and an unknown filter in the URL is a 404. The dashboard card picks the soonest
upcoming() booking, so a booking stays there from before its start through its join window —
when its Join button is live — until the candidate begins.
Nothing here writes: each call reads the clock and filters, and a booking falls into a bucket
whenever someone asks. The queries start from one candidate's bookings (candidate_id is indexed),
so they stay cheap however large the table grows.
What the lists can't fix. For a missed booking, anything reading the stored status still sees
booked: the unique index above (so a candidate cannot rebook an exam they missed) and admin counts. And an abandoned paper is correctlyattendedbut never submitted, so it is never graded. The planned per-minute job that sends reminder emails will also write both — marking missed bookingsno_show(reusingno_show()) and submitting abandoned papers as timeouts and grading them. SeeTRACKER.md.
My Assessments labels each booking with its round — Objective in blue, Subjective in amber —
read from round_type rather than the exam's type, which says "both" for both rounds. An attended
booking shows Show Results (still pointing at a placeholder route; results are not in scope yet)
and a disabled Get Certificate.
Custom manage.py subcommands live in apps/<app>/management/commands/. Any file there with a
Command class becomes a subcommand named after the file — both __init__.py files are required
or Django silently won't find it.
Currently broken. It writes name and level, which were renamed to exam_name and
exam_level when exam_level moved from Subject to Exam:
FieldError: Invalid field name(s) for model Exam: 'level', 'name'.
It also predates Exam.subject becoming a required FK, so fixing the field names alone won't be
enough — each seeded exam now needs a subject to belong to. Until then, add exams through Exam
Center.
When it is fixed, the design it had is worth keeping:
- Idempotent.
update_or_creatematched on slug, which is the identity. - It overwrites admin edits to the fields it owns. That's intentional — the seed is the
source of truth for those fields — so anything that should be admin-editable must come out
of
defaults. - It never deletes. Removing an entry leaves the row in place. Deleting an exam that has bookings or issued credentials against it must never be a side effect of running a seed script.
Seed scripts are preferred over hand-entry through the admin — repeatable for fresh local databases, staging, and CI, and they show up in a diff.
Recurring work starts as one management command run by cron every minute, not a Celery task. It
will send the reminder emails and run the sweep that marks missed bookings no_show and submits
abandoned papers — no broker needed, and easy to run by hand. Two rules for it:
- "Due and not yet sent", never "exactly now". Each reminder is stamped on the booking when sent, and each run sends whatever is due and unstamped — so a late or skipped run delays an email rather than losing it, and no run sends one twice.
- One server only. On several instances, a cron on each would send every email several times; run it leader-only, with a row lock as the safety net.
It reuses ExamBookingQuerySet, so "missed" and "abandoned" mean the same thing in the job as on
the pages. Celery arrives with work that must happen right after a request — certificate
generation, regrades — and the command's logic can move into a beat task unchanged.
Specs live in docs/, all rewritten for Django:
| Document | Covers |
|---|---|
docs/master-spec.md |
Purpose, scope, open decisions |
docs/auth.md |
The OIDC handoff from TestMu AI's login |
docs/conventions.md |
Where rules live, timezones, naming |
docs/routes.md |
URL map and where new routes belong |
scripts/build-spec.py regenerates master-spec.docx from the markdown.
Examiner grading of subjective answers · combined two-round results against the pass percentage ·
saving the completion page's profile links and feedback · result release and Show Results ·
credentials and public verification · email (SMTP) and the per-minute job that sends reminders and
closes missed bookings and abandoned papers · recording lockdown events · delete_question (routed
but stubbed) · Candidate Center · authentication (the OIDC integration with the TestMu AI login is
deferred, which is why ExamBooking.candidate is nullable) · automated tests · the Tailwind
production build.
One smaller gap worth knowing: media rows are mutable — replacing the file on an Image row
would change what a past paper appears to have shown.
TRACKER.md holds the full list.