Trainings
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.
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 Attendance), so a re-sign never double-sends either one.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). 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): any enrollment in caller's company.Student-level callers (
user_group_id > 170): 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) 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) 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). 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.
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). 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, 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 Attendance'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.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, 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, signature, remarks, measures, modified, created
from the matching activity_progress row, null if none
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 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), 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 Attendance 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 (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) 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.
Sign Attendance
POST /trainings/onsite/sign_class.json
Digitally sign class attendance (teacher confirmation).
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) 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) 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
value=1butsignatureis empty.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.
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) — 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). 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) — 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) 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
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)
Always
Always
The student who submitted the row
Until sessions.classwork_deadline passes or the work is graded, whichever is first
Never
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 keeps the older rule — a student may add to it but never withdraw it. The reviewer lookup ignores the ownership narrowing that normally restricts user_group_id > 170 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. 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.
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) 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/remote 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 each question also includes TrainingQuestionOption (the answer options). For user_group_id > 150 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).
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: a caller with user_group_id > 170 may only request their own enrollment (400 Incorrect enrollment id requested otherwise); user_group_id <= 170 may request any enrollment id.
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).
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(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 managers (user_group_id100/135/150), denied for instructors (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.
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) 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.
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 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 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 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.
Last updated