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

Point Entry for Exams

Exams are the third assessable to need a marking interface. Assignments have one; talks acquired a second one beside it. That second build was the right call — a third value on the mode: flag would have been worse than a second table — but it means the question is now open rather than settled: does an exam become a third build, or does it reuse what exists?

This page argues for reuse, and says exactly how far the existing code already reaches. The extraction sequence turns that into steps.

Where this landed

Reuse it was. The sheet row became the shared row (#1150), the seminar table drew it for talks (#1196), and the exam tables draw it for candidates (#1283). The argument below is kept as written; where it names something as missing that has since arrived, the passage says so in place.

What already carries an exam

Exam includes Assessment::Pointable and Assessment::Gradable, so it is given an assessment with tasks on create, exactly as an assignment is.

Assessment::PointEntryService only ever touches a participation and its assessment:

def self.enter_points(participation, task_points, grader, submission = nil)

The submission is already optional, and TaskPoint#submission_id is nullable. The schema anticipated points without a submission from the start.

On the reading side, the read-only GradeTableComponent was written against the assessment and explicitly prepared for exams. It has since gone: an exam's grades are read and entered in the same rows, ExamGradingTableComponent.

One reading component was not ready

Resolved: PointGridComponent was replaced by ExamPointsTableComponent, which draws the roster and reads absence off the status. What it did wrong, for the record: it filtered its main table with .where.not(submitted_at: nil). An exam participation that is reviewed with no submitted_at — which Assessments & Grading documents as valid — disappears from it entirely. It also renders a tutorial column for everything that is not a talk, including exams.

What holds the writing half back

The lifecycle question is answered in three places

The model asks assessment.grading_open?. SubmissionGraderService and both row components ask !assignment.active?. Those are different moments — the plain deadline against the friendly one — so inside a lecture's grace period the view enables fields that Participation and TaskPoint then refuse.

For an exam the divergence stops being a nuisance. Exam has no active? at all, so the service raises NoMethodError before PointEntryService, which would have worked, is ever reached. Asking the model's own hook removes the obstacle instead of adding a branch — and no assessable then needs an active? of its own, because Assessment::Assessable supplies grading_open? == true by default and Assignment overrides it with totally_expired?.

The row components are tied to Assignment and Tutorial

ParticipationRowComponent reaches through @assignment three times, and each reach has a generic equivalent:

todayequivalent
@assignment.assessment.persisted_tasksassessment.persisted_tasks
@assignment.active?assessment.grading_open?
@assignment.assessable?falls away — holding an assessment is the answer

The grading scope can be a value too: User#can_grade_in_scope? already accepts a Lecture or a Tutorial and raises on anything else.

The other half is the endpoint. The row builds its own route — point_user_tutorial_path, refresh_point_user_tutorial_path. A component that constructs its own route can only ever serve one kind of assessable. Handed the URL from outside, the same row serves any.

The mode: flag has to go at the same time, not later: the template branches on it to render a tutorial column for teachers and a correction column for tutors. Neither shape fits an exam, so a generic row that still carries the flag would force exams to choose between two wrong answers.

The controller is told its type by the client

case params[:type] when "Tutorial" has one branch and no else; handed any other type the action does nothing at all — no exception, no flash, an empty 200. The routes feed it a constant, defaults: { type: "Tutorial" }.

None of it is necessary. The assessable and the grading scope both follow from the participation being scored:

participation → assessment → assessable
                          → grading scope

The same assumption sits one level deeper, in the resource loader, which refuses a participation that has no tutorial — which is what every exam participation looks like.

The dashboard hands every Pointable to the assignment table

build_tabs added the points tab for any pointable assessable, and that tab rendered the tutorial marking table with the assessable passed as assignment:. For an exam that meant assignment-specific methods called on an Exam. Resolved: the dashboard dispatches on the assessable — TutorialMarkingTableComponent for an assignment, ExamPointsTableComponent for an exam, and an error for anything else.

What an exam needs that no assignment does

Not everything is subtraction. The exam workflow is seed the roster, mark the no-shows absent, treat certificates as exempt, then grade the rest. Assessment::AbsenceHandling provides mark_absent and mark_exempt(note:), both specced; for a long time nothing in the application called them. Now the exam rows do, through TaskPointsController — and the two can be taken back again (remove_absent, remove_exempt).

And exams have roster entries, not participations. For a long time nothing turned one into the other but the demo seeder. Now Assessment::ParticipationIndex does, when the table is drawn: one participation per candidate on the roster, created if missing — a deliberate write on read, documented on the class.

Both halves of marking, and what an exam needs of each

An exam is the only assessable that is both pointable and gradable, so it is the only one where the two halves of marking meet. They are built the same way, and both stop at the same place:

generic coreassessable-specific wrapperinterface
pointsPointEntryService — takes a participation, submission optionalSubmissionGraderService — the team fan-outtutorial pointing table
gradesGradeEntryService — takes a participation, checks only that the assessable is GradableTalkGraderService — resolved and authorised through the talk; gone since #1283, the controller does bothtalk grading table

Both cores would serve an exam today. Both wrappers were for something an exam does not have — a submission, a talk — and both interfaces were written against the wrapper rather than the core. The grade wrapper is gone; the submission fan-out stays.

The grade half is not simply derived

An exam grade usually comes out of a grade scheme: points, bands, grade. That path exists and works — the grading tab renders the scheme editor beside ExamGradingTableComponent, and GradeScheme requires an assessable that is both pointable and gradable, which only an exam is.

A scheme cannot be the only way in

Entering a grade by hand has to remain possible for an exam, because a scheme is often the wrong instrument:

  • With five candidates nobody builds a distribution. The grade is a judgement, written down directly.
  • An oral examination produces no points at all. There is nothing for a scheme to band.
  • A single case — a hardship, an appeal, a re-mark — needs a correction that does not disturb everyone else's grade.

GradeSchemeApplier already assumes this: it narrows to participations that have no grade yet, so a hand-entered grade survives a scheme being applied afterwards. What is missing is any way to enter one.

An exam without tasks is a legitimate exam, not a misconfigured one. Anything that reasons about "no task carries points" has to leave room for it.

The axes, stated in full

assessablewhat is enteredwhat carries itpermission scope
Assignmenttask pointssubmission (team) or participation (person)tutorial or lecture
Examtask points, or a grade directlyparticipationlecture
Talkone final gradeparticipationlecture

Assignment and exam share the participation point row and PointEntryService. Exam and talk share GradeEntryService and, once the talk row loses its talk, a participation grade row. The submission fan-out stays in SubmissionGraderService; the talk needs no resolution of its own, the controller checks the speaker.

mode: scales along who is looking. The axis that keeps arriving is what is being assessed, and a flag carries only one axis.

Talk grading meets the same wall from the other side as soon as it needs a view for whoever supervises a talk, beside the teacher view it has now.

What deliberately stays

SubmissionGraderService keeps its name and its submission-shaped API. It is the assignment-specific wrapper around a generic core, and that is the right division — the mistake would be to widen it into something that serves exams badly in order to avoid a second, smaller caller.