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.
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.
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:
| today | equivalent |
|---|---|
@assignment.assessment.persisted_tasks | assessment.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 core | assessable-specific wrapper | interface | |
|---|---|---|---|
| points | PointEntryService — takes a participation, submission optional | SubmissionGraderService — the team fan-out | tutorial pointing table |
| grades | GradeEntryService — takes a participation, checks only that the assessable is Gradable | TalkGraderService — resolved and authorised through the talk; gone since #1283, the controller does both | talk 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.
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
| assessable | what is entered | what carries it | permission scope |
|---|---|---|---|
| Assignment | task points | submission (team) or participation (person) | tutorial or lecture |
| Exam | task points, or a grade directly | participation | lecture |
| Talk | one final grade | participation | lecture |
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.
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.