Trainings
Role names and
user_group_idvalues are listed in User groups.
v2 schema. The trainings plugin uses a unified-activities schema:
training_activitiesorders lessons + exams together (drag-drop ordering),activity_progressis the single source of truth per (enrollment, activity), andexamscovers both ONLINE and ONSITE (columntype). Sessions (renamed fromlesson_classes) bind to a singletraining_activity_id. Attendance is recorded per-session onsession_students.attendance_status(attended,attended_post_class,absent,absent_justified, orNULLwhen not yet decided); progress % comes fromactivity_progress.value. The two are independent (a student can have attended a paper exam but not passed it).activity_progressis per-ACTIVITY — unique on (trainings_user_id,training_activity_id) — so it cannot represent attendance for one session out of several under the same activity;session_students.attendance_statusis the per-session record.
Training types.
trainings.typeisONSITEorDISTANCE. The formerREMOTEtype was retired — it always behaved asONSITE— and existing rows were converted. A write that still poststype: "REMOTE"is accepted and stored asONSITE; reads never returnREMOTE.
Schema additions — missed-class follow-up (task #514).
session_studentsalso gainedmissed_email_sent_atandattendance_notified_at(both unix,NULLuntil sent) — independent claim-before-send guards for the missed-class email and the attendance-signed notification respectively (see Sign Class), so a re-sign never double-sends either one.Schema additions — student attendance signature (task #978).
trainingsgainedattendance_signature_days(smallint,0= no closing date, the default) andattendance_signature_from(unix,NULL= no cut-off) — the latter stamped byTraining::beforeSaveon the OFF -> ON transition and backfilled for already-enabled courses, so the requirement never applies to classes taught before a course adopted it.session_studentsgainedsignature(JSON{time, ip, browser, hash},NULLuntil the student countersigns — see Sign Own Attendance) andsignature_requested_at(unix, the claim-before-send guard for the please-sign message raised by Store Attendance). These replaceactivity_progress.signaturefor per-session signing: that row isUNIQUE (trainings_user_id, training_activity_id), so it holds one signature per ACTIVITY and cannot represent two sittings of the same lesson. The old column is still readable on historic rows and is no longer written.
sessionsgainedallow_classwork_upload,classwork_deadline,classwork_description,classwork_notified_at(see Request Classwork) andjustification_deadline(see Schedule Class).trainingsgainedclasswork_deadline_default_daysandjustification_deadline_default_days— per-training day counts the client uses to pre-fill a new session's deadline fields; both save through the existing training-edit endpoint's unrestrictedTraining.*write and are not otherwise documented here. A newsession_justificationstable (id,session_student_id,upload_id,text,statusENUM('pending','approved','rejected'),reviewed_by,reviewed_at,review_note,created,modified) backs Justifications / Submit Justification / Review Justification below. A newsession_classwork_gradestable (id,session_student_idUNIQUE,upload_id,scoredecimal(3,1),feedback,graded_by,graded_at,created,modified) backs Classwork Grades / Rate Classwork below.
My Trainings
GET /trainings.json
Retrieve the authenticated user's training enrollments, teaching assignments, and upcoming classes. Paginated (30 per page).
Path Parameters
user_id
string
No
Optional user UUID to list enrollments for. Ignored unless the caller is an API user; non-API callers always see their own enrollments. Defaults to the authenticated user.
Per-row enrichment
Per-row progress is computed in batch, grouped by training_id, so the endpoint runs O(distinct trainings) queries instead of O(rows). Each enrollment is enriched as follows:
Training.subjects_count(always): integer count ofTrainingSubjectrows for the training. The fullTrainingSubjectlist is no longer included — fetch it from the training-view endpoint when needed.Training.Lessons(only whenTraining.theory == true): unified ground-school progress for all training types.Counts mandatory lessons + non-lesson-gate exams (subject-level + training-level, both ONLINE and ONSITE) as items.
Lesson is "done" when
activity_progress.value=1AND every mandatory lesson-gate exam attached to it hasvalue=1(lesson-gate exams gate the lesson but do not count as their own item).Onsite-exam item is "done" when
activity_progress.value=1(recorded via session attendance + manual grading).Online-test item is "done" when any
exam_attempts.passed=1.Shape:
{ "total": <int>, "completed": <int>, "finished": <bool> }.
Training.FlightProgress(only whenTraining.flights == true):{ "completed": <int>, "total": <int> }— distinctTrainingFlighttemplates marked completed for the enrollment.
The legacy per-row Training.Progress field has been replaced by Training.Lessons (theory) and Training.FlightProgress (flights). Clients that read Progress.total / Progress.completed should switch to Lessons.total / Lessons.completed.
Removed in this revision (drop them from clients):
Training.TrainingSubject(the embedded subject list) — replaced bysubjects_count.Top-level
LessonClassper row (the flattened upcoming-class list) — query the calendar endpoint or the training-view endpoint instead.Training.PhaseandTraining.LastFlight— removed for performance. Use the manager students-list endpoint when you need them.
Response
Manager Trainings List
GET /manager/trainings.json
List all trainings for the company. Manager-only (user_group_id <= 135 — Trainings Manager and above). Paginated (50 per page), ordered by name asc.
Per-row fields
Training.id, name, type, active, theory, flights, flights_count, competencies, metrics, announcement, start, end, color, created, plus:
students_count(int): totaltrainings_usersrows for the training (every status, not deduplicated by user).subjects_count(int): non-deletedtraining_subjectsrows for the training.
The previous Student and TrainingSubject association arrays are no longer returned — use the counts instead.
Response
Training View
GET /trainings/trainings/view/{id}.json
Retrieve full details for a specific training enrollment.
Path Parameters
id
string
Training ID
Response
Notes
ExamAttemptlists every attempt of this enrollment, online and onsite, oldest first. The per-subjectExamlist inTraining.TrainingSubject[]only contains ONSITE exams, so online attempts cannot be derived from it. Rows are flattened — the attempt fields sit at the top level with the exam nested underExam, not wrapped inExamAttempt.ExamAttempt.Onsite exams appear twice by design: once here as an attempt, once as the
activity_progressrow on theirTrainingActivity. Clients that render a single history (the activity timeline does) should keep the activity-progress copy and skip attempts whoseExam.type == "ONSITE".TrainingSubject[].Lesson[]carriesid,nameandminutes.
Training Schema Totals
GET /trainings/trainings/schema/{id}.json
Aggregate counts and durations for a training's ground-school structure and flight missions. Scope: must belong to caller's company.
Path Parameters
id
string
Training UUID
Response
Notes:
ground_hours_totalderived fromSUM(Lesson.minutes) / 60across non-deleted lessons in non-deleted subjects.flight_hours_totalderived fromSUM(TrainingFlight.hours)across non-deleted flight missions.flights_per_flight_typeentries grouped byTrainingFlight.flight_type_id; each row carries bothmissions(count) andhours(sum).
Errors
400
Missing training id
404
Training not found in caller's company
Student Summary
GET /trainings/students/summary/{enrollmentId}.json
Compact progress + attendance roll-up for an enrollment. Cheap to call (no nested rows) — designed for dashboards, modals, and mission-tab headers that don't need the full TrainingsUser view.
Path Parameters
enrollmentId
string
TrainingsUser.id (UUID)
Access
Manager-level callers (
user_group_id <= 170— Flight Instructor and above): any enrollment in caller's company.Student-level callers (
user_group_id > 170— Captain and below): only their own enrollment —403otherwise.
Response
Field semantics
theory.progress— derived fromTraining::getProgress(mandatorytraining_activitiesin scope, all subjects).completedincludes LESSON activities marked attended and EXAM activities passed (gate exams gate the corresponding lesson's completion).percentage = floor(completed * 100 / total).theory.attendance— derived fromTraining::getAttendance.given=session_students.invited = 1rows whosesessions.training_activityis subject-scoped andsessions.datetime < now().taken= subset whereattended = 1.For
Training.type == "DISTANCE"theattendanceblock mirrorsprogress(no live sessions exist for distance training).flightisnullwhenTraining.flights == false.flight.progress—total= count ofTrainingFlighttemplates in the training.completed= distinct templates with at least oneUserTrainingFlight.completed = 1row (flightstatus = LANDED). Same logic asTraining.FlightProgressinMy Trainings.flight.attendance— per-mission attempt counts across alluser_training_flightsfor the enrollment (each row = one attempt).passed = sum(completed = 1). Useful for "X attempts / Y passes" badges.flight.hours.planned=SUM(TrainingFlight.hours).flight.hours.flown=SUM(Flight.block_time) / 3600over flights withstatus = LANDEDlinked throughuser_training_flights. Rounded to one decimal.statusmirrorsTrainingsUser.status— one ofACTIVE,COMPLETED,STOPPED,FAILED,EXPELLED.status_reasonis the free text a manager gave when closing the enrollment,nullotherwise. See Enrollment status.
Errors
400
Missing enrollment id
403
Student requesting another user's enrollment
404
Enrollment not found / Training not found in caller's company
My Subjects
GET /trainings/subjects/mine.json
Retrieve subjects assigned to the authenticated user (as student).
Response
Subject View
Manager View
GET /manager/trainings/subjects/view/{id}.json
Retrieve subject details for the manager UI: subject metadata, full lesson list, subject-level exams, and the unified TrainingActivity ordering used for drag-drop reordering.
Notes:
Examis filtered to subject-scope rows only (training_subject_lesson_id IS NULL); lesson-gate exams are nested under their parent lesson elsewhere. BothONSITEandONLINEtypes are included.TrainingActivityis the unified activity ordering. The endpoint backfills missingTrainingActivityrows for any pre-v2 lesson/exam on read, undeleting any soft-deleted row that reuses the samelesson_id/exam_id(the unique keys ignore thedeletedflag, so a fresh insert would 1062-collide). When a backfill happens the activity list is re-fetched so the response is consistent.total_minutesis the sum of mandatory lessonminutes.The teacher picker list is no longer part of this response — fetch it from
/manager/trainings/subjects/teachers.json(see below).
Response
Manager Teachers
GET /manager/trainings/subjects/teachers.json
Return the teacher-picker list (formerly bundled inside manager_view). Users are scoped to the caller's company_id and to user groups with instructor_teacher = 1.
The payload is a Hash::combine of id → "name surname", grouped by user-group name.
Response
Manager Reorder Activities
POST /manager/trainings/subjects/reorder_activities.json
Persist a new order for the unified activity list (lessons + non-lesson-gate exams) of a single subject. Writes contiguous TrainingActivity.order via TrainingActivity::resequence.
id
string
Yes
Subject UUID
items
string[]
Yes
Ordered list of training_activity_id. IDs not belonging to the subject are silently dropped (whitelist).
Authorization: caller's company must own the training; non-admin users (user_group_id > 135 — Crew Scheduling and below) must be the subject's teacher.
Response
Manager Activity Impact
POST /manager/trainings/subjects/activity_impact.json
Read-only preview of the student-progress impact of soft-deleting a lesson or an exam. The UI calls this immediately before showing the destructive confirm dialog so the manager can be warned with the count of enrollments whose progress would be reset.
The endpoint resolves which TrainingActivity rows would be affected:
lesson_id→ theLESSONactivity for that lesson plus everyEXAMactivity bound to a gate exam (exams.training_subject_lesson_id = lesson_id).exam_id→ the singleEXAMactivity for that exam.
Pass exactly one of lesson_id or exam_id. The aggregates run over ActivityProgress (distinct trainings_user_id), ExamAttempt and TrainingSession; nothing is mutated.
lesson_id
string
One of these two
Lesson UUID
exam_id
string
One of these two
Exam UUID
Authorization: caller's company must own the training the entity belongs to. The endpoint walks the same resolution paths used by the corresponding delete actions (lesson → subject → training, exam → training | training_subject → training | training_subject_lesson → subject → training) and returns 404 if the entity is missing or out of company scope. Soft-deleted (deleted=1) lessons / subjects / trainings are still resolvable so the caller can preview impact of an already-removed entity; the 404.message field disambiguates the failure (Lesson not found: …, Training subject not found for lesson …, Lesson not accessible for current company).
Response
studentsCompleted— distinct enrollments withActivityProgress.value = 1on any of the affected activities (the count that needs warning).studentsTouched— distinct enrollments with anyActivityProgressrow on the affected activities (>= studentsCompleted).examAttempts— totalExamAttemptrows that reference the affected exams (audit context).sessions— totalTrainingSessionrows linked to the affected activities (audit context).
When every count is 0 the UI may skip the impact block and show the regular destructive warning.
Add / Edit Exam
POST /manager/trainings/subjects/add_exam.json
Create or update an exam on a subject. Send id to edit, omit to create (a TrainingActivity of kind=EXAM is seeded for new exams).
training_subject_id
string
Yes
Subject UUID.
id
string
No
Exam UUID — present = edit, absent = create.
type
enum
No
ONLINE | ONSITE (default ONSITE).
name
string
Yes
Exam name.
reference, description, minutes, mandatory, caa_exam
mixed
No
Common fields.
questions, attempts, score, instructions, show_answers, expiration
mixed
No
ONLINE only.
access_mode
enum
No
ONLINE only. FREE | SCHEDULED (default FREE). SCHEDULED gates the exam behind a session. Ignored / forced FREE for ONSITE.
access_window_days
int
No
ONLINE + SCHEDULED only. Days the window stays open after session start. Default 10 (used when omitted or <= 0).
Authorization: caller's company must own the training; non-admin users (user_group_id > 135 — Crew Scheduling and below) must be the subject's teacher.
List Schedulable Exams
GET /manager/trainings/exams/list/{subjectId}.json
Exams in a subject that can be scheduled into a session: all ONSITE exams, plus ONLINE exams with access_mode='SCHEDULED'. FREE online exams are never scheduled and are excluded. Each row carries type and access_mode (plus access_window_days) so the UI can badge them.
Student View
GET /trainings/subjects/view/{enrollmentId}/{subjectId}.json
Retrieve subject details with the full unified activity list (lessons + exams) and the enrolled student's progress, attendance and exam attempts.
Who can call this: the enrolled student, the subject teacher, and managers (user_group_id <= 150 — Chief Pilot and above). The endpoint resolves the student via {enrollmentId} (the TrainingsUser.id URL param), so attempts / progress / attendance always reflect that student regardless of which user is authenticated.
Activity list: subject.TrainingActivity[] is the canonical ordered list, sorted by TrainingActivity.order ASC. Lessons and exams are interleaved as a single sequence — typically lesson → its gate exam → next lesson → next gate exam → … → subject-scope exams at the end. Missing TrainingActivity rows are backfilled on read (live lesson / subject-scope exam without a TA row gets one inserted; a deleted TA row whose source is live again gets undeleted) so every live lesson and exam in the subject is guaranteed to appear.
Per-activity payload:
LESSON activities carry the
Lessonpayload and aSessions[]list — one entry perTrainingSessionunder that activity where the student has aSessionStudentrow, ordered bySession.datetime ASC. Each entry hasSession: {id, datetime, minutes}andSessionStudent: {id, invited, attended}.attendedis resolved from the matchingActivityProgress.value(joined bysession_id) when anActivityProgressrow exists for that session — that is whereOnsiteController::attendancewrites attendance. Sessions without a matchingActivityProgressrow fall back to the rawsession_students.attendedcolumn.EXAM activities carry the
Exampayload with itsExamAttempt[]list for this enrollment (newest first). Onsite-examActivityProgressrows are sorted bySession.datetimeascending.
Reset filter: if an exam activity has been reset (ActivityProgress.reset_at is set), the ExamAttempt[] list under that activity contains only attempts where start > reset_at (falling back to created when start is empty). Pre-reset attempts are hidden from the student here; they remain in the DB and are surfaced — flagged with reset: true — in GET /manager/trainings/students/view/{enrollmentId}.json for manager review.
Response
Legacy fields subject.Lesson[] and subject.Exam[] are no longer emitted by this endpoint — consume subject.TrainingActivity[] and branch on TrainingActivity.kind.
Subject Bank (linked subjects)
A subject can be linked across courses of the same company. Every course keeps its own concrete copy (own ids, own progress); a family (training_subjects.subject_family_id) joins the copies and one member is flagged the source (family_source). Changes are pushed from the source after a preview; nothing syncs automatically.
Authorization (all endpoints below): manager prefix, user_group_id ≤ 135 (teachers and flight instructors get 404). Everything is scoped to the caller's company; superseded course revisions (version_status = SUPERSEDED) are never listed, targeted or changed. TrainingSubject payloads (training view, subject view) carry subject_family_id and family_source.
All bodies are JSON (application/json); 4xx errors carry the usual { "name", "message" }.
List Families
GET /manager/trainings/subjects/families.json
Families of the company with their live members. With ?candidates=1 it also returns candidates: live, not-yet-linked subjects of the company (used by the "add from bank" and "link" pickers).
Import Subject
POST /manager/trainings/subjects/import.json
Copies a subject into a course as a linked family member (the family is created, with the copied subject as source, when it had none). The copy is appended after the course's existing subjects and brings lessons (in curriculum order), slides (rows + stored html), learning objectives, exams with their question links, lesson-gate exams and uploaded files. Uploaded file copies are named name 2.ext, name 3.ext, … The import is recorded as a SUBJECT CREATED change of the destination course (revision/approval for a live course).
subject_id
string
Yes
Subject to copy (same company)
training_id
string
Yes
Destination course (same company, not superseded)
Link Subjects
POST /manager/trainings/subjects/link.json
Links two existing subjects of different courses. target_id joins source_id's family (created when needed). Without confirm it only returns the match preview.
source_id
string
Yes
Subject to follow
target_id
string
Yes
Subject that will follow (must not be linked already)
confirm
int
No
1 writes the links; omitted/0 previews
Matching: lessons by code, then name, then same position; slides by position within a matched lesson; objectives, lesson-gate and subject exams by name (then position); uploads by name. Unpaired rows are reported, never deleted.
With confirm=1 the response is { "confirmed": true, "family_id": "…", "match": {…} }. Errors (400): same course, already linked target, superseded course. 404: subject outside the company.
Unlink / Make Source
POST /manager/trainings/subjects/unlink.json — body { "subject_id" }. The subject keeps its content and leaves the family. If it was the source, the oldest remaining member becomes source; a family left with one subject dissolves. Deleting a subject (subjects/delete) does the same.
POST /manager/trainings/subjects/set_source.json — body { "subject_id" }. Makes that member the single source of its family (400 for a superseded course).
Both return { "result": true }.
Sync Preview
POST /manager/trainings/subjects/sync_preview.json
Read-only. What pushing the source's content would do to each other live member.
source_id
string
Yes
Must be the family source (400 otherwise)
target_ids
string[]
No
Subset of members; default all
sync_hours
int
No
1 also syncs the subject hours (local by default)
sync_code
int
No
1 also syncs the subject code (local by default)
quick
int
No
1 skips the slide-html comparison (cheap; for "needs review" badges — slide html edits are not seen)
op is update (source changed, course untouched since the last sync, or never synced and different), conflict (both changed since the last sync), add (source row the course lacks and has not excluded) or removed_in_source (never applied; has_progress tells whether students have progress on it). A course that changed locally while the source stayed put produces no op. entity is one of SUBJECT, LESSON, SLIDE, OBJECTIVE, EXAM, UPLOAD; ops are ordered parents first, removals last. Fields that never sync: teacher_id, subject/activity order, and (unless opted in) hours and code.
Apply Sync
POST /manager/trainings/subjects/sync.json
Applies the push, one transaction per target, in place (ids and student progress are preserved). New lessons/exams are appended after the subject's last lesson (lesson-gate exams right after their lesson, subject exams last). Exam question sets are written as explicit link inserts/deletes. Removals are never applied. For a CURRENT course the sync opens a revision (Subject "…" synced from "…", plus a SUBJECT UPDATED change when subject fields changed) and queues approval like a manual edit; DRAFT courses are changed without a revision.
source_id
string
Yes
The family source
sync_hours / sync_code
int
No
As in the preview
targets
object[]
Yes
{ "subject_id", "resolutions": { "<fid>": "source" | "course" }, "exclusions": ["<fid>"] }
resolutions answers conflict ops (source overwrites the course item, course keeps it and records it as reviewed); a conflict with no resolution is skipped and returned in unresolved. exclusions lists add ops the course declines — they are remembered and not proposed again. A target that is not a live member of the source's family is rejected per target.
Lessons
View Lesson
GET /trainings/lessons/view/{lessonId}/{enrollmentId}.json
Retrieve lesson details and attendance records.
Response shape
lesson: the lesson withTrainingSubject(incl.Training) andLessonSlideids.enrollment: the validatedTrainingsUser.id(ornullwhen no enrollment was passed).attendance: this student'sActivityProgressrows for the lesson's LESSON activity (User.UserDetailjoined for display).testAuthorization:truewhen the student may take the lesson-gate test (has slides done, or no slides / no time-online requirement).nextActivity: the nextTrainingActivityin the same subject, ordered byTrainingActivity.order. May be a LESSON or an EXAM — the response carries either an embeddedLessonorExampayload accordingly.nullwhen this lesson is the last activity in the subject. Orphan exam activities (deleted/missing Exam) are skipped over automatically.
nextActivity example (next item is the lesson-gate exam):
Replaced the legacy
nextLessonfield. Clients that readnextLessonshould switch tonextActivityand branch onnextActivity.TrainingActivity.kind.
Lesson Slides
GET /trainings/lessons/slides/{lessonId}.json
Retrieve e-learning slides for a lesson.
View Slide
GET /trainings/lessons/slide/{slideId}.json
Retrieve a single slide with content and navigation.
Complete Lesson
POST /trainings/lessons/complete.json
Mark a lesson's slides as completed. Sent as application/x-www-form-urlencoded.
lessonId
string
Yes
Lesson ID
timeSpent
number
Yes
Time spent in seconds
enrollmentId
string
Yes
Enrollment ID
Response
Reset Lesson (student self-service)
POST /trainings/lessons/reset.json
Allows a student to wipe their own progress on a single lesson — both the lesson's ActivityProgress and the lesson-gate online exam attempts — so they can take the lesson again. Intended for DISTANCE trainings where a student has exhausted attempts on the gate exam without passing, and the course operator has opted in to self-recovery.
Sent as JSON.
Body
enrollmentId
string
Yes
TrainingsUser.id — must belong to the authenticated user
lessonId
string
Yes
Lesson.id — must belong to the enrolled training
Guards (all must pass or the call rejects with 400/404)
The enrollment exists and
TrainingsUser.user_id == auth user id(you can only reset your own progress).Training.type == 'DISTANCE'.Training.allow_auto_restart == 1.The lesson belongs to the enrolled training.
The lesson has at least one ONLINE lesson-gate exam (
exams.training_subject_lesson_id = lessonId AND type='ONLINE').At least one of those gate exams is in a failed state for this enrollment:
COUNT(ExamAttempt) >= Exam.attemptsand no attempt passed.
Side effects on success
Reset is non-destructive. ExamAttempts are retained for audit; ActivityProgress rows are kept and stamped with a reset boundary:
For the lesson activity AND each lesson-gate exam activity, the corresponding
ActivityProgressrow is upserted withreset_at = UNIX_TIMESTAMP(now)and the user-visible state is cleared:value=0,attempts_count=0,score=NULL,code=NULL,notes=NULL,last_attempt_id=NULL.ExamAttemptsrows are not deleted. The manager student-view surfaces them with areset: trueflag so reviewers can see the pre-reset cycle.Downstream attempt counting (the "max attempts" gate on
POST /trainings/exams/start/...) and the pass/score aggregation (ActivityProgress::recordExamAttempt) only consider attempts whereExamAttempt.created > ActivityProgress.reset_at— so the student starts fresh.
The enrollment row is not touched. If the enrollment was status='COMPLETED' before the reset (unlikely on a DISTANCE in the failed branch), it stays completed — change it manually if needed.
Response
400 reasons: missing fields, training not DISTANCE, allow_auto_restart=0, lesson has no gate exam, lesson not in failed state. 404 reasons: enrollment doesn't belong to you, lesson not in this training.
Lesson Attendance
GET /trainings/lessons/attendance/{subjectId}.json
Retrieve attendance records for all lessons in a subject.
Class Sessions
View Class
GET /trainings/onsite/class/{id}.json
Retrieve class session details including attendance.
Response class payload contains:
Session,Teacher,SessionStudent,Location.teacher—truewhen the requester is the session's teacher, the subject's teacher (TrainingActivity.TrainingSubject.teacher_id), or a manager (user_group_id <= 140— Crew Scheduling and above). Recomputed server-side on every call from the session/subject data — the same definitionattendance()independently recomputes below, so the two never disagree.enrollment— the requester'sTrainingsUser.idfor this training, ornull. Being on the session roster (session_students, e.g. a pilot-group invite or manual add with no enrollment) is enough to open the class even without one;enrollment=nullis what the client uses to explain the missing progress/evaluation in that case. Belowuser_group_id 170(Captain and below), a caller who is neither enrolled, on the roster, nor a teacher/manager gets a 404.attendances— the session roster, returned by an internal call toattendance(); see Attendance below for the full per-row shape and its own access rule.Session.signature, when present, has its server-onlystatusesbaseline stripped (and the same key inside everyhistoryentry) — see Sign Class's note onstatuses. This action has no teacher/student gate on the roster it exposes, so leavingstatusesin would let any enrolled student read every classmate's attendance baseline.TrainingActivity— the bound activity (id,kind,lesson_id,exam_id,training_subject_id) with its nested children:TrainingActivity.TrainingSubjectincludingTrainingandTraining.Metric[]— competency metrics defined on the training (id,name,training_id), ordered by name. Used to render the metric grid alongside attendance without an extra request.TrainingActivity.Lesson(whenkind=LESSON) withLearningObjective[]— objectives attached to the lesson (id,name,description,training_subject_lesson_id), ordered by name.TrainingActivity.Exam(whenkind=EXAM) — includestypeandaccess_mode, so the session page knows to render the student exam-taking panel for aSCHEDULEDonline exam.
attendances—ActivityProgressrows for the session (scoped to the requesting user unless teacher/manager).
Alongside class, the response carries:
classwork_submitted—truewhen the requester has an activeSessionClassworkupload of their own on this session. One count query; lets a client render the homework state without waiting on the uploads list.signature_required— whether THIS class is subject to the student attendance-signature requirement: the course flag AND the session falling at or aftertrainings.attendance_signature_from. Clients must gate the signature UI and the register's SIGNED column on this, not onTraining.attendance_signature, which alone would render classes taught before the rule as unsigned.classwork_grade— the requester's own homework grade for this session, ornullwhen they have not been graded (or are not on the roster).{ score: 0.0-10.0 | null, feedback: string | null, graded_at: int }. Scoped by construction: the roster row is resolved by (session, caller), so it can never return another student's mark. This exists because students are ACL-denied the Onsite sub-actions and so cannot call Classwork Grades — it is the only way a student reads their own grade. Reviewers get their own row here too, and use Classwork Grades for the roster.
Attendance
GET /trainings/onsite/attendance/{sessionId}/{teacherParam}.json
Return the roster and attendance for a class session. Called internally by View Class (its attendances field is this response), and independently reachable at its own URL/ACO.
The {teacherParam} URL segment is accepted for route compatibility only and is ignored — whether the caller sees the full roster is always recomputed server-side from the session/subject teacher and user_group_id <= 140 (Crew Scheduling and above), the same rule View Class's teacher field uses. It cannot be used to grant teacher-level access from the URL.
Company-scoped: the session's Training.company_id must match the caller's company, or 404 Training Session not found.
Response
attendances is an object keyed by trainings_user_id (or ss-{session_student_id} when the roster row has no matching activity_progress), one entry per roster row, sorted by user_name:
id
ActivityProgress.id, or null if none exists yet for this user/activity
session_student_id
session_students.id
attendance_status
attended | attended_post_class | absent | absent_justified | null
enrolled
true when the roster user has any trainings_users row for this training (any status), matching store_attendance's write guard
value
boolean, true when attendance_status is attended or attended_post_class
user_id, user_name, user_group, photo
roster user display fields
time, remarks, measures, modified, created
from the matching activity_progress row, null if none
signature
the student's own attendance signature for THIS session, from session_students.signature: { time } and nothing else, or null. The stored ip, browser and hash are never returned — a class payload is readable by the whole roster. Previously read from activity_progress.signature, which is per-activity and could not distinguish two sittings of the same lesson
can_sign
true when the caller may countersign this row right now. Only answered for the caller's own row; null on everyone else's
sign_blocked_reason
why can_sign is false: not_required | not_attended | not_yet_open | window_closed | already_signed. Own row only. not_required covers both "this course does not ask" and "this class predates the requirement"
exam_status, exam_rating, code, notes
from activity_progress.value / score / code / notes — exam activities only
Access. Non-teacher, non-manager callers (user_group_id > 140 — Flight Dispatcher and below and not the session/subject teacher) see only their own roster row. Teachers and managers see every row. Roster rows with session_students.user_id IS NULL (an unexpanded pilot-group placeholder) are excluded entirely.
Schedule Class
POST /manager/trainings/onsite/class.json
Create or update a class session. Sent as application/x-www-form-urlencoded.
training_activity_id
string
Yes
Activity ID (kind=LESSON or kind=EXAM). Replaces the old training_subject_lesson_id / training_subject_exam_id pair.
start
string
Yes
Start datetime
teacher_id
string
Yes
Teacher user ID
location_id
string
No
Location ID
remarks
string
No
Notes
notify
boolean
No
Send notification to students
students
string
Yes
Comma-separated student user IDs (creates session_students rows with invited=1).
allow_classwork_upload
boolean
No
Whether students may upload classwork for this session
classwork_deadline
int
No
Unix timestamp deadline for classwork upload, or empty to clear
classwork_description
string
No
Classwork brief shown to students
justification_deadline
int
No
Unix timestamp — deadline for a student to submit an absence justification for this session. Sent on every save; absent or empty clears it to NULL (unlike the three classwork fields below, this one is not preserve-when-omitted).
A training_activity_id may be the activity of a SCHEDULED online exam (kind=EXAM, online). No special handling is needed beyond the standard access gate — the roster (students) defines who may take the exam during the window that opens at start.
Classwork fields are preserved when omitted. allow_classwork_upload, classwork_deadline, and classwork_description are normally set via Request Classwork below, not this endpoint. When editing an existing session, any of the three that is absent from the payload keeps its currently stored value rather than being reset to off/empty — an unrelated edit (date, teacher, location) never silently cancels a teacher's already-saved homework request. A field is only changed when the caller explicitly includes it. A brand-new session has nothing to preserve and defaults to off/no-deadline/no-description.
Request Classwork
POST /trainings/onsite/request_classwork.json
Enable, edit, or cancel a classwork upload request for a session. The only write path for classwork_description; manager_class only ever preserves it.
session_id
string
Yes
TrainingSession.id
allow_classwork_upload
boolean
No
Enable/disable the request
classwork_deadline
int
No
Unix timestamp; empty/absent clears it to NULL
classwork_description
string
No
Homework brief. 400 Homework description is too long (2000 characters max) above 2000 characters (mb_strlen)
Access. Company-scoped (404 Training Session not found). Caller must be the session's or subject's teacher, or a manager (user_group_id <= 140 — Crew Scheduling and above), else 404 Not authorized to modify this class. Blocked once the session is signed: 400 Attendance for this class is already signed.
Side effect — roster notification. On the disabled/never → enabled transition only (never on disable, never on an edit of an already-enabled request), every enrolled roster student gets a non-urgent in-app message with the training/subject/date/location, the homework brief, and the deadline. Guarded by Session.classwork_notified_at (claim-before-send, reset to NULL on every disable so a later re-enable notifies again). Notification failure does not fail the request.
Response
Delete Class
GET /manager/trainings/onsite/delete_class/{classId}/true.json
Delete a scheduled class session.
Session Report
GET /trainings/onsite/session_report/{start}/{end}[/{training_id}].json
Report of class sessions in a date range with per-user attendance totals. Company-scoped via Training.company_id.
Path Parameters
start
string|int
Yes
Inclusive lower bound for Session.datetime. Accepts a unix timestamp or any strtotime-parseable string (e.g. 2026-01-01).
end
string|int
Yes
Inclusive upper bound for Session.datetime. Same accepted formats as start.
training_id
int
No
Optional Training.id filter — only sessions whose subject belongs to this training.
Response
Field semantics
sessions[]— everyTrainingSessionwhosedatetimefalls in[start, end](any status, future or past within the window). Sorted bydatetime ASC.users[]— aggregated fromactivity_progressrows attached to the matched sessions. Every distinctuser_idwith anactivity_progressrow is included (invited set).sessions_invited/minutes_invited— count and sum ofSession.minutesacross everyactivity_progressrow for the user.sessions_attended/minutes_attended— subset whereactivity_progress.valueis truthy.
Users sorted alphabetically by
user_name.
Errors
400
Missing start or end date
Path param null
400
Invalid start or end date
strtotime returned false / 0
List Scheduled Sessions
GET /manager/trainings/onsite/sessions.json
List scheduled class sessions in a date range. Company-scoped via Training.company_id.
Query Parameters
start
string|int
Yes
Inclusive lower bound for Session.datetime. Accepts a unix timestamp, or a date/datetime string parsed in timeZone (e.g. 2026-05-18).
end
string|int
Yes
Inclusive upper bound. Date-only values (no T and no :) are extended to 23:59:59 of that day in timeZone.
timeZone
string
No
IANA timezone name used to interpret start/end and to format response timestamps. Defaults to UTC.
training
string
No
Optional Training.id filter — only sessions whose subject belongs to this training.
Response
Field semantics
sessions[]— everyTrainingSessionwhosedatetimefalls in[start, end]. Sorted bydatetime ASC.start/end— ISO 8601 with offset, formatted in the suppliedtimeZone.end = start + duration_minutes.kind—LESSONorEXAMfrom the boundTrainingActivity.activity— theLesson.name(kind=LESSON) orExam.name(kind=EXAM).training—Training.name.teacher—Teacher.UserDetail.name/surname;idis the user id.location—Location.name, empty string if the session has no location.
Errors
400
Missing start or end
Query param missing or empty
400
Invalid timeZone
DateTimeZone rejected the name
400
Invalid start / Invalid end
Date string could not be parsed
Store Attendance
POST /trainings/onsite/store_attendance.json
Record attendance for a class session. The payload is now keyed by session_students.id (was activity_progress.id); each entry carries an attendance_status value rather than a bare 1/0 flag.
Body
data[session_id]
string
Yes
TrainingSession.id
data[attendance][{session_student_id}][attendance_status]
string
No
One of attended, attended_post_class, absent, absent_justified. 400 Invalid attendance status: {value} if set to anything else.
data[attendance][{session_student_id}][exam_value]
int/bool
No
EXAM activities only — teacher pass/fail grade, written to activity_progress.value alongside (not derived from) attendance
data[attendance][{session_student_id}][exam_rating]
number
No
Exam score
data[attendance][{session_student_id}][notes]
string
No
Exam notes
data[attendance][{session_student_id}][code]
string
No
Exam code
Only the fields actually present in a row are written — an attendance-only save never clobbers a grade, and vice versa.
Empty attendance map is a no-op, not an error. data[attendance] absent, non-array, or {} returns {"store": true, "grades": []} rather than 400 Missing POST params. The client drops unenrolled rows before posting, so a session whose entire roster is unenrolled (or has no students at all) legitimately submits nothing — and since Sign Class saves attendance before signing, the old 400 blocked signing that class outright.
Access. Company-scoped (404 Training Session not found if the session's training belongs to another company). user_group_id <= 140 — Crew Scheduling and above (manager) may always write; above that, the caller must be the session's teacher_id or the subject's teacher_id, else 404 Not authorized to modify this class attendance.
Enrollment guard. Any row whose session_students.user_id has no trainings_users row for this training (any status) is refused outright — 400 Cannot record attendance for a student not enrolled in this training. Checked for every row before anything is written, so one bad row fails the whole request.
Closed sessions. Once Session.status is no longer open/scheduled, non-managers (user_group_id > 140 — Flight Dispatcher and below) may only submit the transition absent/absent_justified → attended_post_class; any other change on a closed session is rejected with 400 Attendance for this class is closed. Managers are exempt.
Response
grades[] lists the rows actually applied: {session_student_id, user_id, value} for a LESSON activity's derived attendance, or {session_student_id, user_id, ...submitted exam fields} for an EXAM activity's grade fields.
Side effect — student signature request. On a training with attendance_signature = 1 and a session at or after attendance_signature_from, every roster row that is now eligible to countersign (see Sign Own Attendance) gets one non-urgent in-app message asking the student to sign, sender = the caller, linking to FRONTEND_HOST/trainings/onsite/class/{sessionId}. Idempotent per student via session_students.signature_requested_at (claim-before-send: conditional UPDATE ... WHERE signature_requested_at IS NULL, same pattern as missed_email_sent_at and attendance_notified_at), so re-saving the sheet does not re-ask. Nothing is sent when the setting is off, and never to a student the policy would then refuse. Notification failures are swallowed and do not affect the response.
This request is raised here, when attendance is recorded, rather than from Sign Class — the request and the ability to act on it now begin at the same moment.
Sign Class
POST /trainings/onsite/sign_class.json
Digitally sign class attendance (teacher confirmation). This is the teacher closing the register. The student countersigning their own row is a different endpoint — see Sign Own Attendance below. The two are independent: signing the class does not close the students' signing window.
classId
string
Yes
Class session ID
password
string
Yes
Teacher's password for verification
Repeatable. A session that is already closed can be signed again — a fresh signature is written over the old one, and every prior signature is preserved in the new signature's history array rather than being discarded.
Resolves undecided attendance. Any roster row still attendance_status=NULL is resolved to absent at sign time — but only for a student actually enrolled in the training (any trainings_users status); an unenrolled roster row's status, including NULL, is left untouched.
Sign window. A teacher (user_group_id > 140 — Flight Dispatcher and below) may normally only sign within 72h before / 6h after Session.datetime. Outside that window a re-sign is still allowed if every change since the LAST signature is exclusively absent/absent_justified → attended_post_class (crediting a late post-class submission); any other change outside the window is rejected with 400 Attendance signature for this class is closed already. Managers (user_group_id <= 140 — Crew Scheduling and above) are not window-limited. Above user_group_id 140, the caller must additionally be the session's own teacher_id (not the subject teacher) or gets 404 Not authorized to sign this class.
Company-scoped. 404 Class not found if the session's training belongs to another company.
Side effect — missed-class email. Distinct from the notification below: for every roster row newly resolved to absent (enrolled students only), a separate templated email (trainings/missed_class) is sent once per student per session, guarded by session_students.missed_email_sent_at (claimed with a conditional UPDATE ... WHERE missed_email_sent_at IS NULL before sending, so concurrent sign calls can't double-send). A re-sign does not re-send it to a student already marked absent on a prior sign.
Side effect — scheduled-exam window. Signing sets Session.status='closed' and Session.signed_at (unix). For a SCHEDULED online exam this closes the access window from that moment (earlier than the access_window_days cutoff). New attempts are then rejected (403); an already-open attempt may still be submitted.
Side effect — attendance notification. Runs on every successful sign — first or repeat, since signing itself is repeatable — but is idempotent per student via session_students.attendance_notified_at (claim-before-send: a conditional UPDATE ... WHERE attendance_notified_at IS NULL before sending, same pattern as missed_email_sent_at above), so each student with an activity_progress row for the session receives at most one non-urgent in-app message via Messages.Message::fastSave (sender = signing teacher), no matter how many times the class is (re-)signed. Per-recipient body includes:
Their attendance result (Attended / Not attended).
For exam activities: rating (
score) and result (Passed / Failed, derived fromvalue).A reminder + frontend link to sign their own attendance, if the student attended and
session_students.signatureis empty — and only when the training hasattendance_signature = 1. (This previously readactivity_progress.signature, which is per-activity, and was sent regardless of the training setting.)The next
TrainingActivityin the sametraining_subject_id(byorder). If a futureSessionexists for that activity, it is shown with date and a frontend class-view link; otherwise just the activity name.If that next activity is an
Examoftype=ONLINEand the student attended this one, an extra line linking toFRONTEND_HOST/trainings/exams/start/{exam_uuid}.
The message redirect field is /trainings/onsite/class/{sessionId}. Notification failures do not affect the sign response.
Response signature. The persisted signature JSON carries a server-only statuses map (session_students.id => attendance_status as of this sign) used purely as the next sign's carve-out baseline — it is stripped, along with the same key inside every history entry, before being returned here or from View Class.
Pending Signatures
GET /manager/trainings/onsite/pending_signatures.json
Every student signature still outstanding across the company's courses — the worklist behind Trainings → Pending signatures. Read-only; nothing here gates completion or certificates.
Two sources in one list, distinguished by type:
attendance—session_students.signature IS NULL. Returned only when the course hasattendance_signature = 1, the session is at or aftertrainings.attendance_signature_from(or that column isNULL), and the roster row'sattendance_statusisattendedorattended_post_class. Classes taught before a course adopted the requirement are therefore never reported as outstanding.debriefing—user_training_flights.signature IS NULLon a course withrequire_debriefing_signature = 1, restricted to flown missions (completed = 1,flight_id IS NOT NULL). No adoption cut-off applies: this signature has always been available, so an old unsigned debriefing is genuinely outstanding. Its date is the flight's owndate, falling back to the row'screated.
training
Limit to one trainings.id
user
Limit to one student's user_id
type
attendance or debriefing; both when omitted
days
Recency window, default 90; 0 for everything. Not cosmetic — the historic debriefing backlog runs to tens of thousands of rows
Access. user_group_id <= 140 (Crew Scheduling and above), else 403 Not allowed to review pending signatures. Company-scoped through Training.company_id. Capped at 2000 rows.
Response
Each row carries type, and flight_id on a debriefing row. session_id, subject_name and attendance_status are null for a debriefing; kind is MISSION. Ordered newest first across both sources; each source is capped at 2000 rows.
requested_at is session_students.signature_requested_at — null means the student has never been asked (the class predates the notification, or attendance was recorded before it existed). can_still_sign is false only where the course set an optional closing date and it has passed; with the default of no closing date it is always true.
Sign Own Attendance
POST /trainings/onsite/sign_attendance.json
The student countersigns their own attendance for one class. Onsite and remote trainings only. Distinct from Sign Class, which is the teacher closing the register.
class_id
string
Yes
Session id
pass
string
Yes
The caller's own password — the re-entry is what makes this a signature
Writes session_students.signature ({time, ip, browser, hash}) for the (session_id, caller) roster row. It does not write activity_progress.signature: that row is UNIQUE (trainings_user_id, training_activity_id), one per activity, so it cannot hold a per-sitting signature. The legacy column is still populated on historic rows and is never written again.
Eligibility. Decided by StudentAttendanceSignaturePolicy, the same rules that produce can_sign / sign_blocked_reason on View Class:
The training must have
attendance_signature = 1(not_required).The session must fall at or after
trainings.attendance_signature_from, the moment that course adopted the requirement. An earlier session is out of scope, and reportsnot_required— notwindow_closed, and never "outstanding".NULLmeans no cut-off was recorded and the requirement applies throughout. This is what stops enabling the setting from retroactively marking a course's entire history as unsigned.The caller's
session_students.attendance_statusmust beattendedorattended_post_class(not_attended).Now must be at or after
Session.datetime - 4h(not_yet_open).There is no closing date by default.
trainings.attendance_signature_daysis opt-in:0(the default) means a student may sign at any time;> 0closes the window that many days afterSession.datetime(window_closed).The teacher having signed the class is irrelevant.
sessions.signatureis not consulted. This endpoint previously rejected outright once the class was signed — while the only message that asks a student to sign is sent by that signing.
Eligibility is checked before the password, so a caller is never asked for a credential only to be refused anyway.
Repeat calls are a no-op, not an error: an already-signed row returns its existing {time} with 200.
Access. The caller must have a session_students row for the session (404 You are not on the roster of this class), and the session's Training.company_id must match theirs (404 Training Session not found). Beyond that, this action is ACL-gated per role on acos.alias = 'sign_attendance': UserGroup 180 (Captain), 200 (Student Pilot), 240 (Cabin Crew) and 245 (Specialist) are granted — the roles that sit in a classroom as students and therefore sign their own row. 250 (Auditor) is denied: an auditor observes a class, they do not attest to attending it. Every other role inherits access from the root controllers ALLOW. A denied role gets 403 ACL_DENIED no matter how valid the request is.
Response
null if nothing was stored. The ip, browser and hash persisted alongside are never returned.
Errors
400
Missing POST params
400
This course does not ask students to sign their attendance.
400
Your attendance for this class has not been recorded as attended.
400
This class has not started yet.
400
The signature period for this class has closed.
400
The entered password was not correct
400
Training is not activated by company manager / Training not started yet / Training finished already
403
ACL_DENIED — the caller's role is not granted this action
404
Training Session not found (unknown session, or another company's)
404
You are not on the roster of this class
Unsign Class
POST /trainings/onsite/unsign_class.json
Remove digital signature from a class.
Classwork Grades
GET /trainings/onsite/classwork_grades/{sessionId}.json
List the homework grades for a session, scoped by role.
Access. Company-scoped (404 Training Session not found). A reviewer — the session's or subject's teacher, or a manager (user_group_id <= 140 — Crew Scheduling and above) — sees every grade on the session. Anyone else is scoped to their own roster row.
Students cannot call this endpoint. On deployments where the Onsite sub-actions are ACL-restricted (the default), a student gets 403 ACL_DENIED here exactly as they do for Justifications. A student's own grade is therefore carried on the Class payload instead, as classwork_grade — see below. Clients should read a student's mark from there and reserve this endpoint for the teacher's roster view.
Response
grades[], ordered by graded_at DESC:
id
SessionClassworkGrade.id (UUID)
user_id
The graded student
upload_id
The upload that was graded, or null when the student submitted nothing
score
0.0–10.0, or null for feedback-only grading
feedback
Teacher's comment, or null
graded_by
User id of the reviewer who graded
graded_at
Unix timestamp
Rate Classwork
POST /trainings/onsite/rate_classwork.json
Grade one student's homework.
session_id
string
Yes
TrainingSession.id
user_id
int
Yes
The student being graded
score
decimal
No
0–10, one decimal. Rounded to one decimal server-side (7.46 → 7.5). Empty clears a previously set score
feedback
string
No
Comment shown to the student
Access. Reviewer only — the session's or subject's teacher, or a manager (user_group_id <= 140 — Crew Scheduling and above). A non-reviewer gets 404 Not authorized to grade this class, the same response as a session that does not exist, so a caller cannot probe for sessions in another company.
Guards. 400 Score must be a number between 0 and 10 for a non-numeric or out-of-range score (validated before any write, so an invalid value never reaches the decimal(3,1) column). 400 A grade needs a score or feedback when both are empty. 404 Student is not on this session roster when the user_id has no session_students row on this session.
Upsert. Keyed on the roster row (session_classwork_grades.session_student_id is UNIQUE), so re-grading overwrites and two reviewers saving at once cannot produce two grades. upload_id is re-resolved on every save to whatever active SessionClasswork upload the student currently has, or null.
Side effect — student notification. Every successful save sends the student a non-urgent in-app message (no push, no email) with the training, session, score and feedback, linking to the class page. A re-grade notifies again. Notification failure is logged and does not fail the request.
Response
Justifications
GET /trainings/onsite/justifications/{sessionId}.json
List absence-justification rows for a session, scoped by role.
Access. Company-scoped (404 Training Session not found). A student who is not a reviewer sees only their own roster row's justification(s). A reviewer — the session's or subject's teacher, or a manager (user_group_id <= 140 — Crew Scheduling and above) — sees every justification for the session. A caller with no roster row on the session (and not a reviewer) gets an empty list rather than an error.
Response
justifications[], ordered by created DESC:
id
SessionJustification.id (UUID)
status
pending | approved | rejected
text
Student's free-text justification, or null
review_note
Reviewer's note, or null
reviewed_at
Unix timestamp, or null if not yet decided
upload_id
Linked SessionJustification upload id, or null
created
Unix timestamp
user_id, user_name
The justifying student
Submit Justification
POST /trainings/onsite/submit_justification.json
A student submits a justification for their own absence.
session_id
string
Yes
TrainingSession.id
text
string
No
Free-text justification
upload_id
string
No
Supporting document upload id
Guards. Caller must be on the session roster (session_students), else 404 Not on this session roster. May submit only when their own attendance_status is exactly absent (not absent_justified, not present, not NULL) and they have no pending justification already on this session, else 400 A justification cannot be submitted for this attendance. Enforced inside a row-locked transaction (SELECT ... FOR UPDATE on the session_students row) so two near-simultaneous submits cannot both create a pending row.
Response
Review Justification
POST /trainings/onsite/review_justification.json
Approve or reject a pending justification.
id
string
Yes
SessionJustification.id
decision
string
Yes
approved or rejected. Anything else → 400 Invalid decision
review_note
string
No
Max 255 characters (mb_strlen) — 400 Review note is too long (255 characters max) above that
Access. Restricted to a manager (user_group_id <= 140 — Crew Scheduling and above) or the session's/subject's teacher, else 404 Not authorized to review this justification. Company-scoped via the justification's session (404 Justification not found, same message as a genuinely missing id — a manager of another company cannot distinguish the two).
Re-decision is refused. The status flip is an atomic conditional update (pending → decision); a justification that is already approved/rejected cannot be re-decided — 400 This justification has already been decided.
Effect on approval. Approving sets session_students.attendance_status = absent_justified, unless the student already counts as present (e.g. credited attended_post_class after submitting but before review) — in that case the justification is still recorded as approved, but the attendance status is left unchanged rather than downgraded. Rejection never changes attendance_status. For a LESSON activity, activity_progress.value is recomputed from all sibling sessions of the activity afterward, same as store_attendance/sign_class; EXAM activities are skipped (their value is the teacher's grade). All writes (justification status, session_students, activity_progress) happen in one transaction.
Response
Record Justification
POST /trainings/onsite/record_justification.json
A teacher or training manager files an absence justification on a student's behalf, after the fact — e.g. a medical note handed in at the office. The reviewer is the one vouching for the evidence, so the justification is stored already approved and the student's attendance becomes absent_justified immediately.
session_id
string
Yes
sessions.id
user_id
int
Yes
The absent student (must be on the session roster)
upload_id
string
No*
An active SessionJustification upload on the same session and company — upload it first (see Session Uploads). Anything else → 400 Invalid justification document
text
string
No*
Free-text note
* At least one of upload_id / text is required, else 400 A justification needs a document or a note.
Access. Same reviewer rule as Review Justification: a manager (user_group_id <= 140 — Crew Scheduling and above) or the session's/subject's teacher, else 404 Not authorized to justify this absence. Company-scoped via the session (404 Training Session not found). A user_id with no roster row → 404 Student is not on this session roster.
Guards. The student's attendance_status must be exactly absent, else 400 Only an unjustified absence can be justified (read under a SELECT ... FOR UPDATE lock on the session_students row). A pending student submission does not block it and is left pending — deciding it later never downgrades absent_justified.
Effect. Inserts a session_justifications row with status = approved, reviewed_by = caller, reviewed_at = now, then applies the same attendance write as an approval in Review Justification (session_students + activity_progress recompute for LESSON activities), all in one transaction.
Response
Session Uploads
Classwork submissions and absence-justification evidence go through the generic upload endpoints (see uploads.md), tagged with one of two reserved Upload.model values: SessionClasswork and SessionJustification. Both are keyed on Upload.foreign_key = sessions.id — not the student — with the uploader identified separately via Upload.user_id.
Never re-tagged. Once a row carries either tag its model/foreign_key are frozen for everyone (POST /uploads/confirm/{id}.json → 403 This file can not be reassigned.).
Delete rules. GET /uploads/delete/{id}.json → 403 This file can not be deleted. unless one of:
Caller
SessionClasswork
SessionJustification
Reviewer — session teacher, subject teacher, or manager (user_group_id <= 140 — Crew Scheduling and above)
Always
Always
The student who submitted the row
Until sessions.classwork_deadline passes or the work is graded, whichever is first
Until sessions.justification_deadline passes or an approved/rejected justification covers the file (reviewed_at >= Upload.created), whichever is first
Anyone else
Never
Never
A student replaces their homework by deleting it and uploading again. Two independent locks close that window, either one being enough: the deadline passing, and a grade existing for that student on that session (any row in session_classwork_grades for their roster row). A grade locks the file even inside the window — replacing it afterwards would leave the teacher's mark attached to work nobody graded. A reviewer can still delete a graded file; redoing the grade is theirs to do. Absence evidence follows the same shape so a student can take back a file uploaded by mistake: locked by the justification deadline, or by any decided justification made after the file was uploaded.
GET /uploads/index/SessionJustification/{sessionId}.json adds can_delete (bool) to every row — whether delete would accept it for the caller.
Justified absence follows its evidence. When a SessionJustification file is deleted and it was the student's last one on the session (files linked to them via session_justifications.upload_id, or uploaded by them and linked to nobody else), a session_students.attendance_status of absent_justified goes back to absent; any other status is left alone. The delete response carries attendance_reverted (bool). Any session_justifications.upload_id pointing at the deleted file is set to NULL. When the attendance is reverted, the student's approved justification rows on the session become status = withdrawn with withdrawn_by / withdrawn_at (migration 2026-09-29-session-justification-withdrawn.sql); GET /trainings/onsite/justifications/{sessionId}.json returns withdrawn_at and withdrawn_by_name on each row. The reviewer lookup ignores the ownership narrowing that normally restricts user_group_id > 170 (Captain and below) to their own rows, so a session teacher who is themselves a line-pilot account can delete a student's submission rather than silently getting {"result": false} with HTTP 200.
Write gate. The uploader must be on the session roster (session_students.user_id), else 403 Uploads are not enabled for this session. SessionJustification has no further condition — several pieces of evidence for one missed class are ordinary — and is also open to reviewers (the session's/subject's teacher, or user_group_id <= 140), who upload evidence on a student's behalf and link it with Record Justification. SessionClasswork additionally requires:
sessions.allow_classwork_upload = truefor that session, andno active
SessionClassworkupload of their own on that session — homework is one file. A student holding a submission must delete it first (subject to the deadline above); a second upload is403 Uploads are not enabled for this session.
The deadline does not gate uploading: a student who submitted nothing may still submit after it, and that submission arrives flagged late (see Late submissions below) rather than refused.
Enforced on every path that can attach one of these tags: POST /uploads/sign.json (before the presigned S3 PUT is issued — gating at complete() would be too late, the object is already in S3 by then), POST /uploads/create.json, and POST /uploads/confirm/{id}.json. POST /uploads/complete/{id}.json re-runs the same gate at activation: the one-file limit counts active rows, so two sign() calls made before either completed would both have seen zero. A row refused there is deleted along with its S3 object.
Staff-only class files (SessionStaff). A third session-keyed tag, Upload.model = SessionStaff, Upload.foreign_key = sessions.id, for files only the class's staff may see. Staff = reviewer, same definition as above: the session's teacher, the subject's teacher, or user_group_id <= 140.
Upload (sign / create / confirm / complete)
Yes
403 Uploads are not enabled for this session
List (GET /uploads/index/SessionStaff/{sessionId}.json)
Every row
Own rows only — in practice none, since they can't upload
Open (download / proxy)
Yes
404
Delete / rename / expiration
Yes, any row, whatever their group
403 (delete) / 404 (rename, expiration)
Unlike the two submission tags, SessionStaff rows are ordinary files: not immutable, and may be re-tagged by whoever may confirm them. The legacy LessonClass tag (alias of Session) remains the class's student-visible attachments.
Exam result evidence (ExamAttempt). Upload.model = ExamAttempt, Upload.foreign_key = exam_attempts.id — files attached to a manually recorded onsite exam result (scanned answer sheet, examiner's report). Management only: managers (user_group_id <= 135, Trainings Manager and above) and the Chief Pilot (user_group_id = 150). Instructors, the student and every other group are refused, and so is an attempt whose enrolment belongs to another company.
Upload (sign / create / confirm / complete)
Yes
403 Uploads are not enabled for this session
List (GET /uploads/index/ExamAttempt/{attemptId}.json)
Every row
404
Open (download / proxy)
Yes
404
Delete / rename / expiration
Yes
403 (delete) / 404 (rename, expiration)
POST /manager/trainings/onsite/store_exam_result.json returns exam_attempt_id alongside the ActivityProgress fields — the attempt it created or edited (null for a legacy pre-history grade), which is the foreign_key to attach files to. GET /manager/trainings/students/view/{enrollmentId}.json adds files (active upload count) to every subject-level Exam[].ExamAttempt[] row, for managers and the Chief Pilot only; other callers get no files key.
Late submissions. GET /uploads/index/{model}/{foreignKey}.json adds two fields to every row carrying one of these tags: deadline (the governing classwork_deadline / justification_deadline, or null) and late (true when the row's created second is after it). Both are derived per request, never stored — a teacher who extends the deadline afterwards un-flags the submissions the extension now covers. Nothing about late refuses or restricts anything; it is a label.
Uploader identity. The same listing returns created (unix, from the row) and user_name (the uploader's full name, or null when the user no longer resolves) on every upload row, for all tags — so a roster's submissions can be attributed without a second request.
Read scope. Enforced on GET /uploads/index/{model}/{foreignKey}.json, GET /uploads/download/{id}.json and GET /uploads/proxy/{id}.json. A plain student sees only their own uploads for the session. The session's own teacher, the subject's teacher, or a manager (user_group_id <= 140 — Crew Scheduling and above) see every upload on the session. Company-scoped: a session belonging to another company (or missing) is refused the same way as a genuinely absent one — 404 Training Session not found from index, plain 404 from download/proxy — so a caller can't distinguish "wrong company" from "doesn't exist".
Student Evaluation
Per-student evaluation for an onsite class session. Backed by activity_progress (one row per (trainings_user_id, training_activity_id)) plus two child tables:
user_training_objectives— qualitative objective ratings (UserTrainingObjective).user_training_metrics— competency/metric ratings, optional free-text comments (UserTrainingMetric).
Both child tables are keyed to the parent ActivityProgress.id (objectives via training_subject_attendance_id, metrics via user_training_attendance_id).
Get Evaluation
GET /trainings/onsite/get_student_evaluation/{classId}/{userId}.json
Fetch the attendance row plus stored objective and metric ratings for a student in a class. Creates the attendance row lazily on first call if the student is enrolled in the training.
URL params
classId
string
Yes
TrainingSession.id
userId
string
Yes
Student User.id
Auth / scope
Caller's
company_idmust matchTraining.company_id.Training.activemust betrue.
Response 200
Errors
400
Missing url params / Invalid url params
classId or studentId null/empty
404
Training Session not found
No TrainingSession with that id
404
Training not found or inactive
Training inactive or belongs to another company
404
Student not enrolled in training
No TrainingsUser row and no existing attendance
Save Evaluation
POST /trainings/onsite/save_student_evaluation.json
Persist evaluation fields and replace the student's objective / metric ratings for a class. Setting ActivityProgress.value = 1 happens automatically whenever at least one objective or metric row is saved.
Auth / scope
Caller's
company_idmust matchTraining.company_id.Training.activemust betrue.The
ActivityProgressrow for(class_id, student_id)must already exist — callget_student_evaluationfirst to create it lazily.
Body (application/x-www-form-urlencoded)
class_id
string
Yes
TrainingSession.id
student_id
string
Yes
Student User.id
remarks
string
No
Free-text evaluation remarks
measures
string
No
Free-text corrective measures
objectives
object
No
Map objective_id → rating. Sending the key clears existing rows and re-inserts non-empty entries. Empty rating skips that row.
metrics
object
No
Map metric_id → rating (string) or metric_id → { rating, comments? }. Sending the key clears existing rows and re-inserts non-empty entries. Allowed ratings: -, STD, +, 1–5, N, I, C, E.
Example body
Response 200
Per-field semantics for objectives and metrics in the response:
null— key was not sent in the request.false— key was sent but all entries had empty ratings (rows were cleared, nothing inserted).array — saved rows (output of
saveAll).
Errors
400
Invalid request type
Non-POST request
400
No data received
Empty body
400
Missing POST params / Invalid POST params
class_id or student_id missing/empty
404
Class not found
No TrainingSession with that id
404
Training not found or inactive
Training inactive or belongs to another company
404
Attendance record not found
No ActivityProgress row for (class_id, student_id) — call get_student_evaluation first
Exams (ONLINE + ONSITE)
The exams table now holds both ONLINE and ONSITE exams. Each row carries:
typeenum:ONLINE(auto-graded viaexam_attempts) orONSITE(graded after a session viaactivity_progress).Scope = where the exam attaches:
training_subject_lesson_id NOT NULL→ lesson-gate (gates that lesson; not a counted activity).training_subject_id NOT NULL,training_subject_lesson_id NULL→ subject-scope (counted activity).both NULL → training-scope (counted activity).
Every non-lesson-gate exam has a corresponding training_activities row so it participates in drag-drop ordering and session scheduling.
Exam Info
GET /trainings/exams/index/{filter}:{id}.json
List every exam matching a single scope filter. Returns all matching exams (not just one), each decorated with an available flag.
{filter} (exactly one, in this precedence):
exam:{exam_id}
The single exam with that id.
lesson:{lesson_id}
All lesson-gate exams attached to that lesson.
subject:{subject_id}
All subject-scope exams (training_subject_lesson_id IS NULL).
training:{training_id}
All training-scope exams (subject and lesson both IS NULL).
Auth / scoping:
Training.company_idmust match the caller's company.Exam.deleted = false(soft-deleted exams never returned).Training.activeis not enforced — exams on archived trainings are returned so managers can still maintain them. The per-rowavailableflag reflects archived state.
Each Exam row carries available: true|false. available is false when any of: exam expired, exam deleted, training not active, or the question bank holds fewer questions than Exam.questions (insufficient pool to compose an attempt).
Access mode (online exams)
Online exams carry access_mode:
FREE(default) — current behaviour: takeable freely once the prerequisites above are met.SCHEDULED— gated behind a scheduled session.availableadditionally requires an active session for this exam and the requesting student, where:the session's
TrainingActivitypoints at this exam (kind=EXAM,exam_id=this), andthe requesting student is on the session roster (
session_students), andthe session is not signed, and
Session.datetime <= now <= Session.datetime + access_window_days * 86400.
All the FREE checks (expiration, deleted, active, question pool) still apply —
availablerequires passing all of them. When several sessions exist (re-scheduled), the most recent in-window unsigned session wins.access_window_daysdefaults to10.
available_until (online exams): the YYYY-MM-DD HH:MM:SS timestamp when the current student's window closes — min(session.datetime + access_window_days, session signed-at). null for FREE exams or when there is no active session.
TrainingQuestion is included with TrainingSubject.name, TrainingSubject.code, Lesson.name per question. For user_group_id <= 135 (Trainings Manager and above) each question also includes TrainingQuestionOption (the answer options). For user_group_id > 150 (Flight Instructor and below) the TrainingQuestion list is stripped.
Errors:
400 BadRequest— no filter provided.404 NotFound— no exam matches.
Response shape:
Manage Exam Questions
Exam questions are a shared bank: a single TrainingQuestion can be linked to many exams through the join table exams_questions. Editing a question changes it in every exam that uses it, and deletion is scoped so cleaning up one exam never destroys another's questions. Both endpoints are manager-scoped (/manager/…).
Create / Update Question
POST /manager/trainings/questions/create.json
Create a new question (and link it to an exam) or update an existing one. Content-Type: application/x-www-form-urlencoded, Cake nested-array field names.
data[Exam][id]
string
On create
Exam to attach a new question to. On edit (see below) it is used only to resolve the question's subject — the question is never re-linked, so a shared question keeps all its other exam links.
data[TrainingQuestion][id]
string
No
Present = edit an existing question; absent = create.
data[TrainingQuestion][name]
string
Yes
Question text.
data[TrainingQuestion][required]
0|1
No
Whether the question must be answered.
data[TrainingQuestion][TrainingQuestionOption][i][id]
int
No
Option id (present = keep/update, absent = new).
data[TrainingQuestion][TrainingQuestionOption][i][name]
string
Yes
Option text.
data[TrainingQuestion][TrainingQuestionOption][i][value]
0|1
No
1 marks the correct option. At least one option must be correct.
Lesson-slide question variant: send data[LessonSlide][training_subject_lesson_id] (and data[TrainingQuestion][training_subject_lesson_id]) instead of data[Exam][id] to create/update a question that lives on a lesson slide.
Response: { "result": true, "question": { "TrainingQuestion": { "id": "…" } }, "slide": … }.
Delete Question
POST /manager/trainings/questions/delete.json
Scoped, non-destructive removal. Nothing is hard-deleted — options, historical attempt answers and the row itself are always preserved.
id
string
Yes
TrainingQuestion UUID.
exam_id
string
No
When present, unlink the question from this exam only (removes the exams_questions row). Omit it in a lesson context to remove the question's slide instead.
Behavior:
With
exam_id— the exam↔question link is removed. If the question is still linked to any other exam (or a lesson slide), the question row is left fully intact and stays live in those exams.Without
exam_id— the lesson slide surfacing the question is removed.The underlying
TrainingQuestionis soft-deleted (deleted=1, hidden everywhere viabeforeFind, recoverable) only once nothing references it — no remaining exam links and no lesson slides. A shared question is never destroyed by deleting it from one exam.
Response: { "result": true }.
Preview Exam
GET /trainings/exams/start/{enrollmentId}/{examId}.json
Preview exam details and previous attempts before starting.
Start Exam
POST /trainings/exams/start/{enrollmentId}/{examId}.json
Begin an exam attempt.
Scheduled exams (server-side enforcement). For an online exam with access_mode=SCHEDULED, starting a new attempt requires an active, in-window, unsigned session the student is rostered on (same condition as available above). Otherwise the request is rejected with:
403 Forbidden— "This exam is only available from your scheduled class during its access window."
This is the real gate: it blocks a direct API hit even when the UI hides the button. An already-open attempt (started while the window was open) may still be resumed and submitted after the window closes — only new attempts are blocked. The attempts cap and expiration still apply within the window.
Response
Take Exam
GET /trainings/exams/do/{attemptId}.json
Retrieve exam questions for an active attempt.
Submit Answer
POST /trainings/exams/answer.json
Submit an answer to an exam question.
attemptDetailId
string
Yes
ExamAttemptDetail ID
questionOptionId
string
Yes
Selected option ID
Finish Exam
GET /trainings/exams/finish/{attemptId}.json
Submit the exam for grading. Grades the attempt, then syncs ActivityProgress for the exam activity (value=best passed, score=best score) via recordExamAttempt.
Scheduled exams — auto-record into attendance. For an online exam with access_mode=SCHEDULED, finishing also links that ActivityProgress row to the gating session (session_id) and sets session_students.attended=1. The teacher's session grade view (attendance()) then shows the auto-graded result directly — exam_status = value, exam_rating = score — with no manual entry. (Re-scheduling the session clears its session-scoped progress as usual; the aggregate is rebuilt from exam_attempts on the next finish.)
View Result
GET /trainings/exams/view/{attemptId}.json
Retrieve exam results and (when enabled) correct answers.
Students Management
List Students
GET /manager/trainings/students/list/training:{trainingId}/status:{status}/group:{groupId}/pilot_group:{pilotGroupId}/username:{search}.json
Retrieve students with filtering. All filter parameters optional (use empty string to skip).
Access: staff user_group_id <= 135, Chief Pilot (150), Flight Instructors (170) and auditors (250). Crew Scheduling (140) and Flight Dispatcher (145) are ACL-denied. Flight Instructors (170) can read this list, /manager/trainings/students/index.json and /manager/trainings.json (the course list) but are ACL-denied every mutating Students/manager_* action (enroll, edit, finish, status, reset, unroll), which stay <= 135 / Chief Pilot. Any other group gets 403 ACL_DENIED.
status accepts one of ACTIVE (default when omitted), COMPLETED, STOPPED, FAILED, EXPELLED, or ALL to return every enrollment regardless of status. Anything else returns 400 Unknown status. Each row carries TrainingsUser.status, TrainingsUser.status_reason and TrainingsUser.status_changed.
Breaking change. This filter replaces the old
finished:{true|false}segment, andTrainingsUser.finishedno longer appears in any response — thefinishedcolumn was dropped in favour ofstatus. See Enrollment status.
View Student
GET /manager/trainings/students/view/{enrollmentId}.json
Retrieve full student enrollment details.
Suggested completion date
TrainingsUser.completion_suggested_at (unix seconds, or null) is the date the records say the student actually finished — the latest of their last completed activity, last passed exam and last completed flight mission, clamped between the enrollment date and now. It is derived on every read, never stored, and is there to prefill the completion-date field of a manual force-complete (POST /manager/trainings/students/finish/{enrollmentId}.json, field completed_at). null means nothing has been recorded for this student yet, in which case a client should default the field to today.
Attendance Status Breakdown
For a non-DISTANCE training, the response includes a top-level training.AttendanceStatus object — per-status session counts for this student in this training, sourced from Training::getAttendanceStatusBreakdown($trainingId, $userId). It is keyed by users.id, not the enrollment (TrainingsUser.id) id — attendance status lives on session_students, which is keyed by user, and a roster row can exist without any enrollment at all.
Five integer keys, always all present:
attended
Sessions where session_students.attendance_status = 'attended'
attended_post_class
Sessions credited attended_post_class (late/post-class submission)
absent
Sessions marked absent
absent_justified
Sessions marked absent_justified (approved justification)
unmarked
Sessions whose attendance_status is still NULL — not yet decided (attendance not signed)
Counts only sessions under a non-deleted training_activities row (training_activities.deleted = 0).
DISTANCE trainings. training.AttendanceStatus is omitted from this response entirely when Training.type = 'DISTANCE' (attendance-by-session doesn't apply — distance trainings track getProgress instead).
Same object, student report endpoint. GET /trainings/students/report/{enrollmentId}.json returns the identical breakdown nested at training.Training.AttendanceStatus — explicitly null (rather than omitted) for a DISTANCE training. Access is decided by ReportVisibility::level() (see Training Certificate below): the student, their supervisor and same-company staff (user_group_id <= 170 — Flight Instructor and above) receive the full report with training.verification: false; anyone else — including an anonymous caller, the endpoint is public — receives the verification subset with training.verification: true.
Homework record
For a non-DISTANCE training, the response also includes training.Homework: every class that requested class work (sessions.allow_classwork_upload = 1, not canceled) with this student on its roster, oldest first. It comes from Training::getHomeworkRecord($trainingId, $userId) and, like AttendanceStatus, is keyed by users.id.
submitted/submitted_at: the student's earliest activeSessionClassworkupload for that class.latecompares it todeadline(nulldeadline = never late).scoreisnullwhen the work is ungraded or was graded with a comment only.summary.averageaverages the numeric scores only, and isnullwhen there are none.graded_byis the grader's display name.
The report endpoint returns the same object at training.Training.Homework, explicitly null for a DISTANCE training, under the same ReportVisibility rules (the verification subset has no Homework).
Reset attempts in the response
For lessons whose gate online-exam has been reset (see POST /trainings/lessons/reset.json), the response exposes the reset boundary and every ExamAttempt is annotated with a reset boolean so the UI can group pre-reset attempts visually.
Surfaced fields:
training.TrainingSubject[].TrainingSubject.Lesson[].Exam[].TrainingActivity.ActivityProgress[0].reset_at— unix timestamp of the most recent reset, ornullif never reset.training.TrainingSubject[].TrainingSubject.Lesson[].Exam[].ExamAttempt[].reset—trueifExamAttempt.start <= reset_at(the attempt belongs to a pre-reset cycle and didn't count toward the current limit),falseotherwise.training.TrainingSubject[].TrainingSubject.Exam[].TrainingActivity.ActivityProgress[0].reset_atand.ExamAttempt[].reset— same annotation for subject-level exams (subject-level exams are not currently reset, but the field is populated for symmetry).
Example slice:
In this example three pre-reset attempts (att-1..3) maxed out the limit; the student called lessons/reset.json at 1778600000 and is now mid-way through att-4 — only that one counts toward the current attempts_count and is what the next recordExamAttempt will aggregate.
Enroll Students
POST /manager/trainings/students/enroll.json
Enroll one or more students (or whole pilot groups) in a training. Sent as application/x-www-form-urlencoded or JSON.
Request Body
training_id
string
Yes
Training UUID. Must belong to the authenticated user's company.
students
array
Yes
Array of identifiers. Numeric entries are treated as user IDs. Non-numeric entries are treated as pilot group IDs and expanded server-side to all member users.
supervisor_id
string
No
UUID of the supervisor/instructor assigned to each new enrollment.
finish_before
integer
No
Deadline as a Unix timestamp. Cast to int; falsy values mean "no deadline".
Behavior
A student already enrolled in the training is skipped, unless their prior enrollment is closed — any
statusother thanACTIVE, i.e.COMPLETED,STOPPED,FAILEDorEXPELLED— in which case a fresh enrollment is created (re-take).Pilot group entries are expanded to their members; members already enrolled are skipped.
For each successful enrollment on an
activetraining, an in-app notification is sent to the student linking to the enrollment.
Response
A map keyed by user ID, with the new enrollment ID (TrainingsUser.id) as the value — one entry per newly-created enrollment.
The object has no wrapper key; the user-ID map is the response root.
Only newly-enrolled students appear. Students that were already enrolled (and not re-taking) are omitted entirely — they are not returned as
falseor any other marker. An empty object{}means nothing new was enrolled.Pilot group IDs in the request are expanded server-side, so the keys are always individual user IDs.
Count the entries to report how many students were enrolled.
Errors
400 Bad Request
Not a POST, empty body, missing training_id, or students is not an array.
404 Not Found
students array is empty, or training_id does not match a training in the user's company.
Save Student Notes
POST /manager/trainings/students/notes.json
Update notes for a student enrollment.
Enrollment status
trainings_users.status is the single source of truth for where an enrollment stands. It replaced the old boolean trainings_users.finished column, which has been dropped — finished no longer appears in any response payload, and the students-list URL filter is now status: instead of finished:.
ACTIVE
The student is training. The only status that counts as an enrollment in progress.
COMPLETED
Training passed. Stamps TrainingsUser.validity and unlocks the certificate endpoint.
STOPPED
Closed without completing — the student quit or abandoned the course.
FAILED
Closed — the student did not pass.
EXPELLED
Closed — the school removed the student from the training.
STOPPED, FAILED and EXPELLED are the non-destructive alternative to unrolling a student: every lesson, exam attempt and flight mission stays on record. Unrolling (POST /manager/trainings/students/unroll/{id}.json) still deletes all of it.
What ACTIVE gates
Only ACTIVE enrollments:
appear in the default students list, in the student's own "in progress" trainings, and in the pilot profile's ongoing trainings;
can be written to by the student —
POST /trainings/lessons/complete.jsonandPOST /trainings/exams/start/...return403 This enrollment is closedotherwise;auto-complete. A closed enrollment is skipped by
TrainingsUser::checkTrainingFinishedand never getsvaliditystamped;are matched by supervisor auto-assignment when scheduling a training flight;
block a re-enrollment. Enrolling a student whose only enrollment is closed creates a fresh one (re-take).
Certificate state on the enrollment views
Both enrollment views carry the certificate's state, so a client can decide what to offer without calling the certificate endpoint — which would issue one.
GET /manager/trainings/students/view/{enrollmentId}.json
response root, Certificate
{ id, number, status, issued_at, revoked_at, revoke_reason } or null
GET /trainings/trainings/view/{enrollmentId}.json
under training, Certificate
{ number, status, issued_at } or null
null means nothing has been issued yet, which for a student is still offerable: their own download issues it. PREVIEW and REVOKED are refused to them, so hide the button rather than let it 403. The manager view carries the id because that is what the three lifecycle endpoints above take.
Status history
Every transition is appended to trainings_user_status_changes and returned as StatusChange (oldest first) by both GET /manager/trainings/students/view/{enrollmentId}.json (response root) and GET /trainings/trainings/view/{enrollmentId}.json (under training):
trainings_users keeps only the current status — this is the full log, so a student stopped and later reopened shows both events. created is unix seconds, changed_by_name is resolved from user_details. Rows are never updated. Enrollments closed before the table existed were seeded with a single row carrying their current status.
Every path that opens, closes or reopens an enrollment writes a row:
Path
Status
Reason
changed_by
POST /manager/trainings/students/enroll.json
ACTIVE
Enrolled
the manager
POST /trainings/students/restart/{id}.json
ACTIVE
Training restarted by the student
the student
POST /manager/trainings/students/status/{id}.json
as requested
the manager's free text
the manager
POST /manager/trainings/students/finish/{id}.json (and /undo)
COMPLETED / ACTIVE
—
the manager
POST /manager/trainings/students/reset/{id}.json
ACTIVE, only when the enrollment was closed
Progress reset
the manager
Auto-completion (TrainingsUser::checkTrainingFinished)
COMPLETED
Automatic completion
null — no acting user, it fires from the progress hooks
changed_by and changed_by_name are null for system-made changes. The first row of an enrollment is always its creation: a reopen can only follow a closing row, so an ACTIVE row with nothing before it means the enrollment was opened, and clients render it as the signup event.
POST /manager/trainings/students/unroll/{id}.json deletes the enrollment together with its history — unrolling is the destructive path, stopping is the one that keeps a record.
Set Enrollment Status
POST /manager/trainings/students/status/{enrollmentId}.json
Close or reopen an enrollment without deleting any progress. Sent as application/x-www-form-urlencoded.
Request Body
status
string
Yes
ACTIVE, COMPLETED, STOPPED, FAILED or EXPELLED. May also be passed as a second URL segment (.../status/{enrollmentId}/STOPPED.json).
reason
string
No
Free text, max 255 chars. Stored in TrainingsUser.status_reason and included in the student's notification.
completed_at
int or string
No
COMPLETED only: the date the student actually finished, as unix seconds or YYYY-MM-DD (a date-only value is anchored at 12:00 UTC). Omitted, the server derives it from the recorded progress — the latest of the last completed activity, passed exam and completed mission — and falls back to now when nothing was ever recorded. Also accepted as completion_date. The same field is accepted by POST /manager/trainings/students/finish/{enrollmentId}.json.
Access
managerprefix:user_group_id > 190— Student Pilot and below (and below 250) is rejected by the plugin, same as every other manager trainings action. External auditors (250) have no ACL grant on this action and cannot call it.The enrollment's training and the student must both belong to the caller's company.
ACL grants mirror
manager_finishexactly: allowed for Company Administrators, Trainings Managers and Chief Pilots (user_group_id100/135/150), denied for Crew Scheduling and Flight Dispatchers (140/145) and for every student/pilot group (170/190/200/300).
Side effects
Writes
status,status_reason,status_changed(unix) andstatus_changed_by(user id), appends the change toTrainingsUser.notes, and inserts atrainings_user_status_changesrow (see Status history).COMPLETEDstampsstatus_changedwith the completion date — supplied, derived or now — and writesTrainingsUser.validityas that date plusTraining.validitydays (null when the training has no validity period).status_changedis the completion date the certificate is issued for;modifiedis only the row's last edit and must not be read as a finish date.ACTIVE(a reopen) clearsvalidity; the terminated statuses leave it alone.Notifies the student, worded per status — completion keeps the original congratulations message, the closing statuses say what happened and quote
reasonwhen given, andACTIVEannounces the enrollment was reopened. The message is flagged urgent, so on top of the in-app message and push notification the student is emailed, provided their address is confirmed anduser_credentials.alerts = 1. Emails count against the company's send quota.No progress row is created, modified or deleted.
Response
Errors
400 Bad Request
Not a POST, missing enrollment id, a status outside the five allowed values, or a completed_at that is unparseable, in the future, or earlier than the enrollment date.
404 Not Found
Enrollment not found, or training/student outside the caller's company.
Training Calendar
GET /trainings/trainings/calendar.json
Retrieve training-related calendar events.
Query Parameters
start
string
Yes
Start date
end
string
Yes
End date
timeZone
string
Yes
IANA timezone
training
string
No
Filter by training ID
Training Certificate
GET /trainings/trainings/certificate/{enrollmentId}.json
Retrieve training completion certificate data. Gated strictly on TrainingsUser.status = 'COMPLETED'. If the auto-completion mechanism (below) hasn't fired, this endpoint returns 404 Training not finished yet regardless of how complete the activities look.
Access (403 otherwise): the student, the enrollment's supervisor_id, or a user with user_group_id <= 170 (Flight Instructor and above) of the same company as the training. Managers of other companies are refused. This is the ReportVisibility::level() rule (app/Lib/Trainings/ReportVisibility.php), shared with the student report.
Issuing. The first successful call issues the certificate: a sequence number is reserved atomically on company_details.certificate_next_number, formatted with company_details.certificate_pattern / certificate_prefix (tokens {prefix} {year} {seq} {seq:N}; see CertificateNumber), and a training_certificates row is written with a JSON snapshot of every printed field. Every later call returns that row unchanged — number and issued_at never move. training_certificates.trainings_user_id is unique (one certificate per enrollment); a lost race burns a sequence number, never duplicates one.
Previewing before issue
?preview=1 creates the row with status: "PREVIEW" instead of LIVE, so the document can be rendered and checked before it counts.
The flag is honoured only for user_group_id <= 135 (Trainings Manager and above) of the training's own company. Every other caller — the student, their supervisor, an instructor, a manager of another company — is served the normal path and issues LIVE, flag or no flag. That is what keeps the automatic case intact: a DISTANCE student who finishes on their own and downloads still gets a real certificate, exactly as before.
The flag is a no-op once a row exists. Previewing again returns the same record, which is why issuing later keeps the reserved number rather than allocating a fresh one.
Status and who may download what
TrainingCertificate.status
Meaning
Student / supervisor
Same-company staff (<= 170)
PREVIEW
Created for checking, never issued. No issued_at is printed.
403
200
LIVE
Issued, untouched.
200
200
MODIFIED
Issued, then a printed field was corrected. Identical behaviour to LIVE.
200
200
REVOKED
Withdrawn. Read-only — certificate_edit refuses it.
403
200
The 403 body is {"message": "This certificate is not available"}. It is decided on the stored status, not on the client: hiding the button is a courtesy, the refusal is the rule.
NEO reflects the same statuses in the PDF — a diagonal PREVIEW — NOT ISSUED watermark with no date of issue, or a red REVOKED struck through the page with the withdrawal date — and puts PREVIEW_ / REVOKED_ in the filename.
Response additions
TrainingCertificate
{ id, number, sequence, issued_at, issued_by, trainings_user_id, training_id, user_id, status, issued_by_preview, edited_at, edited_by, revoked_at, revoked_by, revoke_reason, snapshot }. snapshot = { locale, number, issued_at, status, student{name,surname,dob,licence}, organisation{name,legal_name,address,zip,city,country,approval_type,approval_reference}, course{id,name,type,regulatory_basis,ground_hours,flight_hours,started,completed,valid_until}, signer{user_id,name,surname,position} }. NEO renders the PDF from the snapshot.
Training.regulatory_basis
Effective value: trainings.regulatory_basis, else company_details.regulatory_basis, else null.
Training.manager_position
Free text printed under the signer; null → "Head of Training".
Training.ground_hours
Planned theory hours: sum of training_subjects.hours (not deleted).
Training.flight_hours
Hours actually flown: block time of CONFIRMED flights on the enrollment's completed user_training_flights, one flight counted once, /3600, 2 dp.
Training.Company.locale
companies.locale; the certificate is rendered in this language.
Training.Company.CompanyDetail.{legal_name, approval_type, approval_reference, regulatory_basis, zip}
Organisation block. certificate_prefix / certificate_pattern are not returned.
User.licence
user_certificates.name of the student's valid (status = valid, not expired) licence row, newest issue first; null when none. No passport / address / phone.
TrainingsUser.created
Enrollment date, printed as the course start.
Manager fallback: Training.manager_id may be NULL. When it is, Training.Manager is filled with the first company user in user_group_id ∈ (100, 105, 135) (Company Administrators, Operations Managers and Trainings Managers) that has a non-empty UserDetail.signature so the certificate always has a signatory. Returned shape matches the normal Manager: { id, UserGroup.name, UserDetail.{name, surname, signature} }. If no such user exists Training.Manager stays empty.
Issued Certificates (manager)
GET /manager/trainings/trainings/certificates.json?page=&training_id=&q=&status=
Every certificate the company has, newest first. Querystring pagination (page, 50 per page, maxLimit 500). training_id filters one course; q matches the certificate number or the student's name/surname (LIKE %q%; users live in flylogs_main, so matching students are resolved first and passed as user_id IN (...)); status is one of PREVIEW, LIVE, MODIFIED, REVOKED and anything else is ignored rather than rejected.
Rows carry the full record including snapshot, plus Editor and Revoker alongside Issuer, so a client can render the whole register and open an edit form without a second round trip.
Access: user_group_id <= 170 — Flight Instructor and above (403 otherwise). ACO controllers/Trainings/Trainings/manager_certificates — needs aco_sync; inherits the root allow.
Response
Certificate lifecycle (manager)
Three write endpoints, all POST, all restricted to user_group_id <= 135 (Trainings Manager and above) of the certificate's own company (403 otherwise, 404 when the id belongs to another company — the two are deliberately indistinguishable to a caller from outside). ACOs controllers/Trainings/Trainings/{manager_certificate_issue, manager_certificate_edit, manager_certificate_revoke} — need aco_sync; they inherit the root allow and no explicit grants are required, because the controller narrows access itself.
The register (manager_certificates) stays readable at <= 170. Reading a register is not the same act as rewriting a certified document.
Each returns { "result": true, "certificate": { …the updated row… } }.
Issue a preview
POST /manager/trainings/trainings/certificate_issue/{certificateId}.json
PREVIEW → LIVE. The number and the sequence are not reallocated — they were reserved when the preview was created, which is the whole point of previewing on the real record. issued_at is moved to now (the certificate is issued today, not on the day somebody looked at it) and issued_by is set to the caller. A certificate that is already LIVE or MODIFIED is returned untouched, so the call is idempotent.
Correct an issued certificate
POST /manager/trainings/trainings/certificate_edit/{certificateId}.json
The body is a fragment of the snapshot. Only these paths are read; anything else in the body is ignored, so number, sequence, issued_at and status cannot be rewritten from the client:
—
locale
student
name, surname, dob (YYYY-MM-DD), licence
organisation
name, legal_name, address, zip, city, country, approval_type, approval_reference
course
name, regulatory_basis, ground_hours, flight_hours, started, completed, valid_until
signer
name, surname, position
Coercion: the two *_hours are rounded to 2 dp (empty → 0); the three course dates are unix seconds (empty → null); everything else is trimmed, and an empty string becomes null so a blank input clears a field rather than printing the word "null". A signer block whose name, surname and position all end up empty is set to null outright — and, conversely, naming a signer on a certificate that was issued without one creates the block.
Only keys actually present in the body are touched, so a partial patch never blanks the rest of the certificate.
Status transition: LIVE → MODIFIED. PREVIEW stays PREVIEW — it has not been issued, so there is nothing to amend — and edited_at / edited_by are stamped.
A REVOKED certificate is refused: 400 A withdrawn certificate cannot be edited. A withdrawn certificate is a closed record; the register exists to show what was withdrawn, not a later rewrite of it. Issue a new certificate from the enrolment instead.
Withdraw a certificate
POST /manager/trainings/trainings/certificate_revoke/{certificateId}.json
reason is optional and truncated to 500 characters. Sets status: "REVOKED" plus revoked_at, revoked_by and revoke_reason.
The row and its snapshot are never deleted — the register has to keep showing that the number existed and was withdrawn, and by whom. Withdrawing a PREVIEW is a 400 (A preview has never been issued, so it cannot be withdrawn); delete the preview if that is what you want.
Student Report access and the verification payload
GET /trainings/students/report/{enrollmentId}.json is public (Auth->allow) because the certificate's QR code points at it. The response depends on the caller:
Caller
training.verification
Payload
The student, the enrollment's supervisor, or user_group_id <= 170 (Flight Instructor and above) of the training's company
false
The full report described above, plus training.TrainingCertificate: { number, issued_at, status, revoked_at, revoke_reason } | null.
Anyone else, including no Authorization header
true
training.TrainingsUser { id, training_id, status, status_changed, validity, created }, training.Training { id, name, type, Company { id, name, CompanyTheme{logo,color}, CompanyDetail{legal_name, approval_type, approval_reference, city, Country.name} } }, training.User.UserDetail { name, surname } masked (first letter of each word + * per remaining character: O******** K********), training.TrainingCertificate { number, issued_at, status, revoked_at, revoke_reason } | null. No exams, subjects, flights, passport, date of birth, address or phone.
Reading the report never issues a certificate; only the certificate endpoint allocates numbers.
The status travels with the number, on both variants. Without it a withdrawn certificate would keep verifying as genuine for ever, which is the whole reason the QR code points here. number is null when status is PREVIEW: the certificate has not been issued, so the public page must not confirm a number for it.
Auto-Completion
There are two boolean training-level flags that govern automatic completion behavior. Both default to 0.
Training.auto_finish
When 1, every write to ActivityProgress or UserTrainingFlight triggers a re-evaluation of the enrollment. If all activities AND all flight missions are complete, TrainingsUser.status is set to COMPLETED, TrainingsUser.status_changed is stamped with the completion date and TrainingsUser.validity with completion date + Training.validity * DAY (i.e. Training.validity is the certificate lifetime in days).
Training.allow_auto_restart
When 1, on a DISTANCE training, students can self-serve a lesson reset via POST /trainings/lessons/reset.json (see above) once they've exhausted lesson-gate exam attempts without passing.
Completion rule (used by TrainingsUser::checkTrainingFinished)
An enrollment auto-completes (status ACTIVE → COMPLETED) when all of the following hold:
The training's date window allows it:
Training.start <= today <= Training.end(NULL bounds are treated as open-ended).Training::getProgress(training_id, enrollment_id).finished == true— every mandatory activity hasActivityProgress.value = 1, and every mandatory lesson-gate exam has been passed.Flights — only when
Training.flights = 1. Then, for everyTrainingFlightrow attached to the training there is at least oneUserTrainingFlightrow withcompleted = 1scoped to this enrollment (this applies to every training type that has flight missions, not just DISTANCE). A training with zeroTrainingFlightrows passes this clause trivially, and so does one whose flight section is switched off: turningTraining.flightsoff does not delete thetraining_flightsrows, and those leftovers are shown nowhere and cannot be flown, so they no longer block completion.
If Training.auto_finish = 0, the check still works when called manually (e.g. admin recompute), but it is not invoked automatically on writes.
Frontend implications:
After a successful
POST /trainings/lessons/complete.json,POST /trainings/exams/finish.jsonor any UserTrainingFlight write, re-fetch/trainings/trainings/view/{enrollmentId}.jsonto see whetherTrainingsUser.statusflipped toCOMPLETED. Don't rely on a separate "did it finish?" call.The certificate endpoint only succeeds once
status = 'COMPLETED'is persisted — there is no "force compute" query string.Only an
ACTIVEenrollment can auto-complete. ASTOPPED/FAILED/EXPELLEDone is skipped and never gets itsvaliditystamped.Read the completion date from
TrainingsUser.status_changed, never fromTrainingsUser.modified:modifiedis the row's last edit and moves whenever anything touches the enrollment. Legacy rows completed beforestatus_changedexisted carrynull, which is the only case wheremodifiedis a reasonable fallback.Surface both flags (
Training.auto_finish,Training.allow_auto_restart) in the training detail view so the UI can decide whether to show a "Reset lesson" button and whether to expect automatic finishing.
Locations
List Locations
GET /trainings/locations.json
Retrieve all training locations.
View Location
GET /trainings/locations/view/{id}.json
Retrieve location details and scheduled classes.
Edit Location
POST /manager/trainings/locations/edit.json
Create or update a training location. Sent as application/x-www-form-urlencoded.
id
string
No
Location ID (omit to create new)
name
string
Yes
Location name
type
string
Yes
Location type
phone
string
No
Contact phone
address
string
No
Address
country_id
string
Yes
Country ID
timezone_id
string
Yes
Timezone ID
description
string
No
Description
Flight Missions
Unsigned Missions
GET /trainings/missions/unsigned.json
Returns all flight mission attendance records for the authenticated user that are missing a debriefing signature. Only includes missions from active trainings that have require_debriefing_signature enabled.
Response
Sign Mission Debriefing
POST /trainings/missions/sign.json
Digitally sign the debriefing for a completed flight mission. Requires the user's password for verification. Only the student who flew the mission can sign it. Signing is only possible while the training is active and within its start/end dates.
user_training_flight_id
string
Yes
UserTrainingFlight record ID
pass
string
Yes
User's account password
Response
Returns false if the record was already signed.
Manager Training Operations
List Trainings
GET /manager/trainings.json
List all trainings with student counts (admin view).
Create Training
POST /manager/trainings/trainings/create.json
Create a new training course.
name
string
Yes
Training name
type
string
Yes
Training type
theory
boolean
No
Has theory component
flights
boolean
No
Has flight component
competencies
boolean
No
Uses competency-based evaluation
template_id
string
No
Base on existing template
Training Templates
GET /manager/trainings/trainings/templates.json
List available training templates.
Teaching Report
POST /manager/trainings/trainings/teaching_report.json
Generate a teaching activity report.
training
string
Yes
Training ID
from
string
Yes
Start date
to
string
Yes
End date
Stage Checks
Ordered blocks of flight missions, each ending in a check the student must pass before flying a later stage.
Every endpoint below is inert unless the training has stage_checks = 1. Recording, overriding and voiding additionally require user_group_id <= 135 (Trainings Manager and above) or being the training's manager_id; a 403 is returned otherwise.
List stages
GET /manager/trainings/stages/index/{trainingId}.json
Returns the training's stages ordered by (order, id).
Create a stage
POST /manager/trainings/stages/add/{trainingId}.json
name
string
Yes
Stage name
code
string
No
Short code, e.g. S1
description
string
No
Free text
order
integer
No
Position, defaults to 0
check_training_flight_id
string
No
Mission nominated as this stage's check
Edit a stage
POST /manager/trainings/stages/edit/{id}.json
Accepts any of the fields above. Only the fields sent are changed.
Delete a stage
POST /manager/trainings/stages/delete/{id}.json
Soft-deletes the stage and detaches its missions (training_stage_id set to NULL). The missions themselves are not deleted.
Reorder stages
POST /manager/trainings/stages/reorder/{trainingId}.json
ids[]
string[]
Yes
Every stage id, in the wanted order
Positions are rewritten in one transaction, so no two stages are left sharing an order.
Record a stage check
POST /manager/trainings/stage_checks/record.json
trainings_user_id
string
Yes
Enrollment id
training_stage_id
string
Yes
Stage being checked
result
string
Yes
SAT or UNSAT
pass
string
Yes
The acting user's password — re-authentication for the signature
remarks
string
No
Free text
user_training_flight_id
integer
No
Mission the check was flown on
Results are append-only. A re-check is a new row with attempt + 1; the latest non-voided attempt decides the student's state.
The stored signature holds the signing user, timestamp, IP, browser and a keyed hash bound to that specific result, so a stamp cannot be altered or copied onto another record.
Override a stage
POST /manager/trainings/stage_checks/override.json
trainings_user_id
string
Yes
Enrollment id
training_stage_id
string
Yes
Stage to clear
reason
string
Yes
Why the student may proceed without a pass
pass
string
Yes
The acting user's password
Recorded as result = OVERRIDE. A request without a reason is rejected.
Void a result
POST /manager/trainings/stage_checks/void/{id}.json
void_reason
string
Yes
Why the result is being withdrawn
Results are voided, never edited. A voided result stays in the history and the previous result decides the state again. Voiding an already-voided result returns 400.
Stage status
GET /trainings/students/stage_status/{enrollmentId}.json
Readable by the enrolled student for their own enrollment, and by anyone who may manage the training.
results includes voided rows — the audit trail is the point.
This endpoint reports; it does not enforce. The refusal that actually stops a flight happens when the mission is written, and a blocked mission returns 400 with "This mission is blocked: an earlier stage check has not been passed."
Mission flags
POST /manager/trainings/missions/edit.json
data[TrainingFlight][tg] and data[TrainingFlight][crosscountry] (1/true or anything else) mark a mission as touch-and-go practice and as cross-country. Both are returned as booleans on every TrainingFlight (/trainings/missions/view/{id}.json, the manager course view, PicTrainingFlight / SicTrainingFlight on schedules) and are copied with the course and into new course revisions. Calendar events from /schedules/get.json carry crosscountry: true|false when either seat's mission is cross-country.
Send every flag on each edit. The action stores a flag that is absent from the post as false, so a partial update clears mandatory, tg and crosscountry. Use /manager/trainings/stages/assign/{missionId}.json to move a mission between stages.
The flag is informational on the server: it does not set flights.crosscountry. NEO's flight form ticks the flight's cross-country box when a cross-country mission is added or changed (never unticks it, never on opening an existing flight).
Mission Authorizations
A signed release for one student to fly one mission. Single-use: the flight that uses it consumes it. Inert unless the training has stage_checks = 1 and the mission has a require_authorization level.
training_flights.require_authorization is a ceiling on user_group_id — NULL means no authorization is needed, 170 lets flight instructors and everyone above them sign, 130 restricts it to company management. Because group ids descend in authority, a higher number is the looser rule.
Grant
POST /manager/trainings/mission_authorizations/grant.json
trainings_user_id
string
Yes
Enrollment id
training_flight_id
string
Yes
Mission being authorised
pass
string
Yes
The signer's password — re-authentication for the signature
limitations
string
No
Free text, e.g. circuits only, wind below 15kt
remarks
string
No
Free text
aircraft_id
integer
No
Restrict the release to one aircraft
403 when the caller's user_group_id does not clear the mission's level. 400 when the mission needs no authorization, or when an open grant already exists for that student and mission.
The stored signature holds the signer, timestamp, IP, browser and a keyed hash bound to that grant. granted_by_group_id and required_level are stamped at grant time, so later changes to a user's group or the mission's level never rewrite what a past release meant.
Revoke
POST /manager/trainings/mission_authorizations/revoke/{id}.json
revoke_reason
string
Yes
Why the release is being withdrawn
400 when the grant has already been used by a flight — that is history and cannot be withdrawn.
List
GET /manager/trainings/mission_authorizations/index.json — open (unused, unrevoked) grants for the company.
GET /trainings/mission_authorizations/pending/{enrollmentId}.json — the full history for one enrollment, including used and withdrawn grants. Readable by the student for their own enrollment, and by instructors and above.
Consumption
A grant is consumed when the mission row is written, and is not conditional on the mission being passed — a mission flown but failed still used the release. Three rules follow from that:
Re-saving the flight that consumed a grant is allowed (the grant records
consumed_flight_id).A different flight is refused; it needs its own signature.
Deleting or cancelling the flight restores the grant.
Blocked flights
A refused mission opens one mission_blocks row per (enrollment, mission) and sends one urgent message to the people who can sign it. Repeated refusals reuse that row and send nothing.
An hourly cron widens the audience one rung per company_settings.training_block_escalation_hours (default 4) while the block is unresolved: flight instructor → enrollment instructor → course tutor → student's supervisor → anyone in the company who clears the level. Rungs that cannot sign are skipped, never notified. Issuing the grant, flying the mission, or cancelling the flight resolves the block.
Course Approvals
Recorded decisions over two subjects: GRADUATION (a trainings_users.id) and REVISION (a training_revisions.id). Only GRADUATION gates anything, and only while the course has stage_checks = 1.
All actions require user_group_id <= 135 (Trainings Manager and above) or being the training's manager_id.
Queue
GET /manager/trainings/approvals/index.json — everything PENDING in the company, oldest request first. A course manager (group > 135) sees only courses they manage.
Decide
POST /manager/trainings/approvals/decide/{id}.json
status
string
Yes
APPROVED or REJECTED
pass
string
Yes
The approver's password
decision_reason
string
On reject
Mandatory when rejecting
400 when the item was already decided. An approved REVISION is stamped with approved_by/at and a SHA-256 snapshot_hash of the course content; an approved GRADUATION releases the enrollment to complete.
Graduation requests are created automatically: the first time a student's stages are all cleared, a PENDING row is opened. Repeating the check does not queue a second one.
Course change log
Every structural edit is recorded in training_changes: the entity (STAGE, MISSION, …), its name as it read at the time, the action (CREATED / UPDATED / DELETED), a field-level {field: {from, to}} diff, and who made it. Each row is linked to the revision it belongs to.
GET /manager/trainings/trainings/view/{id}.json returns the open revision with its changes[], so an approver sees the list before deciding rather than approving a revision number.
Two rules worth knowing:
A no-op edit records nothing. Re-saving a record without changing a field opens no revision and queues no approval.
Only posted fields are compared. A partial update is not treated as clearing everything it omitted.
Undoing a change
POST /manager/trainings/trainings/revert/{changeId}.json — approvers only.
Answers 200 with result: false and a plain-language message when the change cannot be undone. Those are business answers, not errors:
"A later change to the same item has to be reverted first."
"That mission has already been flown by a student, so it cannot be removed."
"That stage still has missions assigned to it."
"This deletion was recorded before full snapshots were kept…"
UPDATED restores the recorded from values; a reorder restores the recorded id sequence; CREATED deletes the entity after an in-use check; DELETED restores the row from training_changes.snapshot.
The revert is stamped on the change (reverted_at, reverted_by) so it cannot be applied twice, and it opens a revision of its own — an undo is a change to the course like any other.
Open trainings
Schema additions.
trainingsgainedpublished(tinyint),enrollment_modeENUM('CLOSED','REQUEST','INSTANT'),requirements(longtext),announcement_body(longtext) andcover_upload_id. The existingannouncement(varchar 255) is now the short strapline on the catalog card; the long copy lives inannouncement_body.Two new tables:
training_intakes(one dated run of a course) andtraining_enrollment_requests(a student's application).trainings_usersgained a nullableintake_id—NULLfor every enrollment that predates this feature, for every one a manager still creates off-catalog, and for every one taken on aDISTANCEcourse offered with no dates.An application is deliberately not a
trainings_users.statusvalue. Every "enrollments of this training" query in the plugin assumes a row there is a real enrollment, so a pending row living among them would leak into seat counts, student lists, reports, exam gates and the completion cron.
Seat counting
A seat on an intake is held by:
a
trainings_usersrow with thatintake_id,deleted = 0and statusACTIVEorCOMPLETED; plusa
training_enrollment_requestsrow with statusAPPROVEDwhosetrainings_user_idis stillNULL— approved, but the enrollment has not been created yet.
Waitlisted students hold no seat. Counts are derived on every read; there is no seats_taken column, so a stale counter can never overbook a course.
seats NULL means unlimited, which is not the same as 0. When seats is NULL, seats_left is null and full is always false.
Entry requirements
trainings.requirements (free text) explains entry conditions to humans. training_requirements rows are the ones the system checks, one row per rule, with kind deciding which value column is read:
kind
Column
Meaning
CERTIFICATE
certificate_type
an exact user_certificates.type
HOURS
hours_function + hours_min
total/pic/sic/dual/fi/night/ifr/xc/me, in hours
COURSE
required_training_id
another course, status COMPLETED
AGE
min_age
age on the day they apply
Certificate matching is strict, and deliberately differs from flight dispatch. A rule naming medical_class_1 is satisfied only by a certificate recorded as exactly that type: a medical_class_2 does not satisfy it, and neither does one filed under the legacy coarse medical — including a coarse row whose free-text name reads "Medical Class 1". CertificateComplianceEvaluator takes its coarse→granular bridge as an injected $familyMap, and EnrollmentPrerequisites passes an empty one. Dispatch keeps the bridge (a school that never recorded classes would otherwise ground its fleet); an ATO asking for a Class 1 is asking a different question.
Hours come from PilotHoursTotals, the same roll-up the pilot totals card shows: PICUS and the instructor/examiner classes count as PIC, the instructor classes also count as FI, supervisor counts as neither. user_details.flight_hours — the career total a pilot declares on their profile — is added to total only, because it carries no seat breakdown.
A verdict is a list of per-rule answers, each with a reason code: CERTIFICATE_MISSING, CERTIFICATE_EXPIRED, HOURS_SHORT, COURSE_NOT_COMPLETED, TOO_YOUNG, BIRTHDATE_UNKNOWN. BIRTHDATE_UNKNOWN is not TOO_YOUNG: a profile with no date of birth is an unfilled field, not an underage applicant.
Certificates with status = 'revoked' are dropped before evaluation; everything else is judged on its issue/expiration dates.
A half-saved rule (kind HOURS with no hours_min, say) is ignored, never treated as unmeetable. Unknown kinds are ignored too, so an older backend never blocks everybody on a rule it cannot read.
Plan and access
Every endpoint below requires the company to be on the premium or unlimited plan; any other plan answers 404.
The plugin's inherited gate only covers manager_-prefixed actions, so the three student-facing endpoints (catalog/*, enrollment_requests/submit, enrollment_requests/withdraw) check the plan themselves.
catalog/*, enrollment_requests/submit, enrollment_requests/withdraw
Any user in the company
intakes/*, requirements/*, enrollment_requests/decide
user_group_id ≤ 150 (Chief Pilot and above)
New actions need an ACL entry. aco_sync creates the ACOs, but the controllers root grants read to every group except 180, 240, 245 and 250 — so the three student-facing actions also need explicit aros_acos rows, copied from Trainings/Students/index. Without them students get 403 ACL_DENIED.
Catalog
GET /trainings/catalog/index.json
Courses this company is currently offering. A course reaches the catalog in one of two ways, and in both it must be published, active, and not in CLOSED mode:
Dated courses need at least one intake in
PUBLISHEDorFULLwhoseenroll_deadlinehas not passed. ANULLdeadline means "no deadline", not "expired".DISTANCEcourses need no intake at all. An intake schedules a classroom — a start day to gather a group around and a seat count to cap the room — and a distance course has neither, nor does it need anyone from the school to open the door. A published distance course is therefore listed on its own, withIntakes: [], and the student starts whenever it suits them. A school that does put dates on a distance course is taken at its word: those intakes are returned, and students apply to them exactly as on an on-site course.
Because location_id and month are questions about a scheduled class, a course with no dates cannot answer them and drops out of a list filtered by either.
trainings.template is not a visibility flag and is not filtered on. It marks a course as a blueprint other schools may build from (manager_templates lists it), which says nothing about whether this school teaches it — a school may well offer the same course it shares as a template. "Show this course in the catalog" (published) is the only switch that decides visibility. Until Sep 2026 the catalog excluded templates, which made a published one invisible with no explanation on any screen.
Visible to every user in the company — the course's own requirements are the filter, not group membership.
Path Parameters
Filters ride as CakePHP named parameters, not a query string: /trainings/catalog/index/type:ONSITE/month:2027-01.json
type
string
No
DISTANCE or ONSITE.
location_id
string
No
Only courses with a date at this location. Excludes dateless courses.
month
string
No
YYYY-MM — courses with a date starting in that calendar month. Excludes dateless courses.
page
integer
No
Paginated, 50 per page (maxLimit 200).
Response
view also returns Requirements (the rules) and Me.prerequisites (this caller's verdict, null when the course checks nothing — which is not the same as a verdict where every rule passed). Each verdict row carries a label: the certificate catalogue and course names live on the server, and a key like medical_class_1 or a course uuid means nothing to a client.
Me is what the calling user already is to this course: enrollment (the trainings_users.status, absent when never enrolled) and request (the newest application's status, with request_intake_id and request_reason). A closed enrollment — COMPLETED, STOPPED, FAILED, EXPELLED — does not stop a fresh application; retakes are normal.
Catalog course
GET /trainings/catalog/view/{trainingId}.json
The announcement, the requirements text, a subject summary and every open date, plus the caller's own state. The course object is the response root (no wrapper key).
Subjects carry id, code, name, hours and order only. This page is readable by anyone in the company who has not enrolled, so it never exposes lesson content.
404 when the course is not published, not active, belongs to another company, or the plan does not allow it — the same answer in every case, so the endpoint cannot be used to probe for course ids.
Catalog summary
GET /trainings/catalog/summary.json
A deliberately cheap payload for the home-screen widget: how many courses are on offer plus the first three cards. No seat counts, no per-student state — it is fetched on every home screen.
count: 0 means the school is offering nothing, and is the signal to render no widget at all.
Apply to a course
POST /trainings/enrollment_requests/submit.json
Request Body
Exactly one of intake_id or training_id identifies what is being applied to. Post intake_id for a seat on a date; post training_id on its own for a DISTANCE course offered with no dates. Posting both is not an error — the intake wins and names its own course, so a client cannot reach one course through another's dates.
intake_id
string
Conditional
The date being applied to. Must belong to the caller's company.
training_id
string
Conditional
The course being applied to, when it is offered with no dates. Must belong to the caller's company. Ignored when intake_id is present.
note
string
No
Message for the school, shown to the manager in the approvals queue.
Neither field is a way past the rules: a course-level application to an ONSITE course is refused with INTAKE_NOT_FOUND, and so is one to a DISTANCE course whose type has since changed. 400 Missing Intake Id is returned only when neither field is posted.
Behavior
What happens is decided by the server, from the course's enrollment_mode and the seats left. The client must not assume:
outcome
When
ENROLLED
INSTANT mode and seats free — the enrollment already exists.
PENDING
REQUEST mode — a manager must approve it.
WAITLIST
The intake is FULL, whatever the mode.
Taking the last seat flips the intake to FULL in the same transaction as the enrollment, under a SELECT … FOR UPDATE on the intake row, so two students racing for one seat produce exactly one enrollment.
A course-level application carries no seat: there is no room to fill, so WAITLIST is unreachable, there is nothing to lock, and the mode alone decides between ENROLLED and PENDING. The resulting trainings_users row (or training_enrollment_requests row) keeps intake_id NULL, and is created through the same TrainingsUser::enrol() path as every other enrollment, so the audit trail is identical.
A refusal is a soft 200 carrying result: false and a reason code — never a sentence, because the wording belongs to the client in seven locales:
NOT_PUBLISHED, COURSE_INACTIVE, ENROLLMENT_CLOSED, INTAKE_NOT_FOUND, INTAKE_NOT_OPEN, ENROLLMENT_NOT_OPEN_YET, ENROLLMENT_DEADLINE_PASSED, ALREADY_ENROLLED, ALREADY_APPLIED, PREREQUISITES_NOT_MET.
The order of those checks is part of the contract: publication → mode → intake state → enrolment window → who the student already is → capacity. A full intake whose deadline has passed is closed, not waitlistable. The deadline day itself is still open — it is the last day to apply, not the first closed one.
With no intake, the middle of that order simply does not apply: publication and mode are checked as always, the intake state, window and capacity checks are skipped, and a live enrollment or a live application still blocks a second one. INTAKE_NOT_FOUND is what a course that needs dates answers when asked for a dateless application.
Entry requirements at submit time
Requirements are checked after the course's own eligibility, so a closed course answers ENROLLMENT_CLOSED rather than "you need a medical". What an unmet rule does depends on the mode:
INSTANT— refused, soft200withreason: "PREREQUISITES_NOT_MET"and the full verdict inprerequisites_verdict. There is no human in that path to notice.REQUEST— allowed through. The verdict is stored on the application inprerequisite_snapshotas JSON, so the manager decides with it in front of them (a medical booked for Friday is a normal reason to approve anyway).
The snapshot is what was true at submit time. Editing or deleting a rule afterwards does not rewrite it, which is why the approvals queue reads the stored copy rather than re-evaluating.
A course with no requirement rows costs no extra queries: the facts a rule set needs are gathered per kind, so a course with no hours rule never pays for the aggregate queries PilotHoursTotals costs.
A student may hold only one live application per intake — and one per course for the dateless kind, which the one_open_request unique key covers by folding a NULL intake into the key as -. A double submit answers ALREADY_APPLIED rather than creating a second one or failing.
Withdraw an application
POST /trainings/enrollment_requests/withdraw/{id}.json
Only the applicant may withdraw, and only while the application is still PENDING or WAITLIST. An answered application returns result: false with reason REQUEST_ALREADY_ANSWERED. Anyone else's application is 404.
Course dates
GET /manager/trainings/intakes/index/{trainingId}.json
Every date of one course with its seat position. Paginated (limit 200, maxLimit 1000).
Save a course date
POST /manager/trainings/intakes/save.json
Request Body
id
string
No
Omit to create.
training_id
string
Yes
The course. Fixed at creation — sending a different one on an update is ignored, because moving a date would move its applications and enrollments with it.
name
string
No
Shown to students, e.g. "Jan 2027".
status
string
No
DRAFT (default), PUBLISHED, FULL, CLOSED, CANCELED.
start / end
date
No
YYYY-MM-DD. end may not precede start.
enroll_opens / enroll_deadline
date
No
YYYY-MM-DD. The deadline may not precede the opening.
seats
integer
No
Empty or absent means unlimited. Send 0 only if you really mean nobody can enroll.
location_id
string
No
notes
string
No
Internal, not shown to students.
company_id is taken from the session and never from the payload.
Note the PHP quirk: an empty
validationErrorsserialises as[](array), a populated one as an object. Check for emptiness before treating it as a map.
Delete a course date
POST /manager/trainings/intakes/delete/{id}.json
Soft delete. Refused while any student is enrolled on the date — cancel it instead, so the students and their history survive. The refusal is a soft 200 carrying the count:
Change a date's status
POST /manager/trainings/intakes/status/{id}/{status}.json
DRAFT
Invisible to students. New dates start here.
PUBLISHED
In the catalog, taking applications.
FULL
Visible, waitlist joins only. Normally written by the application the moment the last seat goes; setting it by hand stops enrolment without cancelling the run.
CLOSED
No longer taking anyone.
CANCELED
The run is called off.
Raising seats and setting PUBLISHED puts a full date back in the catalog. An unknown status is 400.
Decide an application
POST /manager/trainings/enrollment_requests/decide/{id}/{decision}.json
decision is APPROVE or REJECT. A rejection takes { "reason": "…" }, which the student sees on the course page.
Approving creates the enrollment through TrainingsUser::enrol() — the same path the manager's own enrol screen uses, so the row and the TrainingsUserStatusChange audit entry are identical — stamps trainings_user_id on the application, and re-checks capacity under a row lock. A date that filled in the meantime answers result: false with INTAKE_FULL rather than overbooking.
Approving a WAITLIST row is how a waitlisted student is promoted. Promotion is always manual; nothing is promoted automatically when a seat frees up.
Applications queue
Open applications also appear on the existing approvals endpoint, as a third queue beside approvals and flights:
GET /manager/trainings/approvals/index.json
PENDING rows come before WAITLIST ones. The applicant's name arrives in Applicant rather than a User association: users lives in the main database while these rows live in flylogs_trainings, and the name is on user_details in any case. A course manager sees applications for their own courses; user_group_id ≤ 135 (Trainings Manager and above) sees every course's.
Exam sittings
Schema additions (
flylogs_trainings). Five new tables:exam_authority_rules(attempt limits the school configures),exam_sittings(one dated sitting),exam_sitting_subjects(the course subjects a sitting offers),exam_registrations(a student put forward for a sitting) andexam_registration_subjects(the subjects chosen, and the official result of each).These tables are separate from the internal
examstable.exams.caa_examis unchanged, and official results never writeactivity_progress. None of the tables has a fee or price column.2026-09-29: new table
exam_sitting_requirements(flylogs/migrations/2026-09-29-exam-sitting-requirements.sql) — entry requirements per sitting, same columns and kinds astraining_requirementsbut keyed onsitting_id. No new actions, so noaco_sync. See Entry requirements below.2026-09-16 (task #959):
exam_registration_subjects.resultgained the valueABSENT(flylogs/migrations/2026-09-16-exam-sittings-absent.sql, ENUM change only), andmanager_studentwas added (needsaco_sync).
A sitting is type AUTHORITY (the authority's exam; Flylogs only manages registration and results) or SCHOOL. datetime is a unix timestamp. registration_opens / registration_deadline are Y-m-d dates, and the deadline day is the last day to register or cancel. seats and max_subjects NULL mean unlimited / no cap.
Plan and access
Every endpoint requires the premium or unlimited plan; any other plan answers 404. The student-facing actions check the plan themselves, because the plugin's inherited gate only covers manager_ actions.
exam_registrations/index, register, cancel, mine
Any user in the company. cancel only works on the caller's own registration.
exam_sittings/manager_* (reads included)
user_group_id ≤ 150 (Chief Pilot and above)
The 19 new actions need aco_sync. The four student actions also need explicit aros_acos grants copied from Trainings/Students/index, like the catalog: see flylogs/migrations/2026-09-13-exam-sittings-acl.md.
exam_sittings/manager_audit was added later and needs its own aco_sync run — nothing else, since it inherits the same root allow as the other manager actions: see flylogs/migrations/2026-09-25-exam-sittings-audit-acl.md.
Attempt rules
exam_authority_rules rows are data the school edits; no regulation is encoded in code.
max_attempts_per_subject
Attempts at one subject
max_sittings
Distinct sittings used, across every subject
min_days_between_attempts
Calendar days between two attempts at the same subject, pass or fail
completion_window_months
Window within which later sittings must fall
window_starts_on
FIRST_ATTEMPT or FIRST_PASS, at any subject
window_from_month_end
1 starts the window at the end of the anchor's calendar month
Every limit is nullable: NULL means not limited. 0 is refused by validation.
ExamAttemptPolicy evaluates one subject at one candidate sitting and returns this verdict:
BLOCK reasons: ATTEMPTS_EXHAUSTED, SITTINGS_EXHAUSTED, WINDOW_EXPIRED, TOO_SOON. WARN reasons: ALREADY_PASSED, LAST_ATTEMPT, LAST_SITTING. reason is always the most severe reason present.
An attempt is an
exam_registration_subjectsrow withresultPASS,FAILorABSENTand asat_at. An absence spends the attempt like aFAIL(never a pass) — the authority counts a no-show, so the school does too (task #959).PENDINGis not an attempt.History is scoped per rule: all the student's sat results in sittings with the same
authority_rule_id. Sittings with no rule share one scope of their own, so they still get counters but never aBLOCK.The candidate sitting's own results are left out, so a sitting whose results are in still reads as the attempt it was.
Days are counted in the company timezone.
Eligibility and seats
A student may register for a subject when their enrolment on the subject's course is:
COMPLETED; orACTIVE, with every mandatoryEXAMactivity of the subject done (activity_progress.value = 1on that enrolment); or, when the subject has no mandatory exam, every mandatoryLESSONdone.
STOPPED, FAILED and EXPELLED enrolments are not eligible.
A registration holds a seat while it is PENDING, CONFIRMED, SAT or ABSENT. Counts are derived on every read. A sitting flips PUBLISHED → FULL when its last seat is taken and back again when one frees up; FULL is never set by hand.
Entry requirements
A sitting can carry the same checkable entry requirements as a course (see Entry requirements under open trainings): kind CERTIFICATE (certificate_type, exact match), HOURS (hours_function + hours_min in hours), COURSE (required_training_id, completed) or AGE (min_age, on the day the student registers). Rows live in exam_sitting_requirements and are judged by the same EnrollmentPrerequisites evaluator, so a rule means the same thing on a course and on a sitting. A sitting with no rows checks nothing.
Requirements are about the student, not a subject:
Student registration (
exam_registrations/register) is refused withPREREQUISITES_NOT_METwhen any rule is unmet — there is no override on this path.Manager registration (
exam_sittings/register) is never refused for it; it comes back as a warning with no subject (see below).Requirements are managed by
user_group_id≤ 150 (Chief Pilot and above), throughexam_sittings/save.json.
Student: open sittings
GET /trainings/exam_registrations/index.json (named param page)
This returns sittings in PUBLISHED or FULL that haven't taken place yet and whose deadline hasn't passed. Each sitting lists only the subjects the caller is eligible for, each with its attempt verdict. A sitting with no eligible subject and no registration of the caller's is left out.
Prerequisites is the caller's verdict against the sitting's entry requirements, in the same shape as the catalog's Me.prerequisites, or null when the sitting has none (which is not the same as "all met"). Unmet reasons: CERTIFICATE_MISSING, CERTIFICATE_EXPIRED, HOURS_SHORT, COURSE_NOT_COMPLETED, TOO_YOUNG, BIRTHDATE_UNKNOWN. Unmet requirements do not hide a sitting; they show in can_register.
Student: register
POST /trainings/exam_registrations/register.json
The server validates the request under a row lock on the sitting, so two students racing for the last seat get one registration and one refusal. A refusal is { "result": false, "reason": "…", "subject_id": "…" }. The reasons, in the order they are checked:
SITTING_FULL, SITTING_NOT_OPEN, SITTING_PAST, REGISTRATION_NOT_OPEN_YET, REGISTRATION_DEADLINE_PASSED, ALREADY_REGISTERED, PREREQUISITES_NOT_MET, NO_SUBJECTS, TOO_MANY_SUBJECTS, SUBJECT_NOT_OFFERED, SUBJECT_NOT_ELIGIBLE, ATTEMPT_BLOCKED, SUBJECT_FULL, SITTING_FULL.
A PREREQUISITES_NOT_MET refusal carries the verdict instead of a subject: { "result": false, "reason": "PREREQUISITES_NOT_MET", "prerequisites_verdict": { "met": false, "rules": [...] } }.
A new registration is PENDING. A subject whose verdict is WARN is accepted, and the warning comes back in the response:
Student: cancel
POST /trainings/exam_registrations/cancel/{id}.json
This cancels the caller's own PENDING or CONFIRMED registration, up to and including the registration deadline, or until the sitting starts when there is no deadline. Refusals: CANNOT_CANCEL_STATUS, CANCEL_DEADLINE_PASSED. Another user's registration id answers 404.
Student: my registrations
GET /trainings/exam_registrations/mine.json (named param page)
This returns registrations, newest first, each with ExamSitting, Subjects (result, score, attempt_no, and training_id so a course page can filter to its own subjects), files (see Official result reports) and can_cancel. It also returns standing: one entry per rule and subject the caller has ever sat (with training_id), with the attempt verdict evaluated as of now (attempts used, passed, next eligible date, window end). standing is built from the whole history, not from the current page.
Both this endpoint and the manager's manager_student below are shaped by ExamStudentRecord, so the student's page and the manager's view of the same student always agree.
Manager: one student's record
GET /manager/trainings/exam_sittings/student/{user_id}.json (named param page; limit 100, maxLimit 500)
Same payload as mine (registrations, standing, paging) for the given student, who must belong to the session company and not be deleted (404 otherwise). Requires user_group_id ≤ 150 (Chief Pilot and above). Needs aco_sync; it inherits the root allow like every other manager_* action. NEO's enrolment pages (/trainings/view/{enrolment} for the student, /manager/trainings/students/view/{enrolment} for a manager) render an Exam sittings section inside their Exams tab from these two endpoints, filtered to the course's training_id. Both pages accept ?tab=exams, and the manager sittings page accepts ?sitting={id} to open one sitting's registration list directly.
Manager: sittings
GET
/manager/trainings/exam_sittings/index.json
Named params status, page. Rows carry seats_taken, rule_name, Subjects, Requirements and Registrations (count by status). limit 50.
GET
/manager/trainings/exam_sittings/view/{id}.json
One sitting with Subjects, Requirements and Rule.
GET
/manager/trainings/exam_sittings/options.json
trainings (with Subjects), rules, locations for the form.
POST
/manager/trainings/exam_sittings/save.json
Create or update. Ignores status, company_id and deleted.
POST
/manager/trainings/exam_sittings/status/{id}/{status}.json
DRAFT, PUBLISHED, CLOSED or CANCELED. Publishing a sitting with no seats left lands on FULL.
POST
/manager/trainings/exam_sittings/delete/{id}.json
Soft delete. Refused while any registration is PENDING, CONFIRMED, ABSENT or SAT; the response carries registered.
save.json takes an optional subjects array, [{ "training_subject_id": "…", "seats": null }]. When it is present, the offered subjects are reconciled to match it row by row: new subjects are added, seat changes are updated, and subjects left out are soft-deleted. Removing a subject that someone holds a seat for rolls the whole save back with { "result": false, "reason": "SUBJECT_HAS_REGISTRATIONS", "subject_id": "…", "registered": 2 }. Every subject must belong to one of the company's courses (otherwise 404), and so must the rule, course and location.
It also takes an optional requirements array, [{ "id": "…optional", "kind": "HOURS", "hours_function": "pic", "hours_min": 100 }]. When it is present, the sitting's entry requirements are reconciled to match it in the same transaction: a row whose id already belongs to this sitting is updated, any other row is created, and existing rows left out are deleted (a rule is configuration, not history). Values not read by the row's kind are blanked. A rule that fails validation rolls the whole save back with validationErrors (same messages as a course requirement). A required_training_id outside the company answers 404. The saved sitting comes back with Requirements. Omitting requirements leaves them untouched.
Manager: registrations and results
GET /manager/trainings/exam_sittings/registrations/{id}.json (named params page, limit up to 1000)
This returns every registration with Applicant { id, name } and Subjects. Each subject carries its result and a verdict evaluated as of this sitting.
POST /manager/trainings/exam_sittings/registration_status.json
CONFIRMED
PENDING, ABSENT, REJECTED, CANCELED
REJECTED
PENDING, CONFIRMED
CANCELED
PENDING, CONFIRMED, ABSENT
ABSENT
PENDING, CONFIRMED
SAT is never set here. Moving a registration to ABSENT stamps every subject whose result is still PENDING as ABSENT, with sat_at and attempt_no exactly as a FAIL would get them, inside the same transaction; leaving ABSENT (to CONFIRMED, PENDING or CANCELED) puts those subjects back to PENDING and clears the stamps, so a withdrawn absence never counts. Rows that can't make the requested change are skipped, not failed: { "result": true, "updated": [...], "skipped": [{ "id": "…", "reason": "INVALID_TRANSITION" | "SITTING_FULL" | "NOT_FOUND" }] }. Moving a registration back into a seat-holding status is refused per row once the sitting is full.
POST /manager/trainings/exam_sittings/results/{id}.json
PASS/FAIL/ABSENT stamps sat_at (the sitting's datetime) and attempt_no (from the student's earlier sat results under the same rule, this sitting excluded). A PASS or FAIL anywhere marks the registration SAT; absences alone mark it ABSENT. PENDING clears the result; a SAT or ABSENT registration with nothing sat left returns to CONFIRMED. ABSENT is never announced to the student. Skip reasons: NOT_FOUND, UNKNOWN_RESULT, REGISTRATION_NOT_ACTIVE (rejected or cancelled), INVALID.
Manager: register a student
GET /manager/trainings/exam_sittings/candidates/{id}.json
This returns every live user of the company (users.deleted = 0, active = 1) with an ACTIVE or COMPLETED enrolment on a course whose subjects the sitting offers, sorted by name. Each candidate carries registered (they already hold a live registration here) and Subjects, keyed by exam_sitting_subjects.id, each with eligible, eligibility_reason (NOT_ENROLLED, ENROLMENT_CLOSED, EXAMS_NOT_PASSED, LESSONS_NOT_COMPLETE) and attempt, the verdict at this sitting. Eligibility is shown here, not enforced.
POST /manager/trainings/exam_sittings/register.json
The request is checked by ExamRegistrationRules::decideForManager() under the same row lock as a student's registration. A manager is not bound by the registration window, the sitting's date (so past sittings can be recorded), DRAFT/CLOSED status, or eligibility. A manager is bound by a CANCELED sitting, one live registration per student, max_subjects, the offered subjects, seats and the attempt rules (ATTEMPT_BLOCKED).
The user must belong to the session company and be live; otherwise the call answers 404. The registration is created CONFIRMED and the student is notified. Subjects the student isn't ready for, attempt warnings and unmet entry requirements come back as warnings. Entry requirements are about the student, so that warning has subject_id null and the number of unmet rules in detail:
Manager: reset a student's attempt history
POST /manager/trainings/exam_sittings/reset_attempts.json
This gives a fresh count for a student under one rule, typically after they restart their training having used up their attempts. It inserts one exam_attempt_resets row (company_id, user_id, authority_rule_id, reset_at, reason, created_by). The table is insert-only, and no result is edited or deleted.
ExamSittingFacts::history() then ignores every sat result with sat_at <= reset_at for that student and rule, via the latest reset. That affects every verdict, the attempt_no stamped on new results, and the student's standing. Attempts, sittings and the completion window all start again. Earlier registrations still show their results and attempt numbers.
{ "result": true, "reset": { "id": "…", "user_id": 490, "authority_rule_id": "…", "reset_at": 1789300000 } }
Saved; the student is notified
{ "result": false, "reason": "REASON_REQUIRED" }
Empty reason
404
Rule not in the company, or student not a live user of the company
candidates/{id}.json carries reset_at per candidate (the latest reset under the sitting's rule, or null) and sitting.Rule. Requires user_group_id ≤ 150 (Chief Pilot and above).
Notifications
The student receives a message through Message::fastSave: in-app, plus email according to their notification settings. It links to /trainings/exam_sittings and is sent:
manager_register
Registered for exam: {sitting}, with the subject list
registration_status → CONFIRMED
Exam registration confirmed: {sitting}, with the subject list
registration_status → REJECTED
Exam registration not accepted: {sitting}, with the reason, HTML-escaped
registration_status → CANCELED
Exam registration cancelled: {sitting}, with the reason, HTML-escaped
results
Exam results: {sitting}, listing each subject whose result changed to PASS/FAIL, with its score
reset_attempts
Exam attempt history reset: {rule}, with the reason, HTML-escaped
Nothing is sent for ABSENT (as a status or as a result), for a score-only correction, or when the same result is saved again. Messages are sent after the transaction commits; a failure is logged and never undoes the change.
Official result reports
One report per student per sitting: managers attach the authority's result report to the student's registration through the generic uploads API with model ExamRegistration and foreign_key the exam_registrations.id (/uploads/sign → PUT → /uploads/complete, or the legacy /uploads/create; list with /uploads/index/ExamRegistration/{registration_id}.json). No new endpoint and no ACL change: the uploads index is company-scoped, and the registration id is a UUID the student already holds. manager_registrations, mine and manager_student each carry, per registration, files (count) and Files (list of { id, name, url, mime, icon }, url being the signed CDN link Upload::afterFind() builds, valid about an hour), from ExamStudentRecord::files(). NEO shows the file as a plain link under the subjects on every view; the manager's registration list also opens an upload box per row on request.
Manager: authority rules
GET
/manager/trainings/exam_sittings/rules.json
Each rule carries sittings, the number of sittings using it.
POST
/manager/trainings/exam_sittings/rule_save.json
Create or update. Empty limits are stored as NULL.
POST
/manager/trainings/exam_sittings/rule_delete/{id}.json
Soft delete, refused while a sitting uses the rule (sittings in the response).
Manager: the period audit
GET /manager/trainings/exam_sittings/audit.json
The return a school files with its authority: for a date range, how many subject papers were sat and how many were passed, failed, missed or resat. Same gate as every other manager action here (user_group_id ≤ 150); the action is a new ACO, so it needs aco_sync before it answers anything but 403 ACL_DENIED.
Query string (all optional): from, to (YYYY-MM-DD, inclusive, read in the company timezone), type (AUTHORITY|SCHOOL), authority, training_id, subject_id, canceled (1 to include canceled sittings). The window defaults to the last twelve months, and from after to is a 400.
The period is keyed on exam_sittings.datetime, not on exam_registration_subjects.sat_at: a sitting held in June belongs to June's return even if its results were typed in July. Sittings with no date set are out, as are registrations the school REJECTED or the student CANCELED — their subject lines were never sat.
Counting, as ExamSittingAudit defines it:
papers
One subject paper sat by one candidate. Three subjects at one sitting is three papers.
pass / fail / absent
The three results ExamAttemptPolicy spends an attempt on. fail is graded failures only.
fail_total
fail + absent — the failed figure the report shows. A no-show spent the attempt without passing, so it is a failure; absent stays separate underneath so the split is still readable.
pending
Ungraded lines. In no other figure, so an incomplete period says so instead of reporting a lower pass rate.
sat
pass + fail_total — every paper that spent an attempt, and the pass-rate denominator.
pass_rate
pass / sat, to one decimal, null when nothing was sat.
first_attempts / resits
Split on attempt_no > 1. A row with no attempt number (legacy) counts as a first attempt.
first_pass_rate / resit_pass_rate
Over first_attempts / resits, absences included, so both stay comparable with pass_rate.
avg_score
Mean of the recorded marks. Absences and blank scores are left out — a blank is "not recorded", not a zero.
registered / no_shows
Seats taken (registrations the school neither rejected nor the student withdrew) and the ABSENT ones among them.
candidates
Distinct students with at least one graded paper.
The response carries totals, the same counter block broken down in by_subject, by_sitting (every sitting of the period, including ones with nothing graded yet), by_authority and by_training, plus period (from, to, from_at, to_at), truncated (the period holds more sittings than one read returns) and rows — the candidate-level lines behind every figure, each with student, subject_code, subject_name, result, score, attempt_no and sat_at.
Draft a mission comment
POST /trainings/missions/comment_draft.json
Drafts the comment for ONE training mission from the gradings supplied in the body.
Access: caller's user_group_id must be 170 or below (Flight Instructor and above — instructors and managers). Students get 403.
This call is slow by API standards (typically 5–15 seconds: it is a model call), so clients must show progress and set a generous timeout. A short per-user cooldown applies; calling again immediately returns 400.
Request Body
Mission.name
string
The mission's name, for context
Mission.evaluation
string
Rating scale in use: NUM (1–5), STD (STD / STD + / STD -) or NICE (N/I/C/E). Read correctly per scale — an N means not competent, not a letter grade
Mission.exercises
array
{ name, rating, comments } per exercise, as they stand in the form
Mission.metrics
array
Same shape, for performance metrics
Items with neither a rating nor a comment are ignored: an ungraded exercise was not assessed, and summarising it would invent content.
Response
result
boolean
false when no draft could be produced
message
string
Human-readable outcome
comment
string
The draft, up to 1,500 characters
model
string
The model that produced it
The generator is instructed to use only the gradings given and never to decide whether the mission is passed, whether the student is competent, or whether anything should be repeated. Treat the output as an editable draft for a qualified person, never as an assessment.
Errors
400
Not a POST, or the cooldown is still active
403
user_group_id above 170 (Captain and below)
Two cases answer 200 with result: false rather than failing: nothing has been graded yet, and no AI provider is configured on the installation.
Course revisions, student moves and prior experience
Task #1050. All endpoints below are manager endpoints limited to staff up to and including Trainings Manager (user_group_id <= 135); anyone else gets 404 (403 for prior_experience on enroll). Approving anything in /manager/trainings/approvals also requires user_group_id <= 135 — the course's own manager_id no longer grants it. Refusals are answered with HTTP 200 and {"result": false, "code": "..."} — the codes are listed per endpoint.
All times on these endpoints are whole seconds; nothing is sent or stored as decimal hours.
Check a grade against its comment
POST /trainings/missions/grade_check.json
Does the comment on ONE graded exercise contradict its grade? Reads nothing and writes nothing.
Body: { "method": "STD|NICE|NUM", "rating": "+", "comment": "...", "exercise": "Steep turns" } — method is the training's evaluation_method, rating one of that scale's values (-/STD/+, N/I/C/E, 1–5).
Response: { "check": { "reads_as": "-", "confidence": 1, "mismatch": true, "direction": "lower" } }, or { "check": null } when the comment is under 12 characters, the rating is not on the scale, or the feature is disabled. mismatch needs ≥ 0.6 confidence and a gap of 1 level on STD, 2 on NICE and NUM.
Access: user_group_id 170 or below; students get 403.
Enrollment status MIGRATED
TrainingsUser.status gained MIGRATED: the student moved to another course or course revision and carries on in the enrollment named by TrainingsUser.migrated_to_id. The new enrollment points back through migrated_from_id and inherits notes and prior_experience_reason. A MIGRATED enrollment cannot be changed through /manager/trainings/students/status, reset, finish or the student restart (400 ENROLLMENT_MIGRATED); MIGRATED itself cannot be set through status. It does not satisfy a course prerequisite, issue a certificate or hold an intake seat. A prerequisite naming a course is met by completing any course revision of it.
Progress rows carry a source: NORMAL, MIGRATED (copied from the previous enrollment) or CREDIT (prior experience, kept as CREDIT when carried over). Any ordinary write to an activity_progress row turns it back to NORMAL.
Course revisions
A course revision is a Training row. The fields keep their database names: version (course revision number 1, 2, …), version_root_id (id of revision 1; null on revision 1) and version_status: CURRENT (the default for new students), DRAFT (being prepared; never enrollable, never in the catalog) or SUPERSEDED (an earlier revision; managers may still enrol into it, students cannot apply). GET /manager/trainings/trainings/index.json returns the three fields on every row.
POST /manager/trainings/trainings/new_version/{trainingId}.json — copies the CURRENT revision into a new DRAFT (subjects, lessons, exams, missions, stages, entry requirements, settings; published = 0, enrollment_mode = CLOSED). The course row is locked for the duration, so concurrent calls create one draft and the others answer DRAFT_EXISTS. Answer: {result, code, id} where id is the draft. Codes: NOT_CURRENT, DRAFT_EXISTS, SAVE_FAILED.
POST /manager/trainings/trainings/publish_version/{draftId}.json — the draft becomes CURRENT and takes over active, published and enrollment_mode; the previous revision becomes SUPERSEDED, unpublished and closed to applications. Its open intakes (DRAFT, PUBLISHED, FULL) and PENDING / WAITLIST applications move to the new revision; no enrollment is moved. Answer: {result, code, previous_id, students_on_previous}. Codes: NOT_DRAFT, REVISION_PENDING (the draft has unapproved changes; approving them through /manager/trainings/approvals/decide/{id}.json publishes it and that answer then carries published_version with the same fields), SAVE_FAILED.
POST /manager/trainings/trainings/discard_version/{draftId}.json — deletes a draft. Code: NOT_DRAFT.
GET /manager/trainings/trainings/revision_diff/{fromId}.json?to={toId} — what changed between two course revisions (any two courses of the company). Subjects, activities, missions and stages are paired by normalised name; missions also report position changes.
Mission fields may hold position, flight_type_id, hours, rules, mandatory, tg, crosscountry; settings compared are type, theory, flights, stage_checks, evaluation_method, auto_finish, validity, duration, show_certificate, regulatory_basis, requirements. Planned hours are the course's own values (stored in hours by the course).
POST /manager/trainings/students/enroll.json accepts any CURRENT or SUPERSEDED revision as training_id and refuses a DRAFT with code: REVISION_IS_DRAFT. The catalog, submit and the approval of an application only accept the CURRENT revision (NOT_PUBLISHED).
Moving a student to another course
Compatibility: a subject matches on normalised name (case and spacing ignored) and equal planned hours; inside a matched subject, activity i matches activity i when kind and name are equal; course-level activities match the same way. Mission i matches mission i (course order, ties by id) when flight_type_id and hours are equal — an inserted mission shifts every later position. Students are always moved to a CURRENT revision.
GET /manager/trainings/students/migrate_preview/{enrollmentId}.json?target={trainingId} — writes nothing. Answer { "preview": { refusal, source, target, map, carried: {activities, missions, credits}, unmatched_hours: [{ flight_type_id, name, seconds, in_target }] } }; map pairs old ids with new ids (null = not carried).
POST /manager/trainings/students/migrate/{enrollmentId}.json — body { "target": "<trainingId>", "carry_flight_types": [4], "reason": "…" }. Opens an ACTIVE enrollment on the target with the matched activity_progress and user_training_flights rows copied (same flights), credits the unmatched flown time of the listed flight types (only types the target flies; a flight already carried with a matched mission is not credited again), carries existing credits whose flight type / subject exists in the target, and closes the old enrollment as MIGRATED — all in one transaction. Answer: {result, code, trainings_user_id}. Codes: REASON_REQUIRED, NOT_ACTIVE, SAME_COURSE, ALREADY_ON_TARGET, VERSION_NOT_CURRENT, SAVE_FAILED.
GET /manager/trainings/students/version_candidates/{trainingId}.json?target={trainingId} — every ACTIVE enrollment of the course with {enrollment_id, user_id, name, refusal, carried, unmatched_hours} for moving it to the target.
POST /manager/trainings/students/migrate_bulk.json — body { "target": "…", "reason": "…", "students": [{ "enrollment_id": "…", "carry_flight_types": [] }] }. Each student is moved in its own transaction; answer { "results": [{ "enrollment_id", "result", "code", "trainings_user_id" }] }.
Prior experience
GET /manager/trainings/students/course_outline/{trainingId}.json — { "outline": { "subjects", "course_activities", "missions", "flight_types": [{ "id", "name", "color", "seconds" }] } }: what the prior-experience form offers (flight types are those the course's missions use; seconds is the course's planned time for that type).
Prior experience payload — whole seconds:
Flight-type and subject time is stored in
enrollment_credits(kindFLIGHT_TYPE_HOURS/SUBJECT_HOURS,seconds,originPRIOR) exactly as sent.A completed subject writes
activity_progressrows (value = 1,source = CREDIT) for every activity of the subject that has no progress yet.A completed mission writes a
user_training_flightsrow withflight_id = null,completed = 1,time = 0,source = CREDIT. It counts for mission progress and course completion; its time comes only from the flight type credit.TrainingsUser.prior_experience_reasonholds the reason.
Send it as prior_experience in POST /manager/trainings/students/enroll.json (one individual student only), or on its own:
POST /manager/trainings/students/prior/{enrollmentId}.json — replaces the enrollment's prior experience (removes origin = PRIOR credits, credited missions and activity rows still tagged CREDIT, then writes the new ones). Answer {result, code}.
Codes (both paths): REASON_REQUIRED, INVALID_TIME (not a whole number of seconds), UNKNOWN_FLIGHT_TYPE, UNKNOWN_SUBJECT, UNKNOWN_MISSION, NEGATIVE_HOURS, PRIOR_SINGLE_STUDENT (enroll with more than one student or a pilot group), ENROLLMENT_CLOSED (prior on an enrollment not in progress), SAVE_FAILED. When the enrollment is created but its prior experience could not be saved, enroll.json answers normally with code: PRIOR_NOT_SAVED.
GET /manager/trainings/students/view/{enrollmentId}.json adds:
HoursByFlightType:[{ flight_type_id, planned, done, credited }]in seconds — planned = the course's mission time of that type, done = time the enrollment's confirmed flights gave its missions, credited =enrollment_credits.HoursBySubject:[{ training_subject_id, planned, done, credited }]in seconds — planned =training_subjects.hours, done = sum ofactivity_progress.time.EnrollmentCredit: the raw credit rows.PriorCredit:{ subjects_completed: [ids], missions: [ids] }.TrainingsUser.prior_experience_reason,migrated_from_id,migrated_to_id;Training.version(course revision number),Training.version_status.
Course pathways and experience requirements
Task #464. Credit a closed enrollment into another course (e.g. LAPL(A) towards PPL(A), FCL.210.A(b)), pair two courses by hand and keep the pairings as a course pathway, and check a student's flight experience against the course's own minimums. Every endpoint is a manager endpoint limited to staff up to and including Trainings Manager (user_group_id <= 135), scoped to the caller's company; anyone else gets 404. All times are whole seconds. Flylogs supplies no regulatory values: every minimum is typed by the school.
Credit mode and hand pairings on the move endpoints
The move endpoints of the previous section gained three things:
mode:move(default; ACTIVE enrollment closes asMIGRATED) orcredit— aCOMPLETED,STOPPEDorFAILEDenrollment is left unchanged (status, certificate, progress, notes) and a new ACTIVE enrollment opens on the target with copies of the matched rows (source = MIGRATED, same flights). The new enrollment hascredited_from_idset;migrated_from_idstaysNULL. New refusal code:NOT_CREDITABLE(credit mode, status not COMPLETED / STOPPED / FAILED).NOT_ACTIVEnow applies to move mode only.overrides: hand pairings{ "subjects": { "<srcId>": "<tgtId>"|null }, "activities": { … }, "missions": { … } }—null= explicitly not carried. Ids that are not items of the two courses, lesson↔exam pairs, and a second claim on a subject or activity target are dropped silently; missions may share a target. Absentoverrides= the saved pathway for this source course revision and target course applies (unless stale); an empty object = no hand pairings at all.Matching order: hand pairing → Subject Bank family (
training_subjects.subject_family_id,subject_family_items) → the name / position rules.
GET /manager/trainings/students/migrate_preview/{enrollmentId}.json?target={trainingId}&mode=move|credit
POST same URL, JSON { "target", "mode", "overrides" } — still writes nothing.
The preview also answers:
mode
move / credit
map.how
{ subjects, activities, missions }: source id → OVERRIDE | FAMILY | AUTO for every paired (or explicitly excluded) item
progress
{ activities_done: [ids], missions_done: [ids] } — source items the student completed
overrides
the hand pairings that were applied
pathway
{ id, label, stale, carry_flight_types: [ids], pairings } of the saved pathway, or null
experience
how the student would stand against the target's experience requirements after the move (shape below), or null when the target has none
POST /manager/trainings/students/migrate/{enrollmentId}.json — additionally accepts mode, overrides, save_pathway (bool) and pathway_label. With save_pathway the pairings used are saved as the pathway after the move commits (a failed save never undoes the move).
migrate_bulk and version_candidates apply saved pathways automatically (no overrides parameter).
Pathways
A pathway belongs to an exact source course revision and to the target course (its root, COALESCE(version_root_id, id)). It records the target course revision it was saved against; once the target's current revision differs, it is stale and is not applied until saved again. Only hand pairings are stored.
GET /manager/trainings/trainings/pathways/{trainingId}.json — the pathway map around a course:
Edges come from enrollments moved (migrated_from_id) or credited (credited_from_id) and from saved pathways; only edges touching the course are returned. coverage is [matched, total] of the source course against the target's current revision (null when stale). Moves between revisions of one course are counted in revision_moves, not drawn as edges.
GET /manager/trainings/trainings/pathway.json?source={trainingId}&target={trainingId}
POST same URL, JSON { "source", "target", "overrides" } — recompute the match with unsaved edits; writes nothing.
Answer { pathway, source, target, map, overrides, flightTypes }: pathway = { id, source_training_id, target_root_id, target_training_id, label, carry_flight_types, overrides, stale, modified } or null; source / target = { id, name, version, version_status, course } (course outline as in the move preview); flightTypes = [{ id, name }] the target flies. 400 when source and target are the same course revision.
POST /manager/trainings/trainings/pathway_save.json — body { "source", "target", "overrides", "label", "carry_flight_types": [ids] }. Creates or replaces the pathway from source into the course of target. Answer { result, id, code }; codes TARGET_NOT_CURRENT (target is not a CURRENT course revision), SAVE_FAILED.
POST /manager/trainings/trainings/pathway_delete/{pathwayId}.json — { result: true }; 404 when it is not the company's. Enrollments already moved or credited are not touched.
Experience requirements
GET /manager/trainings/trainings/experience/{trainingId}.json → { "requirements": { … } | null }
POST same URL, JSON body below → saves and answers the same. Empty / null = no rule; when no rule is set at all the requirements are removed (requirements: null). Invalid values answer 400.
min_instruction, min_dual, min_solo, min_solo_xc
seconds
minimums
max_fstd
seconds
FSTD time counted towards instruction at most
xc_min_km, xc_min_landings
int
one qualifying solo cross-country trip
alt_certificate_type
string
exact user_certificates.type that qualifies for the alternative minimums
alt_training_id
id
or: completed any course revision of this company course
alt_min_total
seconds
and at least this total aircraft flight time (logged, sims excluded, plus declared user_details.flight_hours)
alt_min_instruction, alt_min_dual
seconds
replace min_instruction / min_dual when the alternative applies
enforce
bool
block completion (automatic and manual) while not met
Requirements are copied with the course (course copy and new course revisions).
Evaluation (ExperienceEvaluation), returned as Experience: { enforce, evaluation } by GET /manager/trainings/students/view/{enrollmentId}.json (null when the course has none) and as experience by the move preview:
Counting rules: the enrollment chain (the enrollment plus every enrollment it was moved or credited from, recursively); confirmed flights logged on missions, each flight once (largest per-enrollment allocation, capped at block time); role from the student's seat and the flight type's seat classification (dual → dual; pic/picus with no other pilot → solo; picus with another pilot → dual; else instruction only); FSTD = aircraft with simulator = 1; only the head enrollment's PRIOR flight-type credits (classified by the flight type's seats; never cross-country). A trip chains solo legs of one day in one aircraft where each leg departs from the previous landing; distance uses airports.lat/lon.
Completion gate. With enforce, StageGateEnforcer::mayComplete() refuses (no auto-finish), and POST /manager/trainings/students/finish / status to COMPLETED answer 400 "This course cannot be completed yet: the experience requirements are not met."
Other payload additions
GET /manager/trainings/students/view/{enrollmentId}.json adds CreditedFrom ({ id, name, version } or null), CreditedInto ([{ id, status, training_id, name, version }]) and TrainingsUser.credited_from_id.
Deploy: migrations/2026-10-15-course-pathways.sql (adds trainings_users.credited_from_id, training_pathways, training_pathway_items, training_experience_requirements) before the backend; aco_sync for Trainings/manager_pathways, manager_pathway, manager_pathway_save, manager_pathway_delete, manager_experience.
Question bank and recovery exams
Lesson codes, subject question bank and extraordinary (recovery) exams. All manager endpoints require a training manager (user group ≤ 135) or, for groups above 135, the teacher of the subject; other groups get 403. Subject, exam and enrollment ids are always checked against the caller's company. Errors answer {message} with the HTTP status (400 bad input, 403, 404).
Lesson code. Lesson.code (≤ 30 chars, unique per subject, case-insensitive) is accepted by POST /manager/trainings/lessons/add|edit as data[Lesson][code] and returned by lesson list/edit. A duplicate fails validation. When a new lesson is created without a code and its subject has a code, the code defaults to <subject code>-<NN> (NN = the lesson's order, two digits, moved on while that code is taken in the subject); it is never recomputed on edit or reorder.
Question tag. training_questions.lesson_code is the lesson code a question is tagged with (nullable). Matching is case-insensitive.
GET /manager/trainings/question_bank/bank/{subjectId}.json — querystring code, untagged=1, q, page, limit (≤ 100). { questions: [{ TrainingQuestion: {id,name,lesson_code,required}, TrainingQuestionOption: [{id,name,value}] }], lessons: [{id,name,code,questions}], untagged, orphans: [{code,questions}], total, page, limit, pages }.
POST /manager/trainings/question_bank/tag.json — { subject_id, question_ids[], lesson_code } ("" clears). The code must belong to a lesson of the subject. { result, updated }.
POST /manager/trainings/question_bank/import.json — { subject_id, rows: [{ name, lesson_code?, options?: [{name, value}] }], exam_id? }, max 5000 rows. A question with the same text (case/space-insensitive) in the subject only gets its tag updated. { result, created, updated, skipped: [{row, name, reason, lesson_code?}] }, reason ∈ unknown_lesson_code, no_correct_option, invalid.
POST /manager/trainings/question_bank/preview.json — { subject_id, lesson_codes[], any_course, include_untagged } → { counts: [{code, questions}], total } (code "" = untagged).
POST /manager/trainings/question_bank/generate.json — { subject_id, lesson_codes[] (empty = whole subject), questions, minutes, score, attempts (default 1), name?, any_course (default true), include_untagged, show_answers }. Creates an exam with extraordinary = 1 (type ONLINE, no training activity, mandatory = 0) linked to the whole matching pool; each attempt draws questions of them balanced across lesson codes. { result, exam_id, pool, untagged, warnings }, or { result: false, message, available } when the pool is too small. any_course also pools questions of subjects of the same company with the same code or template_subject_id.
GET /manager/trainings/question_bank/exams/{subjectId}.json — recovery exams of the subject (Exam.assigned, Exam.pool added).
POST /manager/trainings/question_bank/assign.json — { exam_id, trainings_user_ids[] and/or session_id, mode: ONLINE|PAPER, available_from?, available_until? (unix), missed_session_ids?: {trainingsUserId: sessionId} }. Default window: now → access_window_days. Re-assigning the same mode updates the existing row. { result, created, updated }.
POST /manager/trainings/question_bank/open_to_session.json — { session_id, exam_id }: opens the session lesson's own exam (or a recovery exam of the subject) online to every active student rostered on the session. { result, opened, available_until }.
GET /manager/trainings/question_bank/assignments/{examId}.json, /manager/trainings/question_bank/enrollment/{trainingsUserId}.json — assignment rows: { id, exam_id, trainings_user_id, user_id, session_id, missed_session_id, mode, available_from, available_until, paper_version, paper_score, paper_passed, paper_graded_at, exam, best_attempt, attempts_count, passed, score_value }.
POST /manager/trainings/question_bank/grade_paper.json — { assignment_id, score (0–100), passed } for PAPER assignments.
POST /manager/trainings/question_bank/set_attendance.json — { assignment_id, attendance: "attended_post_class" | "keep" }. Only when the assignment has a missed_session_id and is passed; sets attended_post_class on a missed (absent / absent_justified) register row. Never automatic.
POST /manager/trainings/question_bank/paper.json — { exam_id, assignment_ids[], versions (1–4) } → { meta, sheets: [{ assignment_id, version, questions: [{ id, name, lesson_code, options: [{id,name,correct}] }] }] }. The draw is stored per assignment (paper_questions), so repeating the call returns the same questions; versions B–D reorder questions and options.
GET /manager/trainings/question_bank/session/{sessionId}.json — exams opened/assigned from a class: { exams: [{ exam, assignments: [A + in_progress, user_name] }] }. Used by the class page to show the window, who has not started / is in progress / finished, and the scores.
POST /manager/trainings/question_bank/close_opening.json — { session_id, exam_id }: ends the availability window of the exam's online assignments for that class (available_until = now). Results are kept. { result, closed }.
POST /manager/trainings/question_bank/delete_opening.json — { session_id, exam_id }: soft-deletes the class's assignments of that exam; 400 when any student already has an attempt or a graded paper. A recovery exam left with no assignments and no attempts is deleted too (examDeleted). The session/{id} listing returns results and can_delete per exam.
Only one online exam can be open per class: assign (online, with session_id) and open_to_session answer 400 "Another exam is already open for this class." while another exam's window is open.
GET /trainings/question_bank/my_exams/{trainingsUserId}.json — student: own ONLINE assignments with open (window currently open). Taking the exam uses the normal exam flow (/trainings/exams/start/{enrollmentId}/{examId}); starting a new attempt of an extraordinary exam requires an open assignment, otherwise 403.
Viewing an attempt. GET /trainings/exams/view/{attemptId}.json is allowed for the student, for managers (user group ≤ 150) of the exam's company and for the teacher of the exam's subject; anyone else gets 404. Staff always receive the answers (show_answers = true).
Balanced draw for every exam. exams/start now draws attempt questions grouped by lesson_code (round-robin, shuffled); untagged questions form one more group. With a single group it is a plain random draw, as before.
Last updated