Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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.

Which state this applies to

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.

Warning

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.

Do not overreach

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 max from the task, and the Stimulus controller enforces it — but TaskPoint validates 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.

Both corrections are in

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.

Where this landed

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.

A single callback at finalisation is not enough

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:

  • TalkGraderService resolves 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 Talk from params[:talk_id] before it does anything else. Derive the assessable from the participation, exactly as step 4 does for points.
  • GradeTalkRowComponent takes 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

  • SubmissionGraderService keeps its name and its submission-shaped API.
  • seed_participations_from! is the seeding API. A second one is not needed.
  • The ||= on a participation's tutorial_id is 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 — GradeScheme requires 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.