The Extraction, Step by Step
The sequence that turns Point Entry for Exams into code. Steps 1 and 2 are corrections worth having whether or not exams follow; from step 3 onward each step is a precondition for the next.
The assessment work does not live on next. The tutor grading view is based on the
Müsli integration branch, and the talk grading view on the tutor branch, so the combined
state is the head of the talk grading work. That is the baseline the steps below assume.
1 · One answer to "may this be graded now?"
SubmissionGraderService and both row components ask !assignment.active?; the model
asks assessment.grading_open?. Point all three at the model's hook. The submission row
keeps valid_for_marking? on top — that is a real extra condition, not a duplicate.
The boundary moves from the plain deadline to the friendly one, so parts of the task point request specs will move with it. Read each failure before adjusting it: some of them may be pinning the old boundary deliberately.
2 · Refuse reviewed when no task carries points
update_status_if_all_scored! promotes a pending participation the moment no task is
missing points — and with no tasks at all that condition is vacuously true. An empty
save then yields reviewed with a total of 0.0.
Slice 5 describes this fallback as a guard rather than a
feature and expects a nil total. The measured value is 0.0; the guard has to cover
both.
The guard belongs to the points-driven promotion — the path that runs when points are
entered. An exam with no tasks is legitimate (an oral examination has nothing to score),
and it must still be gradable by hand. Manual grading sets the status through
GradeEntryService and never touches this method, so the two do not collide as long as
the guard is written about points, not about the assessment.
Not reachable through the interface today — the row's save button never becomes enabled
without inputs to dirty — so this closes a latent gap rather than a live defect. It is
worth closing before exams arrive, because a premature reviewed enters
StudentPerformance::ComputationService as a final result of zero, and that record
carries exam eligibility.
3 · Extract the participation row
Move it out of the tutorials namespace; it stops being a tutorial thing. It takes the
participation, the assessment, the grading scope and its two URLs, and it takes no
mode. The columns that differ between callers become optional slots rather than
branches on a flag.
Two things are worth correcting while the file is open:
- The row loads its points with one query per row. A large exam roster makes that hurt immediately; take a preloaded association instead.
- The point input carries
maxfrom the task, and the Stimulus controller enforces it — butTaskPointvalidates only that points are non-negative. Points above the maximum are deliberately allowed, which is how a bonus works. Reusing the row unchanged would carry an interface inconsistency into exams; decide it here rather than inherit it.
Nothing about assignments should change. If a spec has to move, something else moved with it.
The table reads the participations of everybody on the page, marks included, in one query and hands each row its team's; the tasks are read once and handed down the same way. The request spec pins the count: a group of eight hand-ins costs what a group of two does.
The maximum is decided as no ceiling at any layer: TaskPoint allows more than
max_points because a bonus is points, the input carries no max, and the controller
checks only the minimum. What a task is worth is what the header says, not what a tutor
may enter.
4 · Derive the assessable instead of being told it
Drop params[:type]. The assessable and the grading scope both follow from the
participation — for an assignment the scope is its tutorial or, failing that, the
lecture; for an exam it is the lecture.
This is more than the branch statements in the actions. The resource loader also
requires a tutorial and names the assessable assignment, so an exam participation
fails there before any branch is reached. An unknown assessable class should be answered
deliberately, with 400 or 404 — not with an uncaught exception, and not, as today, with
an empty 200.
The submission actions stay assignment-specific. They are about a team handing something in, which exams do not do.
Who may grade is settled from the record: the participation's or hand-in's own group, or
the lecture where it has none - an exam, or a sheet handed in on paper by somebody in no
group, which the loader no longer refuses. The page still says which table the row goes
back into (grading_scope_type), because the group's table and the lecture's table draw
different columns; that hint decides the shape of the answer and nothing else.
A participation in anything but an assignment is refused with 400 before the actions run: this page has no row to draw it into. A record that is not there answers 404. Both carry the flash, and Turbo renders it either way.
5 · Project the exam roster onto participations
Assessment#seed_participations_from! already exists, is idempotent through a unique
index, serves achievements and the assignment backfill, and writes pending with no
submitted_at — precisely what an exam needs.
Participants can still be added and removed after a campaign is finalised. A one-shot seeding hook lets the roster and the gradebook drift apart. What is needed is a projection, idempotent throughout: finalisation seeds every active entry; a manual add or a reactivation ensures a participation; a removal without grading data takes it away again; a removal with grading data stays blocked, as it already is.
Do not widen SubmissionGraderService.init_participation to accept a missing tutorial.
That service stays assignment-specific, and going through it would contradict the
division the rest of this sequence rests on.
6 · The exam point table, and a dashboard that dispatches
The table takes an assessment and a grading scope and renders the header plus participation rows from step 3. Exams need none of the non-submitter zone, the "mark as participated" action, bulk download, bulk upload, or the team column.
The dashboard has to choose the table by assessable rather than handing every pointable to the assignment one.
7 · Absence, and what submitted_at means for an exam
mark_absent and mark_exempt(note:) need a controller and a route. Excusing must go
through mark_exempt: it clears the grade along with the status, and setting the status
directly would leave a failing grade that re-applying a scheme will not remove either.
PointGridComponent is gone: an exam's points tab is ExamPointsTableComponent,
which draws every candidate on the roster and reads absence off the status, not off
submitted_at.
8 · A grade a person can type
The exam grade normally comes out of a scheme, but a scheme is the wrong instrument for a cohort of five, for an oral examination that produced no points, and for a single correction after the fact. Entering a grade by hand has to be possible.
GradeEntryService.set_grade is already generic — it asks only that the assessable is
Gradable — and GradeSchemeApplier already narrows to participations without a grade,
so a typed grade survives a scheme applied afterwards. What is missing is everything
above the service:
TalkGraderServiceresolves and authorises through the talk; an exam needs the same service scoped to the lecture instead. The talk wrapper stays where it is.- The grades controller finds a
Talkfromparams[:talk_id]before it does anything else. Derive the assessable from the participation, exactly as step 4 does for points. GradeTalkRowComponenttakes a talk. Loosened the same way as the point row in step 3, it becomes a participation grade row that serves both.
This is the point-entry work over again on the other axis, and the same rule decides it: a component that resolves its own subject can only ever serve one kind of assessable.
Afterwards, if it still grates
Small shared primitives between the talk and point interfaces — the person cell, the status badge, the save and refresh buttons, the dirty-state behaviour. A shared grading row across both would be premature: a talk carries one final grade, an assignment and an exam carry task points.
Leave alone
SubmissionGraderServicekeeps its name and its submission-shaped API.seed_participations_from!is the seeding API. A second one is not needed.- The
||=on a participation'stutorial_idis deliberate. The pointer records where this is graded, decided once; it does not follow a roster move, and a spec pins that. - A talk cannot carry a grade scheme —
GradeSchemerequires an assessable that is both pointable and gradable, and a talk is only gradable. Any reasoning about schemes applies to exams, never to talks or assignments.