Skip to content

Trainings

Role names and user_group_id values are listed in User groups.

v2 schema. The trainings plugin uses a unified-activities schema: training_activities orders lessons + exams together (drag-drop ordering), activity_progress is the single source of truth per (enrollment, activity), and exams covers both ONLINE and ONSITE (column type). Sessions (renamed from lesson_classes) bind to a single training_activity_id. Attendance is recorded per-session on session_students.attendance_status (attended, attended_post_class, absent, absent_justified, or NULL when not yet decided); progress % comes from activity_progress.value. The two are independent (a student can have attended a paper exam but not passed it). activity_progress is 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_status is the per-session record.

Training types. trainings.type is ONSITE or DISTANCE. The former REMOTE type was retired — it always behaved as ONSITE — and existing rows were converted. A write that still posts type: "REMOTE" is accepted and stored as ONSITE; reads never return REMOTE.

Schema additions — missed-class follow-up (task #514). session_students also gained missed_email_sent_at and attendance_notified_at (both unix, NULL until 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). trainings gained attendance_signature_days (smallint, 0 = no closing date, the default) and attendance_signature_from (unix, NULL = no cut-off) — the latter stamped by Training::beforeSave on the OFF -> ON transition and backfilled for already-enabled courses, so the requirement never applies to classes taught before a course adopted it. session_students gained signature (JSON {time, ip, browser, hash}, NULL until the student countersigns — see Sign Own Attendance) and signature_requested_at (unix, the claim-before-send guard for the please-sign message raised by Store Attendance). These replace activity_progress.signature for per-session signing: that row is UNIQUE (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.

sessions gained allow_classwork_upload, classwork_deadline, classwork_description, classwork_notified_at (see Request Classwork) and justification_deadline (see Schedule Class). trainings gained classwork_deadline_default_days and justification_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 unrestricted Training.* write and are not otherwise documented here. A new session_justifications table (id, session_student_id, upload_id, text, status ENUM('pending','approved','rejected'), reviewed_by, reviewed_at, review_note, created, modified) backs Justifications / Submit Justification / Review Justification below. A new session_classwork_grades table (id, session_student_id UNIQUE, upload_id, score decimal(3,1), feedback, graded_by, graded_at, created, modified) backs Classwork Grades / Rate Classwork below.

user_group_id = 175 is the ground-school teacher of a flight school (offered by GET /users/user_groups.json to companies.type = 'school' only). It sits between Flight Instructor (170) and Captain (180), so every rule written as user_group_id <= 170 (“Flight Instructor and above”) leaves it out. A Theory Knowledge Instructor never has more access than a Flight Instructor.

A course they teach = the caller is the teacher of one of the course’s subjects (training_subjects.teacher_id) or of one of its class sessions that is not cancelled (sessions.teacher_id).

Endpoint What a Theory Knowledge Instructor gets
GET /manager/trainings/trainings/index.json Only the courses they teach (empty list if none).
GET /manager/trainings/students/list/training:{id}.json The students of a course they teach; {"students": []} for any other course.
GET /manager/trainings/students/view/{enrollmentId}.json The record of a student of a course they teach; 404 otherwise. Read-only.
GET /trainings/students/report/{enrollmentId}.json, GET /trainings/trainings/certificate/{enrollmentId}.json Full report / certificate download for a student of a course they teach in their company; the verification subset otherwise.
GET /trainings/students/summary/{enrollmentId}.json, GET /trainings/students/subject_report/{subjectId}/{userId}.json Any student of a course they teach; otherwise their own only.
POST /manager/trainings/onsite/class.json Create / edit classes of a course they teach; 404 otherwise.
POST /manager/trainings/onsite/store_exam_result.json Record onsite exam results for a course they teach; 404 otherwise.
Class attendance, class signature, homework grading, absence justifications Same as any teacher: allowed on the sessions where they are the teacher (session or subject teacher).
GET /manager/trainings/subjects/teachers.json Listed as a selectable teacher.

ACL-denied (403 ACL_DENIED): every mutating Students/manager_* action (enroll, edit, status, notes, finish, reset, unroll, migrate*, prior), course and subject editing, stages, stage checks, mission authorizations, approvals, intakes, requirements, Trainings/manager_certificates, the flight-instructor pages (/manager/pilots/fi_assignments, /schedules/fi_availabilities, /manager/flights/audit, /manager/flights/annuals), /manager/users/*, pilot groups and maintenance inventory. Rule: the group copies Flight Instructor’s grants and is denied every action a Flight Instructor may use and a Pilot (190) may not, except the read-only student pages above.

Outside trainings a Theory Knowledge Instructor has the access of a line pilot: own flights, own schedule, own documents. They cannot create or confirm flights unless the school ticks them under Company settings → Flights → Who can create / confirm flights (company_permissions), like Captains and Pilots.

GET /trainings.json

Retrieve the authenticated user’s training enrollments, teaching assignments, and upcoming classes. Paginated (30 per page).

Parameter Type Required Description
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 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 of TrainingSubject rows for the training. The full TrainingSubject list is no longer included — fetch it from the training-view endpoint when needed.
  • Training.Lessons (only when Training.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=1 AND every mandatory lesson-gate exam attached to it has value=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 when Training.flights == true): { "completed": <int>, "total": <int> } — distinct TrainingFlight templates 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 by subjects_count.
  • Top-level LessonClass per row (the flattened upcoming-class list) — query the calendar endpoint or the training-view endpoint instead.
  • Training.Phase and Training.LastFlight — removed for performance. Use the manager students-list endpoint when you need them.
{
"trainings": [
{
"Training": {
"id": "10",
"active": true,
"type": "DISTANCE",
"name": "PPL Ground School",
"validity": null,
"flights": false,
"flights_count": "0",
"theory": true,
"time_online": true,
"training_id": "10",
"enrollment_id": "500",
"enrollment": "1714003200",
"supervisor_id": "101",
"flight_phase": null,
"status": "ACTIVE",
"status_reason": null,
"finish_date": 0,
"subjects_count": 8,
"Lessons": {
"total": 12,
"completed": 5,
"finished": false
}
}
},
{
"Training": {
"id": "11",
"type": "ONSITE",
"name": "CPL Flight Phase",
"flights": true,
"theory": false,
"training_id": "11",
"enrollment_id": "501",
"subjects_count": 0,
"FlightProgress": { "completed": 4, "total": 18 }
}
}
],
"teacher": [
{
"TrainingSubject": {
"id": "20",
"code": "MET",
"name": "Meteorology",
"hours": "40",
"lessons": "12"
},
"Training": { "name": "PPL Ground School", "id": "10" }
}
],
"myClasses": [
{
"Session": {
"id": "300",
"training_activity_id": "act-50",
"datetime": "2025-03-15 09:00:00",
"status": "scheduled",
"teacher_id": "101",
"signature": null,
"minutes": "120",
"location_id": "5",
"remarks": ""
},
"TrainingActivity": {
"id": "act-50",
"kind": "LESSON",
"lesson_id": "50",
"exam_id": null,
"training_subject_id": "20"
},
"Lesson": {
"name": "Cloud Formation",
"training_subject_id": "20",
"TrainingSubject": { "name": "Meteorology" }
},
"Exam": null,
"Teacher": {
"id": "101",
"UserDetail": { "name": "Maria", "surname": "Garcia", "id": "101" }
}
}
]
}

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.

Training.id, name, type, active, theory, flights, flights_count, competencies, metrics, announcement, start, end, color, created, plus:

  • students_count (int): total trainings_users rows for the training (every status, not deduplicated by user).
  • subjects_count (int): non-deleted training_subjects rows for the training.

The previous Student and TrainingSubject association arrays are no longer returned — use the counts instead.

{
"trainings": [
{
"Training": {
"id": "10",
"name": "PPL Ground School",
"type": "DISTANCE",
"active": true,
"theory": true,
"flights": false,
"flights_count": "0",
"competencies": false,
"metrics": false,
"announcement": null,
"start": null,
"end": null,
"color": "#1f77b4",
"created": "2025-01-15",
"students_count": "24",
"subjects_count": "8"
}
}
]
}

GET /trainings/trainings/view/{id}.json

Retrieve full details for a specific training enrollment.

Parameter Type Description
id string Training ID
{
"training": {
"TrainingsUser": {
"id": "500",
"training_id": "10",
"user_id": "123",
"supervisor_id": "101",
"finish_before": "2025-12-31",
"status": "ACTIVE",
"status_reason": null,
"status_changed": null,
"validity": null,
"flight_phase": null,
"notes": null,
"created": "2025-01-15",
"modified": "2025-03-10"
},
"Training": {
"id": "10",
"name": "PPL Ground School",
"type": "ground",
"active": true,
"theory": true,
"flights": false,
"competencies": false,
"metrics": false,
"auto_finish": true,
"allow_auto_restart": false,
"validity": 31536000,
"description": "Private Pilot Licence ground theory",
"TrainingSubject": [
{
"id": "20",
"code": "MET",
"name": "Meteorology",
"hours": "40",
"lessons": "12",
"training_id": "10",
"Lesson": [
{ "id": "50", "minutes": "120", "training_subject_id": "20", "mandatory": true }
],
"Exam": [],
"TrainingActivity": [
{ "id": "act-50", "kind": "LESSON", "lesson_id": "50", "exam_id": null, "order": 10, "mandatory": true }
],
"Progress": { "total": 12, "completed": 5, "finished": false, "lessons": {} }
}
],
"TrainingFlight": [],
"Manager": {
"id": "100",
"UserDetail": { "name": "Admin", "surname": "User", "id": "100" }
}
},
"User": {
"id": "123",
"UserDetail": { "name": "John", "surname": "Doe", "id": "123" }
},
"Supervisor": {
"id": "101",
"email": "maria@example.com",
"UserDetail": { "name": "Maria", "surname": "Garcia", "photo": null, "id": "101" }
},
"Function": "student",
"weeklytime": [
{ "week_name": "Week 10", "year": "2025", "week": "10", "week_time": "480" }
],
"Progress": { "total": 12, "completed": 5, "finished": false },
"TrainingFlightTypes": {},
"ExamAttempt": [
{
"id": "0b06c152-…",
"exam_id": "6305fe63-…",
"training_subject_id": "60288e6f-…",
"start": "1599429600",
"finish": "1599429600",
"status": "FINISHED",
"score": "30",
"passed": false,
"Exam": {
"id": "6305fe63-…",
"name": "PPL",
"type": "ONSITE",
"training_subject_id": "60288e6f-…",
"training_subject_lesson_id": null
}
}
]
},
"performedFlightTrainings": []
}
  • ExamAttempt lists every attempt of this enrollment, online and onsite, oldest first. The per-subject Exam list in Training.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 under Exam, not wrapped in ExamAttempt.ExamAttempt.
  • Onsite exams appear twice by design: once here as an attempt, once as the activity_progress row on their TrainingActivity. Clients that render a single history (the activity timeline does) should keep the activity-progress copy and skip attempts whose Exam.type == "ONSITE".
  • TrainingSubject[].Lesson[] carries id, name and minutes.

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.

Parameter Type Description
id string Training UUID
{
"schema": {
"subjects_total": 4,
"lessons_total": 38,
"lessons_per_subject": [
{ "id": "20", "name": "Meteorology", "lessons": 12, "hours": 24.5 }
],
"ground_hours_total": 76.0,
"missions_total": 22,
"flights_per_flight_type": [
{ "id": "1", "name": "Dual", "missions": 14, "hours": 21.5 },
{ "id": "2", "name": "Solo", "missions": 8, "hours": 12.0 }
],
"flight_hours_total": 33.5
}
}

Notes:

  • ground_hours_total derived from SUM(Lesson.minutes) / 60 across non-deleted lessons in non-deleted subjects.
  • flight_hours_total derived from SUM(TrainingFlight.hours) across non-deleted flight missions.
  • flights_per_flight_type entries grouped by TrainingFlight.flight_type_id; each row carries both missions (count) and hours (sum).
Status Reason
400 Missing training id
404 Training not found in caller’s company

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.

Parameter Type Description
enrollmentId string TrainingsUser.id (UUID)
  • Manager-level callers (user_group_id <= 170 — Flight Instructor and above): any enrollment in caller’s company.
  • Theory Knowledge Instructors (175): their own enrollment, plus any enrollment in a course they teach — 403 otherwise.
  • Student-level callers (user_group_id > 170 — Captain and below): only their own enrollment — 403 otherwise.
{
"result": {
"training_id": "1a2b...",
"enrollment_id": "9f8e...",
"user_id": "42",
"status": "ACTIVE",
"status_reason": null,
"theory": {
"progress": { "total": 38, "completed": 21, "percentage": 55 },
"attendance": { "given": 40, "taken": 31, "percentage": 77 }
},
"flight": {
"progress": { "total": 22, "completed": 9, "percentage": 41 },
"attendance": { "attempts": 14, "passed": 9, "percentage": 64 },
"hours": { "planned": 33.5, "flown": 18.2 }
}
}
}
  • theory.progress — derived from Training::getProgress (mandatory training_activities in scope, all subjects). completed includes LESSON activities marked attended and EXAM activities passed (gate exams gate the corresponding lesson’s completion). percentage = floor(completed * 100 / total).
  • theory.attendance — derived from Training::getAttendance. given = session_students.invited = 1 rows whose sessions.training_activity is subject-scoped and sessions.datetime < now(), less rows with attendance_status = absent_justified — a justified absence is neutral for the rate. taken = subset where attended = 1 (a justified absence is never attended, so it never adds progress). percentage = floor(taken * 100 / given).
  • For Training.type == "DISTANCE" the attendance block mirrors progress (no live sessions exist for distance training).
  • flight is null when Training.flights == false.
  • flight.progress — total = count of TrainingFlight templates in the training. completed = distinct templates with at least one UserTrainingFlight.completed = 1 row (flight status = LANDED). Same logic as Training.FlightProgress in My Trainings.
  • flight.attendance — per-mission attempt counts across all user_training_flights for 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) / 3600 over flights with status = LANDED linked through user_training_flights. Rounded to one decimal.
  • status mirrors TrainingsUser.status — one of ACTIVE, COMPLETED, STOPPED, FAILED, EXPELLED. status_reason is the free text a manager gave when closing the enrollment, null otherwise. See Enrollment status.
Status Reason
400 Missing enrollment id
403 Student requesting another user’s enrollment
404 Enrollment not found / Training not found in caller’s company

GET /trainings/subjects/mine.json

Retrieve subjects assigned to the authenticated user (as student).

[
{
"Training": { "id": "10", "name": "PPL Ground School" },
"TrainingSubject": [
{ "id": "20", "code": "MET", "name": "Meteorology", "teacher_id": "101", "training_id": "10" }
]
}
]

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:

  • Exam is filtered to subject-scope rows only (training_subject_lesson_id IS NULL); lesson-gate exams are nested under their parent lesson elsewhere. Both ONSITE and ONLINE types are included.
  • TrainingActivity is the unified activity ordering. The endpoint backfills missing TrainingActivity rows for any pre-v2 lesson/exam on read, undeleting any soft-deleted row that reuses the same lesson_id/exam_id (the unique keys ignore the deleted flag, so a fresh insert would 1062-collide). When a backfill happens the activity list is re-fetched so the response is consistent.
  • total_minutes is the sum of mandatory lesson minutes.
  • The teacher picker list is no longer part of this response — fetch it from /manager/trainings/subjects/teachers.json (see below).
{
"subject": {
"TrainingSubject": {
"id": "20",
"name": "Meteorology",
"code": "MET",
"description": "Weather theory for pilots",
"hours": "40",
"teacher_id": "101",
"active": true,
"total_minutes": 2400
},
"Lesson": [
{ "id": "50", "name": "Cloud Formation", "minutes": "120", "order": "1", "mandatory": true }
],
"Exam": [
{ "id": "e-9", "name": "Final", "type": "ONSITE", "mandatory": true, "order": "1" }
],
"TrainingActivity": [
{ "id": "act-50", "kind": "LESSON", "lesson_id": "50", "exam_id": null, "order": "1", "mandatory": true },
{ "id": "act-e9", "kind": "EXAM", "lesson_id": null, "exam_id": "e-9", "order": "2", "mandatory": true }
],
"Teacher": { "id": "101" }
}
}

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_group_id <= 170 (Flight Instructor and above) plus Theory Knowledge Instructors (175), grouped by user-group name.

The payload is a Hash::combine of id → "name surname", grouped by user-group name.

{
"users": {
"Instructor": {
"101": "Maria Garcia",
"102": "John Smith"
},
"Chief Instructor": {
"55": "Anna Lopez"
}
}
}

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.

Field Type Required Description
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.

{ "result": true }

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 → the LESSON activity for that lesson plus every EXAM activity bound to a gate exam (exams.training_subject_lesson_id = lesson_id).
  • exam_id → the single EXAM activity 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.

Field Type Required Description
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).

{
"result": true,
"impact": {
"activityIds": ["act-50", "act-e50"],
"studentsCompleted": 12,
"studentsTouched": 17,
"examAttempts": 34,
"sessions": 2
}
}
  • studentsCompleted — distinct enrollments with ActivityProgress.value = 1 on any of the affected activities (the count that needs warning).
  • studentsTouched — distinct enrollments with any ActivityProgress row on the affected activities (>= studentsCompleted).
  • examAttempts — total ExamAttempt rows that reference the affected exams (audit context).
  • sessions — total TrainingSession rows 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.


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).

Field Type Required Description
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.

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.


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 Lesson payload and a Sessions[] list — one entry per TrainingSession under that activity where the student has a SessionStudent row, ordered by Session.datetime ASC. Each entry has Session: {id, datetime, minutes} and SessionStudent: {id, invited, attended}. attended is resolved from the matching ActivityProgress.value (joined by session_id) when an ActivityProgress row exists for that session — that is where OnsiteController::attendance writes attendance. Sessions without a matching ActivityProgress row fall back to the raw session_students.attended column.
  • EXAM activities carry the Exam payload with its ExamAttempt[] list for this enrollment (newest first). Onsite-exam ActivityProgress rows are sorted by Session.datetime ascending.

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.

{
"trainingsUserId": "500",
"displayDetails": false,
"enrollment": { "TrainingsUser": { "id": "500", "user_id": "900", "training_id": "10" } },
"subject": {
"TrainingSubject": {
"id": "20",
"training_id": "10",
"name": "Meteorology",
"code": "MET",
"teacher_id": "101"
},
"Training": {
"id": "10",
"name": "PPL Ground School",
"type": "DISTANCE",
"time_online": true,
"active": true,
"allow_auto_restart": true
},
"TrainingActivity": [
{
"TrainingActivity": { "id": "act-50", "kind": "LESSON", "lesson_id": "50", "exam_id": null, "order": 1, "mandatory": true },
"Lesson": { "id": "50", "name": "Cloud Formation", "minutes": 120 },
"ActivityProgress": [
{ "id": "ap-1", "value": 1, "time": 1800, "session_id": "s-1", "created": "2025-03-01 09:00:00", "Session": { "id": "s-1", "datetime": "2025-03-01 09:00:00" } }
],
"Sessions": [
{ "Session": { "id": "s-1", "datetime": "2025-03-01 09:00:00", "minutes": 60 }, "SessionStudent": { "id": "ss-1", "invited": true, "attended": true } },
{ "Session": { "id": "s-2", "datetime": "2025-03-08 09:00:00", "minutes": 60 }, "SessionStudent": { "id": "ss-2", "invited": true, "attended": false } }
]
},
{
"TrainingActivity": { "id": "act-e50", "kind": "EXAM", "lesson_id": null, "exam_id": "e-50-gate", "order": 2, "mandatory": true },
"Exam": {
"id": "e-50-gate",
"name": "Cloud Formation — gate",
"type": "ONLINE",
"attempts": 2,
"score": 75,
"questions": 10,
"minutes": 15,
"training_subject_id": "20",
"training_subject_lesson_id": "50",
"ExamAttempt": [
{ "id": "att-2", "start": 1741420000, "finish": 1741420900, "passed": true, "status": "FINISHED", "score": 80, "created": "2025-03-08 10:00:00" }
]
},
"ActivityProgress": []
},
{
"TrainingActivity": { "id": "act-e9", "kind": "EXAM", "lesson_id": null, "exam_id": "e-9", "order": 99, "mandatory": true },
"Exam": { "id": "e-9", "name": "Final", "type": "ONSITE", "training_subject_id": "20", "training_subject_lesson_id": null, "ExamAttempt": [] },
"ActivityProgress": [
{ "id": "ap-2", "value": 1, "score": "85", "code": "PASS", "session_id": "s-3", "created": "2025-03-15 10:00:00", "Session": { "id": "s-3", "datetime": "2025-03-15 10:00:00" } }
]
}
],
"Progress": { "total": 12, "completed": 5, "finished": false, "lessons": {} }
}
}

Legacy fields subject.Lesson[] and subject.Exam[] are no longer emitted by this endpoint — consume subject.TrainingActivity[] and branch on TrainingActivity.kind.


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" }.

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).

{
"families": [
{
"id": "5f0c…",
"name": "Air Law",
"members": [
{ "subject_id": "…", "name": "Air Law", "code": "010", "hours": "20.00", "source": true,
"training_id": "…", "training_name": "PPL(A)", "training_version": "1" }
]
}
],
"candidates": [ { "subject_id": "…", "name": "Meteorology", "code": "MET", "hours": "8.00", "training_id": "…", "training_name": "SPL", "training_version": "1" } ]
}

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).

Field Type Required Description
subject_id string Yes Subject to copy (same company)
training_id string Yes Destination course (same company, not superseded)
{ "result": true, "subject": { "id": "…", "name": "Air Law", "training_id": "…", "subject_family_id": "…", "family_source": false } }

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.

Field Type Required Description
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.

{
"confirmed": false,
"match": {
"pairs": { "LESSON": [["l-src", "l-tgt"]], "SLIDE": [[12, 40]] },
"added": [ { "entity": "LESSON", "id": "l-extra" } ],
"removed": [ { "entity": "EXAM", "id": "e-local" } ]
},
"source": { "id": "…", "name": "…" }, "target": { "id": "…", "name": "…" }
}

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.

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 }.

POST /manager/trainings/subjects/sync_preview.json

Read-only. What pushing the source’s content would do to each other live member.

Field Type Required Description
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)
{
"source": { "subject_id": "…", "name": "Air Law", "training_name": "PPL(A)" },
"targets": [
{
"subject_id": "…", "training_id": "…", "training_name": "SPL", "training_status": "CURRENT",
"counts": { "update": 2, "add": 1, "conflict": 1, "removed_in_source": 0 },
"ops": [
{ "op": "update", "entity": "LESSON", "fid": "…", "label": "Privileges",
"source_row_id": "…", "target_row_id": "…", "parent_fid": null,
"changes": [ { "field": "minutes", "from": "60", "to": "90" } ] }
]
}
]
}

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.

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.

Field Type Required Description
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.

{
"results": [
{ "subject_id": "…", "result": true,
"applied": { "update:LESSON": 2, "add:LESSON": 1, "kept": 1 },
"unresolved": [], "removed_in_source": [] },
{ "subject_id": "…", "result": false, "error": "Not a syncable member of this family" }
]
}

GET /trainings/lessons/view/{lessonId}/{enrollmentId}.json

Retrieve lesson details and attendance records.

  • lesson: the lesson with TrainingSubject (incl. Training) and LessonSlide ids.
  • enrollment: the validated TrainingsUser.id (or null when no enrollment was passed).
  • attendance: this student’s ActivityProgress rows for the lesson’s LESSON activity (User.UserDetail joined for display).
  • testAuthorization: true when the student may take the lesson-gate test (has slides done, or no slides / no time-online requirement).
  • nextActivity: the next TrainingActivity in the same subject, ordered by TrainingActivity.order. May be a LESSON or an EXAM — the response carries either an embedded Lesson or Exam payload accordingly. null when 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):

{
"nextActivity": {
"TrainingActivity": {
"id": "act-e50",
"kind": "EXAM",
"lesson_id": null,
"exam_id": "e-50-gate",
"order": 55,
"mandatory": true
},
"Exam": {
"id": "e-50-gate",
"name": "Cloud Formation — gate",
"type": "ONLINE",
"attempts": 2,
"score": 75,
"questions": 10,
"minutes": 15,
"training_subject_id": "20",
"training_subject_lesson_id": "50"
}
}
}

Replaced the legacy nextLesson field. Clients that read nextLesson should switch to nextActivity and branch on nextActivity.TrainingActivity.kind.

GET /trainings/lessons/slides/{lessonId}.json

Retrieve e-learning slides for a lesson.

GET /trainings/lessons/slide/{slideId}.json

Retrieve a single slide with content and navigation.

POST /trainings/lessons/complete.json

Mark a lesson’s slides as completed. Sent as application/x-www-form-urlencoded.

Field Type Required Description
lessonId string Yes Lesson ID
timeSpent number Yes Time spent in seconds
enrollmentId string Yes Enrollment ID
{
"result": true,
"finished": false,
"trainingId": "10",
"next": {
"title": "Wind Patterns",
"action": "lesson",
"id": "51",
"enrollmentId": "500"
}
}

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.

{
"enrollmentId": "500",
"lessonId": "50"
}
Field Type Required Description
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)

Section titled “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.attempts and no attempt passed.

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 ActivityProgress row is upserted with reset_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.
  • ExamAttempts rows are not deleted. The manager student-view surfaces them with a reset: true flag 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 where ExamAttempt.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.

{
"result": true,
"message": "You can now restart the lesson."
}

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.

GET /trainings/lessons/attendance/{subjectId}.json

Retrieve attendance records for all lessons in a subject.


GET /trainings/onsite/class/{id}.json

Retrieve class session details including attendance.

Response class payload contains:

  • Session, Teacher, SessionStudent, Location.
  • teacher — true when 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 definition attendance() independently recomputes below, so the two never disagree.
  • enrollment — the requester’s TrainingsUser.id for this training, or null. 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=null is what the client uses to explain the missing progress/evaluation in that case. Below user_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 to attendance(); see Attendance below for the full per-row shape and its own access rule.
  • Session.signature, when present, has its server-only statuses baseline stripped (and the same key inside every history entry) — see Sign Class’s note on statuses. This action has no teacher/student gate on the roster it exposes, so leaving statuses in 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.TrainingSubject including Training and Training.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 (when kind=LESSON) with LearningObjective[] — objectives attached to the lesson (id, name, description, training_subject_lesson_id), ordered by name.
    • TrainingActivity.Exam (when kind=EXAM) — includes type and access_mode, so the session page knows to render the student exam-taking panel for a SCHEDULED online exam.
  • attendances — ActivityProgress rows for the session (scoped to the requesting user unless teacher/manager).

Alongside class, the response carries:

  • classwork_submitted — true when the requester has an active SessionClasswork upload 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 after trainings.attendance_signature_from. Clients must gate the signature UI and the register’s SIGNED column on this, not on Training.attendance_signature, which alone would render classes taught before the rule as unsigned.
  • classwork_grade — the requester’s own homework grade for this session, or null when 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.

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.

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:

Field Description
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”
absence_notified_at Unix timestamp when the student gave advance notice of absence (Notify Absence), or null. Kept after the teacher changes the status
absence_reason Reason given with the notice, or null
can_notify_absence true when the caller may give notice now. Own row only; null on everyone else’s
can_withdraw_absence true when the caller may withdraw their notice now. Own row only; null on everyone else’s
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.

POST /manager/trainings/onsite/class.json

Create or update a class session. Sent as application/x-www-form-urlencoded.

A Theory Knowledge Instructor (175) may only create or edit classes of a course they teach; any other activity or class id answers 404.

Field Type Required Description
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.

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.

Field Type Required Description
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.

{ "result": true }

GET /manager/trainings/onsite/delete_class/{classId}/true.json

Delete a scheduled class session.

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.

Param Type Required Description
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.
{
"sessions": [
{
"id": "s-1",
"datetime": 1767225600,
"minutes": 90,
"status": "closed",
"training_id": "t-1",
"training": "B737 Recurrent",
"subject_id": "sub-1",
"subject": "Performance",
"kind": "LESSON",
"activity": "Takeoff Performance",
"teacher_id": "u-9",
"teacher_name": "Jane Doe"
}
],
"users": [
{
"user_id": "u-1",
"user_name": "John Smith",
"user_group": "Pilot",
"sessions_invited": 5,
"sessions_attended": 4,
"minutes_invited": 450,
"minutes_attended": 360
}
],
"filters": { "start": 1767225600, "end": 1769904000, "training_id": "t-1" }
}
  • sessions[] — every TrainingSession whose datetime falls in [start, end] (any status, future or past within the window). Sorted by datetime ASC.
  • users[] — aggregated from activity_progress rows attached to the matched sessions. Every distinct user_id with an activity_progress row is included (invited set).
    • sessions_invited / minutes_invited — count and sum of Session.minutes across every activity_progress row for the user.
    • sessions_attended / minutes_attended — subset where activity_progress.value is truthy.
  • Users sorted alphabetically by user_name.
Status Body When
400 Missing start or end date Path param null
400 Invalid start or end date strtotime returned false / 0

GET /manager/trainings/onsite/sessions.json

List scheduled class sessions in a date range. Company-scoped via Training.company_id.

Param Type Required Description
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.
{
"sessions": [
{
"id": "s-1",
"start": "2026-05-19T09:00:00+00:00",
"end": "2026-05-19T10:30:00+00:00",
"duration_minutes": 90,
"kind": "LESSON",
"activity": "Takeoff Performance",
"training": "B737 Recurrent",
"teacher": { "id": "u-9", "name": "Jane", "surname": "Doe" },
"location": "Classroom A"
}
],
"filters": {
"start": 1747526400,
"end": 1748217599,
"timeZone": "UTC",
"training": null
}
}
  • sessions[] — every TrainingSession whose datetime falls in [start, end]. Sorted by datetime ASC.
  • start / end — ISO 8601 with offset, formatted in the supplied timeZone. end = start + duration_minutes.
  • kind — LESSON or EXAM from the bound TrainingActivity.
  • activity — the Lesson.name (kind=LESSON) or Exam.name (kind=EXAM).
  • training — Training.name.
  • teacher — Teacher.UserDetail.name / surname; id is the user id.
  • location — Location.name, empty string if the session has no location.
Status Body When
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

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

Field Type Required Description
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.

{ "store": true, "grades": [ { "session_student_id": "ss-1", "user_id": "u-1", "value": 1 } ] }

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.

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.

Field Type Required Description
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 from value).
  • A reminder + frontend link to sign their own attendance, if the student attended and session_students.signature is empty — and only when the training has attendance_signature = 1. (This previously read activity_progress.signature, which is per-activity, and was sent regardless of the training setting.)
  • The next TrainingActivity in the same training_subject_id (by order). If a future Session exists 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 Exam of type=ONLINE and the student attended this one, an extra line linking to FRONTEND_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.

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 has attendance_signature = 1, the session is at or after trainings.attendance_signature_from (or that column is NULL), and the roster row’s attendance_status is attended or attended_post_class. Classes taught before a course adopted the requirement are therefore never reported as outstanding.
  • debriefing — user_training_flights.signature IS NULL on a course with require_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 own date, falling back to the row’s created.
Query Description
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.

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.

{ "pending": [ {
"type": "attendance",
"session_student_id": "123", "session_id": "…", "flight_id": null, "session_datetime": 1789000000,
"training_id": "…", "training_name": "ATPL(A) - INTEGRATED",
"subject_name": "Air Law", "item_name": "Lesson 1", "kind": "LESSON",
"user_id": "42", "user_name": "Ada Lovelace",
"attendance_status": "attended", "requested_at": null, "can_still_sign": true
} ] }

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.

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.

Field Type Required Description
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 reports not_required — not window_closed, and never “outstanding”. NULL means 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_status must be attended or attended_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_days is opt-in: 0 (the default) means a student may sign at any time; > 0 closes the window that many days after Session.datetime (window_closed).
  • The teacher having signed the class is irrelevant. sessions.signature is 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.

{ "update": { "time": 1758200000 } }

null if nothing was stored. The ip, browser and hash persisted alongside are never returned.

Status Message
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

POST /trainings/onsite/unsign_class.json

Remove digital signature from a class.

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.

grades[], ordered by graded_at DESC:

Field Description
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

POST /trainings/onsite/rate_classwork.json

Grade one student’s homework.

Field Type Required Description
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.

{ "result": true, "grade": { "id": "...", "user_id": 490, "upload_id": null, "score": 8.5, "feedback": "...", "graded_by": 618, "graded_at": 1788517402 } }

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.

justifications[], ordered by created DESC:

Field Description
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

POST /trainings/onsite/notify_absence.json

A student gives advance notice that they will not attend a class they are on the roster for — or withdraws a notice they gave. Attending is the default; this is the only action a student takes when they cannot make it.

Field Type Required Description
session_id string Yes sessions.id
reason string No Optional reason shown to the teacher. Trimmed, max 500 characters (longer input is truncated)
withdraw bool No 1/true withdraws the caller’s notice instead of giving one

Access. Applies to the caller’s own roster row only. Company-scoped via the session (404 Training Session not found). No session_students row for the caller (after pilot-group invites are expanded) → 404 Not on this session roster. ACL: Student Pilot and other groups inheriting the root allow; Captain (180), Cabin Crew (240) and Specialist (245) are granted explicitly; Auditor (250) is not.

Guards (AbsenceNoticePolicy, read under a SELECT ... FOR UPDATE lock on the session_students row). Refused with 400 Absence notice not allowed: <reason> where <reason> is one of:

Reason Meaning
not_enrolled The caller has no trainings_users row for this training (invitee only)
closed Session is signed, closed or canceled
ended Class has ended — datetime + session minutes (falls back to the lesson/exam length; with neither, the class start)
already_marked Notify only: attendance_status is not NULL — the teacher already recorded something
not_notified Withdraw only: no notice was given, or the status in force is no longer absent
justification_pending Withdraw only: a pending justification exists for this absence

Effect. Notify sets session_students.attendance_status = absent, attended = 0, absence_notified_at = now, absence_reason. Withdraw resets attendance_status to NULL and clears both notice columns. activity_progress is not touched (unmarked and absent both count as not present). The session teacher and the subject teacher (if different, and never the caller) receive an in-app message, non-urgent — no push or email. Because the status is now absent, Submit Justification accepts a justification immediately.

The teacher keeps the last word: Store Attendance may overwrite the status as usual, and absence_notified_at/absence_reason are kept as the record that notice was given.

{ "notice": { "attendance_status": "absent", "absence_notified_at": 1792224000, "absence_reason": "Medical appointment" } }

On withdraw all three values are null.

POST /trainings/onsite/submit_justification.json

A student submits a justification for their own absence.

Field Type Required Description
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.

{ "saved": true }

POST /trainings/onsite/review_justification.json

Approve or reject a pending justification.

Field Type Required Description
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.

{ "saved": true, "newStatus": "absent_justified" }

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.

Field Type Required Description
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.

{ "saved": true, "newStatus": "absent_justified" }

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 = true for that session, and
  • no active SessionClasswork upload 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 is 403 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.

Action Reviewer Anyone else (students included)
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.

Action Managers / Chief Pilot, same company Anyone else
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”.


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 /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

Param Type Required Description
classId string Yes TrainingSession.id
userId string Yes Student User.id

Auth / scope

  • Caller’s company_id must match Training.company_id.
  • Training.active must be true.

Response 200

{
"attendanceRecord": {
"ActivityProgress": {
"id": "...",
"remarks": "...",
"measures": "...",
"value": 0,
"time": 60
},
"UserTrainingObjective": [
{ "objective_id": "12", "rating": "STD" }
],
"UserTrainingMetric": [
{ "metric_id": "7", "rating": "3", "comments": null }
]
}
}

Errors

Status Message Cause
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

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_id must match Training.company_id.
  • Training.active must be true.
  • The ActivityProgress row for (class_id, student_id) must already exist — call get_student_evaluation first to create it lazily.

Body (application/x-www-form-urlencoded)

Field Type Required Description
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

class_id=abc-123
student_id=42
remarks=Good progress on stalls
measures=Review crosswind landings
objectives[12]=STD
objectives[13]=+
metrics[7][rating]=3
metrics[7][comments]=Smooth flare
metrics[8]=N

Response 200

{
"result": true,
"updateData": {
"id": "...",
"modified": 1715500000,
"remarks": "...",
"measures": "...",
"value": 1
},
"objectives": [ { "UserTrainingObjective": { "id": "..." } } ],
"metrics": [ { "UserTrainingMetric": { "id": "..." } } ]
}

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

Status Message Cause
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

The exams table now holds both ONLINE and ONSITE exams. Each row carries:

  • type enum: ONLINE (auto-graded via exam_attempts) or ONSITE (graded after a session via activity_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.

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):

Filter Returns
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_id must match the caller’s company.
  • Exam.deleted = false (soft-deleted exams never returned).
  • Training.active is not enforced — exams on archived trainings are returned so managers can still maintain them. The per-row available flag 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).

Online exams carry access_mode:

  • FREE (default) — current behaviour: takeable freely once the prerequisites above are met.

  • SCHEDULED — gated behind a scheduled session. available additionally requires an active session for this exam and the requesting student, where:

    • the session’s TrainingActivity points at this exam (kind=EXAM, exam_id=this), and
    • the requesting student is on the session roster (session_students), and
    • the 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 — available requires passing all of them. When several sessions exist (re-scheduled), the most recent in-window unsigned session wins. access_window_days defaults to 10.

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:

{
"exams": [
{
"Exam": {
"id": "...",
"name": "Final LFR test",
"type": "ONLINE",
"attempts": 2,
"score": 75,
"questions": 20,
"minutes": 30,
"expiration": null,
"training_id": "...",
"training_subject_id": "...",
"training_subject_lesson_id": null,
"deleted": false,
"access_mode": "FREE",
"access_window_days": 10,
"available": true,
"available_until": null
},
"TrainingQuestion": [
{ "id": "...", "name": "Question text",
"TrainingSubject": { "name": "...", "code": "..." },
"Lesson": { "name": "..." } }
]
}
]
}

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/…).

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.

Field Type Required Description
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": … }.

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.

Field Type Required Description
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 TrainingQuestion is soft-deleted (deleted=1, hidden everywhere via beforeFind, 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 }.

GET /trainings/exams/start/{enrollmentId}/{examId}.json

Preview exam details and previous attempts before starting.

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.

{
"result": true,
"message": "Exam started",
"attempt_id": "1500"
}

GET /trainings/exams/do/{attemptId}.json

Retrieve exam questions for an active attempt.

POST /trainings/exams/answer.json

Submit an answer to an exam question.

Field Type Required Description
attemptDetailId string Yes ExamAttemptDetail ID
questionOptionId string Yes Selected option ID

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.)

GET /trainings/exams/view/{attemptId}.json

Retrieve exam results and (when enabled) correct answers.


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), Theory Knowledge Instructors (175) 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. Theory Knowledge Instructors (175) get the same read-only access limited to the courses they teach: for any other training this list answers {"students": []}. 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, and TrainingsUser.finished no longer appears in any response — the finished column was dropped in favour of status. See Enrollment status.

GET /manager/trainings/students/view/{enrollmentId}.json

Retrieve full student enrollment details.

A Theory Knowledge Instructor (175) gets 404 for an enrollment in a course they do not teach.

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.

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:

Key Meaning
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.

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.

"Homework": {
"items": [
{
"session_id": "12c2d835-…",
"datetime": 1790064000,
"subject_code": "1",
"subject_name": "AIR LAW AND ATC PROCEDURES",
"lesson": "Airspace and ATC services",
"description": "Summarise the airspace classes A to G.",
"deadline": 1790359200,
"attendance_status": "attended",
"submitted": true,
"submitted_at": 1790770531,
"late": true,
"graded": true,
"score": 7.0,
"feedback": "Good summary. Handed in late.",
"graded_at": 1790770543,
"graded_by": "Laura Bennett"
}
],
"summary": { "requested": 1, "submitted": 1, "late": 1, "graded": 1, "average": 7.0 }
}
  • submitted / submitted_at: the student’s earliest active SessionClasswork upload for that class. late compares it to deadline (null deadline = never late).
  • score is null when the work is ungraded or was graded with a comment only. summary.average averages the numeric scores only, and is null when there are none.
  • graded_by is 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).

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, or null if never reset.
  • training.TrainingSubject[].TrainingSubject.Lesson[].Exam[].ExamAttempt[].reset — true if ExamAttempt.start <= reset_at (the attempt belongs to a pre-reset cycle and didn’t count toward the current limit), false otherwise.
  • training.TrainingSubject[].TrainingSubject.Exam[].TrainingActivity.ActivityProgress[0].reset_at and .ExamAttempt[].reset — same annotation for subject-level exams (subject-level exams are not currently reset, but the field is populated for symmetry).

Example slice:

{
"training": {
"TrainingSubject": [
{
"TrainingSubject": {
"Lesson": [
{
"id": "50",
"Exam": [
{
"id": "e-50-gate",
"attempts": 3,
"TrainingActivity": {
"id": "act-e50",
"ActivityProgress": [
{ "value": 0, "attempts_count": 0, "reset_at": 1778600000 }
]
},
"ExamAttempt": [
{ "id": "att-1", "start": 1778500001, "passed": 0, "status": "FINISHED", "reset": true },
{ "id": "att-2", "start": 1778510002, "passed": 0, "status": "FINISHED", "reset": true },
{ "id": "att-3", "start": 1778550003, "passed": 0, "status": "FINISHED", "reset": true },
{ "id": "att-4", "start": 1778700004, "passed": 0, "status": "STARTED", "reset": false }
]
}
]
}
]
}
}
]
}
}

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.

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.

Field Type Required Description
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”.
  • A student already enrolled in the training is skipped, unless their prior enrollment is closed — any status other than ACTIVE, i.e. COMPLETED, STOPPED, FAILED or EXPELLED — 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 active training, an in-app notification is sent to the student linking to the enrollment.

A map keyed by user ID, with the new enrollment ID (TrainingsUser.id) as the value — one entry per newly-created enrollment.

{
"123": "e1f2a3b4-c5d6-7890-abcd-ef1234567890",
"456": "9a8b7c6d-5e4f-3210-9876-543210fedcba"
}
  • 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 false or 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.
Status When
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.

POST /manager/trainings/students/notes.json

Update notes for a student enrollment.


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:.

Status Meaning
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.

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.json and POST /trainings/exams/start/... return 403 This enrollment is closed otherwise;
  • auto-complete. A closed enrollment is skipped by TrainingsUser::checkTrainingFinished and never gets validity stamped;
  • 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).

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.

Endpoint Where Shape
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.

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):

"StatusChange": [
{
"id": "b171362d-…",
"status": "STOPPED",
"reason": "Left the school",
"changed_by": "3",
"created": "1787673311",
"changed_by_name": "Flylogs Support"
}
]

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.

POST /manager/trainings/students/status/{enrollmentId}.json

Close or reopen an enrollment without deleting any progress. Sent as application/x-www-form-urlencoded.

Field Type Required Description
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.
  • manager prefix: 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_finish exactly: allowed for Company Administrators, Trainings Managers and Chief Pilots (user_group_id 100/135/150), denied for Crew Scheduling and Flight Dispatchers (140/145) and for every student/pilot group (170/190/200/300).
  • Writes status, status_reason, status_changed (unix) and status_changed_by (user id), appends the change to TrainingsUser.notes, and inserts a trainings_user_status_changes row (see Status history).
  • COMPLETED stamps status_changed with the completion date — supplied, derived or now — and writes TrainingsUser.validity as that date plus Training.validity days (null when the training has no validity period). status_changed is the completion date the certificate is issued for; modified is only the row’s last edit and must not be read as a finish date. ACTIVE (a reopen) clears validity; 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 reason when given, and ACTIVE announces 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 and user_credentials.alerts = 1. Emails count against the company’s send quota.
  • No progress row is created, modified or deleted.
{
"result": true,
"message": "Status updated.",
"status": "STOPPED"
}
Status When
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.

GET /trainings/trainings/calendar.json

Retrieve training-related calendar events.

Parameter Type Required Description
start string Yes Start date
end string Yes End date
timeZone string Yes IANA timezone
training string No Filter by training ID

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. A course with show_certificate = 1 is issued on completion: CertificateIssuer::issueOnCompletion runs after both the manual path (_setEnrollmentStatus to COMPLETED, issuer = the acting user) and the automatic one (TrainingsUser::checkTrainingFinished, issuer null), and does nothing when a certificate row already exists (including PREVIEW / REVOKED) or the course hides certificates; a failure is logged to the trainings log and never blocks the completion. Otherwise 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.

?preview=1 creates the row with status: "PREVIEW" instead of LIVE, so the document can be rendered and checked before it counts.

Terminal window
curl -H "Authorization: Bearer $TOKEN" \
"https://fmc.flylogs.com/v1/trainings/trainings/certificate/{enrollmentId}.json?preview=1"

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.

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.

Field Description
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}, credited? }. credited (task #1120) is present only when the enrollment has credit and holds the credited-training statement: `{ sources[{course,version,school,status,started,ended,how: MOVED
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.


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.

{
"certificates": [
{
"TrainingCertificate": {
"id": "…", "number": "EGM-2026-0001", "sequence": "1",
"issued_at": "1789200000", "issued_by": "618",
"trainings_user_id": "…", "training_id": "…", "user_id": "377",
"status": "MODIFIED", "issued_by_preview": true,
"edited_at": "1789300000", "edited_by": "618",
"revoked_at": null, "revoked_by": null, "revoke_reason": null,
"snapshot": { "…": "every printed field" }
},
"Training": { "id": "…", "name": "ATPL(A) - INTEGRATED", "color": "#249ac3" },
"User": { "id": "377", "UserDetail": { "name": "Martha", "surname": "Smith" } },
"Issuer": { "id": "618", "UserDetail": { "name": "Iñigo", "surname": "García" } },
"Editor": { "id": "618", "UserDetail": { "name": "Iñigo", "surname": "García" } },
"Revoker": { "id": null, "UserDetail": null }
}
],
"pagination": { "page": 1, "pageCount": 1, "count": 1, "limit": 50 }
}

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… } }.

POST /manager/trainings/trainings/certificate_issue/{certificateId}.json

Terminal window
curl -X POST -H "Authorization: Bearer $TOKEN" \
"https://fmc.flylogs.com/v1/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.

POST /manager/trainings/trainings/certificate_edit/{certificateId}.json

Terminal window
curl -X POST -H "Authorization: Bearer $TOKEN" \
-d "signer[position]=Head of Training" \
-d "signer[name]=Ana" -d "signer[surname]=Ruiz" \
-d "course[ground_hours]=123.5" \
-d "course[completed]=1757894400" \
-d "locale=es" \
"https://fmc.flylogs.com/v1/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:

Section Editable paths
— 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.

POST /manager/trainings/trainings/certificate_revoke/{certificateId}.json

Terminal window
curl -X POST -H "Authorization: Bearer $TOKEN" \
-d "reason=Issued with the wrong signatory; reissued under a corrected record." \
"https://fmc.flylogs.com/v1/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.

{
"result": true,
"certificate": {
"id": "db963496-fc8f-457b-8393-825c337e06f2",
"number": "2026-0001",
"status": "REVOKED",
"revoked_at": "1790068739",
"revoked_by": "3443",
"revoke_reason": "Issued with the wrong signatory; reissued under a corrected record.",
"snapshot": { "…": "unchanged, plus status: REVOKED" }
}
}

Student Report access and the verification payload

Section titled “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.

{
"training": {
"verification": true,
"User": { "UserDetail": { "name": "N****", "surname": "K******" } },
"Training": { "name": "EASA PPL(A) / National PPL(A)", "…": "…" },
"TrainingCertificate": {
"number": "2026-0004",
"issued_at": "1790068731",
"status": "REVOKED",
"revoked_at": "1790068739",
"revoke_reason": "Issued with the wrong signatory; reissued under a corrected record."
}
}
}

There are two boolean training-level flags that govern automatic completion behavior. Both default to 0.

Field Description
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)

Section titled “Completion rule (used by TrainingsUser::checkTrainingFinished)”

An enrollment auto-completes (status ACTIVE → COMPLETED) when all of the following hold:

  1. The training’s date window allows it: Training.start <= today <= Training.end (NULL bounds are treated as open-ended).
  2. Training::getProgress(training_id, enrollment_id).finished == true — every mandatory activity has ActivityProgress.value = 1, and every mandatory lesson-gate exam has been passed.
  3. Flights — only when Training.flights = 1. Then, for every TrainingFlight row attached to the training there is at least one UserTrainingFlight row with completed = 1 scoped to this enrollment (this applies to every training type that has flight missions, not just DISTANCE). A training with zero TrainingFlight rows passes this clause trivially, and so does one whose flight section is switched off: turning Training.flights off does not delete the training_flights rows, 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.json or any UserTrainingFlight write, re-fetch /trainings/trainings/view/{enrollmentId}.json to see whether TrainingsUser.status flipped to COMPLETED. 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 ACTIVE enrollment can auto-complete. A STOPPED / FAILED / EXPELLED one is skipped and never gets its validity stamped.
  • Read the completion date from TrainingsUser.status_changed, never from TrainingsUser.modified: modified is the row’s last edit and moves whenever anything touches the enrollment. Legacy rows completed before status_changed existed carry null, which is the only case where modified is 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.

GET /trainings/locations.json

Retrieve all training locations.

GET /trainings/locations/view/{id}.json

Retrieve location details and scheduled classes.

POST /manager/trainings/locations/edit.json

Create or update a training location. Sent as application/x-www-form-urlencoded.

Field Type Required Description
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

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.

{
"missions": [
{
"UserTrainingFlight": {
"id": "800",
"training_flight_id": "tf-uuid-1",
"flight_id": "flight-uuid-1",
"completed": "2025-04-15 14:30:00",
"time": "5400"
},
"TrainingFlight": {
"id": "tf-uuid-1",
"name": "Solo Navigation",
"order": "3",
"Training": {
"id": "10",
"name": "CPL Flight Training",
"active": true,
"start": "2025-01-01",
"end": "2025-12-31",
"require_debriefing_signature": true
}
}
}
]
}

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.

Field Type Required Description
user_training_flight_id string Yes UserTrainingFlight record ID
pass string Yes User’s account password
{
"update": true
}

Returns false if the record was already signed.


GET /manager/trainings.json

List all trainings with student counts (admin view).

POST /manager/trainings/trainings/create.json

Create a new training course.

Field Type Required Description
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

GET /manager/trainings/trainings/templates.json

List available training templates.

POST /manager/trainings/trainings/teaching_report.json

Generate a teaching activity report.

Field Type Required Description
training string Yes Training ID
from string Yes Start date
to string Yes End date

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.

GET /manager/trainings/stages/index/{trainingId}.json

Returns the training’s stages ordered by (order, id).

POST /manager/trainings/stages/add/{trainingId}.json

Field Type Required Description
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

POST /manager/trainings/stages/edit/{id}.json

Accepts any of the fields above. Only the fields sent are changed.

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.

POST /manager/trainings/stages/reorder/{trainingId}.json

Field Type Required Description
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.

POST /manager/trainings/stage_checks/record.json

Field Type Required Description
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.

POST /manager/trainings/stage_checks/override.json

Field Type Required Description
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.

POST /manager/trainings/stage_checks/void/{id}.json

Field Type Required Description
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.

GET /trainings/students/stage_status/{enrollmentId}.json

Readable by the enrolled student for their own enrollment, and by anyone who may manage the training.

{
"stage_checks": 1,
"stages": [{ "TrainingStage": { "id": "…", "order": 1, "code": "S1", "name": "Presolo" } }],
"gate": {
"stages": { "<stageId>": "OPEN | CHECK_DUE | COMPLETE | BLOCKED" },
"missions": { "<missionId>": "OK | BLOCKED_STAGE" }
},
"results": [{ "StageCheckResult": { "attempt": 1, "result": "UNSAT", "…": "…" } }]
}

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.”

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).

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.

POST /manager/trainings/mission_authorizations/grant.json

Field Type Required Description
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.

POST /manager/trainings/mission_authorizations/revoke/{id}.json

Field Type Required Description
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.

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.

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.

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.

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.

GET /manager/trainings/approvals/index.json — everything PENDING in the company, oldest request first. A course manager (group > 135) sees only courses they manage.

POST /manager/trainings/approvals/decide/{id}.json

Field Type Required Description
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.

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.

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.

Schema additions. trainings gained published (tinyint), enrollment_mode ENUM('CLOSED','REQUEST','INSTANT'), requirements (longtext), announcement_body (longtext) and cover_upload_id. The existing announcement (varchar 255) is now the short strapline on the catalog card; the long copy lives in announcement_body.

Two new tables: training_intakes (one dated run of a course) and training_enrollment_requests (a student’s application). trainings_users gained a nullable intake_id — NULL for every enrollment that predates this feature, for every one a manager still creates off-catalog, and for every one taken on a DISTANCE course offered with no dates.

An application is deliberately not a trainings_users.status value. 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.

A seat on an intake is held by:

  • a trainings_users row with that intake_id, deleted = 0 and status ACTIVE or COMPLETED; plus
  • a training_enrollment_requests row with status APPROVED whose trainings_user_id is still NULL — 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.

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.

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.

Endpoint group Who
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.

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 PUBLISHED or FULL whose enroll_deadline has not passed. A NULL deadline means “no deadline”, not “expired”.
  • DISTANCE courses 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, with Intakes: [], 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.

Filters ride as CakePHP named parameters, not a query string: /trainings/catalog/index/type:ONSITE/month:2027-01.json

Parameter Type Required Description
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).
{
"courses": [
{
"Training": {
"id": "602852d0-953c-4783-a87d-31dc3626a689",
"name": "ATPL (A)",
"type": "DISTANCE",
"announcement": "Evening ATPL",
"announcement_body": "Full ATPL theory, evenings.",
"color": "#249ac3",
"enrollment_mode": "REQUEST",
"duration": 750
},
"Intakes": [
{
"id": "06236fe7-2d0d-4094-83e7-285780c6ccd4",
"name": "Jan 2027",
"status": "PUBLISHED",
"start": "2027-01-10",
"end": "2027-06-30",
"enroll_opens": null,
"enroll_deadline": "2026-12-15",
"seats": 2,
"location_id": null,
"seats_taken": 0,
"seats_left": 2,
"full": false
}
],
"Me": {
"enrollment": "EXPELLED",
"enrollment_intake_id": null
}
}
]
}

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.

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.

{
"Training": {
"id": "602852d0-953c-4783-a87d-31dc3626a689",
"name": "ATPL (A)",
"requirements": "Class 1 medical",
"enrollment_mode": "REQUEST"
},
"Subjects": [
{ "id": "7b1e…", "code": "010", "name": "Air Law", "hours": "52.00", "order": 1 }
],
"Intakes": [
{ "id": "06236fe7…", "name": "Jan 2027", "status": "PUBLISHED", "seats_left": 2, "full": false }
],
"Me": {}
}

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.

{
"count": 1,
"courses": [
{
"id": "602852d0-953c-4783-a87d-31dc3626a689",
"name": "ATPL (A)",
"type": "DISTANCE",
"announcement": "Evening ATPL",
"color": "#249ac3",
"next_start": "2027-01-10",
"intakes": 1
}
]
}

POST /trainings/enrollment_requests/submit.json

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.

Field Type Required Description
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.

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.

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, soft 200 with reason: "PREREQUISITES_NOT_MET" and the full verdict in prerequisites_verdict. There is no human in that path to notice.
  • REQUEST — allowed through. The verdict is stored on the application in prerequisite_snapshot as 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.

{ "result": true, "outcome": "PENDING", "request_id": "6e4ab9d0-6594-46e1-beff-6019c907bb7e" }
{ "result": true, "outcome": "ENROLLED", "trainings_user_id": "cab97254-4dab-4d9a-ae2e-851d052e2464" }
{ "result": false, "reason": "ALREADY_APPLIED" }

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.

{ "result": true }

GET /manager/trainings/intakes/index/{trainingId}.json

Every date of one course with its seat position. Paginated (limit 200, maxLimit 1000).

{
"intakes": [
{
"TrainingIntake": {
"id": "06236fe7-2d0d-4094-83e7-285780c6ccd4",
"name": "Jan 2027",
"status": "PUBLISHED",
"start": "2027-01-10",
"end": "2027-06-30",
"enroll_deadline": "2026-12-15",
"seats": 2,
"seats_taken": 1,
"seats_left": 1,
"full": false
}
}
],
"training": {
"Training": { "id": "602852d0…", "name": "ATPL (A)", "published": true, "enrollment_mode": "REQUEST" }
}
}

POST /manager/trainings/intakes/save.json

Field Type Required Description
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.

{
"result": true,
"validationErrors": [],
"intake": { "TrainingIntake": { "id": "06236fe7…", "seats_taken": 0, "seats_left": 2, "full": false } }
}
{
"result": false,
"validationErrors": { "end": ["The course cannot end before it starts"] }
}

Note the PHP quirk: an empty validationErrors serialises as [] (array), a populated one as an object. Check for emptiness before treating it as a map.

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:

{
"result": false,
"message": "This intake has 1 enrolled student(s). Cancel it instead of deleting it.",
"enrolled": 1
}

POST /manager/trainings/intakes/status/{id}/{status}.json

Status Meaning
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.

{ "result": true, "message": "Intake updated", "status": "CLOSED" }

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.

{ "result": true, "outcome": "APPROVED", "trainings_user_id": "af1a9796-f5ed-438e-b6d3-6655d959ac62" }
{ "result": false, "reason": "INTAKE_FULL" }

Open applications also appear on the existing approvals endpoint, as a third queue beside approvals and flights:

GET /manager/trainings/approvals/index.json

{
"approvals": [],
"flights": [],
"enrollments": [
{
"TrainingEnrollmentRequest": {
"id": "6e4ab9d0…",
"status": "PENDING",
"student_note": "Evenings please",
"created": "1789...",
"user_id": "490"
},
"Training": { "id": "602852d0…", "name": "ATPL (A)" },
"TrainingIntake": { "id": "06236fe7…", "name": "Jan 2027", "seats_taken": 0, "seats": 2 },
"Applicant": { "id": 490, "name": "Martha Smith" }
}
]
}

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.

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) and exam_registration_subjects (the subjects chosen, and the official result of each).

These tables are separate from the internal exams table. exams.caa_exam is unchanged, and official results never write activity_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 as training_requirements but keyed on sitting_id. No new actions, so no aco_sync. See Entry requirements below.

2026-09-16 (task #959): exam_registration_subjects.result gained the value ABSENT (flylogs/migrations/2026-09-16-exam-sittings-absent.sql, ENUM change only), and manager_student was added (needs aco_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.

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.

Endpoint group Who
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.

exam_authority_rules rows are data the school edits; no regulation is encoded in code.

Column Meaning
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:

{
"verdict": "BLOCK",
"reason": "TOO_SOON",
"reasons": [{ "reason": "TOO_SOON", "verdict": "BLOCK" }, { "reason": "LAST_ATTEMPT", "verdict": "WARN" }],
"rule": "EASA",
"attempts_used": 1, "attempt_no": 2, "max_attempts": 2,
"sittings_used": 1, "max_sittings": null,
"passed": false, "last_attempt_at": 1791877884,
"next_eligible_date": "2026-11-12", "window_ends": "2028-04-30"
}

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_subjects row with result PASS, FAIL or ABSENT and a sat_at. An absence spends the attempt like a FAIL (never a pass) — the authority counts a no-show, so the school does too (task #959). PENDING is 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 a BLOCK.
  • 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.

A student may register for a subject when their enrolment on the subject’s course is:

  • COMPLETED; or
  • ACTIVE, with every mandatory EXAM activity of the subject done (activity_progress.value = 1 on that enrolment); or, when the subject has no mandatory exam, every mandatory LESSON done.

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.

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 with PREREQUISITES_NOT_MET when 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), through exam_sittings/save.json.

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.

{
"sittings": [{
"ExamSitting": { "id": "…", "name": "October theory", "type": "AUTHORITY", "status": "PUBLISHED", "datetime": 1792400000, "registration_deadline": "2026-10-01", "seats": 20, "max_subjects": 4, "seats_taken": 3, "seats_left": 17, "rule_name": "EASA", "location_name": "Madrid" },
"Subjects": [{ "id": "…", "training_subject_id": "…", "code": "010", "name": "Air Law", "training_name": "ATPL(A)", "seats": null, "taken": 2, "seats_left": null, "eligible": true, "eligibility_reason": null, "attempt": { "verdict": "OK", "attempt_no": 1 } }],
"Registration": null,
"Prerequisites": { "met": false, "rules": [{ "kind": "CERTIFICATE", "key": "medical_class_1", "required": "medical_class_1", "actual": null, "met": false, "reason": "CERTIFICATE_MISSING", "label": "Medical Class 1" }] },
"can_register": { "allowed": false, "reason": "PREREQUISITES_NOT_MET" }
}],
"paging": { "page": 1, "pageCount": 1 }
}

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.

POST /trainings/exam_registrations/register.json

{ "sitting_id": "…", "subjects": ["<exam_sitting_subjects.id>", "…"], "note": "optional" }

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:

{ "result": true, "registration": { "id": "…", "status": "PENDING", "subjects": ["…"] }, "warnings": [{ "subject_id": "…", "reason": "LAST_ATTEMPT" }] }

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.

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.

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.

Method Path Notes
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.

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

{ "sitting_id": "…", "ids": ["…"], "status": "CONFIRMED", "reason": "optional, stored on REJECTED/CANCELED" }
To Allowed from
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

{ "results": [{ "id": "<exam_registration_subjects.id>", "result": "PASS", "score": 88 }] }

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.

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

{ "sitting_id": "…", "user_id": 490, "subjects": ["<exam_sitting_subjects.id>"], "note": "optional" }

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:

{
"result": true,
"registration": { "id": "…", "status": "CONFIRMED", "subjects": ["…"] },
"warnings": [
{ "subject_id": "…", "reason": "NOT_ELIGIBLE", "detail": "EXAMS_NOT_PASSED" },
{ "subject_id": "…", "reason": "LAST_ATTEMPT" },
{ "subject_id": null, "reason": "PREREQUISITES_NOT_MET", "detail": 2 }
]
}

Manager: reset a student’s attempt history

Section titled “Manager: reset a student’s attempt history”

POST /manager/trainings/exam_sittings/reset_attempts.json

{ "user_id": 490, "authority_rule_id": "…", "reason": "Restarted the ATPL course" }

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.

Response When
{ "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).

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:

Trigger Subject
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.

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.

Method Path Notes
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).

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:

Figure Definition
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.

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.

Parameter Type Description
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.

{
"Mission": {
"name": "EX 12 — Circuits",
"evaluation": "NUM",
"exercises": [
{ "name": "Normal takeoff", "rating": "4", "comments": "" },
{ "name": "Circuit pattern", "rating": "3", "comments": "Wide on the downwind leg." },
{ "name": "Landing flare", "rating": "2", "comments": "Flaring high, needs more practice." }
],
"metrics": [{ "name": "Airmanship", "rating": "4", "comments": "" }]
}
}
Field Type Description
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
{
"result": true,
"message": "Draft generated. Review and edit before saving.",
"comment": "Normal takeoff and airmanship were solid. The circuit pattern was wide on the downwind leg, and the landing flare was high and needs more practice.",
"model": "deepseek-v4-pro"
}

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.

Code Meaning
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

Section titled “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.

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.

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.

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.

{
"result": true, "code": null,
"from": { "id": "…", "name": "EASA PPL", "version": 1, "version_status": "CURRENT" },
"to": { "id": "…", "name": "EASA PPL", "version": 2, "version_status": "DRAFT" },
"diff": {
"subjects": { "added": ["Navigation"], "removed": [], "changed": [{ "name": "Air Law", "hours": [10, 12], "activities_added": ["Airspace"], "activities_removed": [] }] },
"missions": { "added": [{ "position": 1, "name": "Familiarisation" }], "removed": [], "changed": [{ "name": "First solo", "fields": { "position": [2, 3], "hours": [0.5, 0.75] } }] },
"stages": { "added": [], "removed": [] },
"settings": { "validity": [365, 730] }
},
"flight_types": { "3": "DUAL", "4": "SOLO" }
}

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).

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" }] }.

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:

{
"reason": "PPL holder, 120 h logged",
"flight_type_seconds": { "3": 4800 },
"subjects": { "<subjectId>": { "seconds": 10800, "completed": true } },
"missions": ["<trainingFlightId>"]
}
  • Flight-type and subject time is stored in enrollment_credits (kind FLIGHT_TYPE_HOURS / SUBJECT_HOURS, seconds, origin PRIOR) exactly as sent.
  • A completed subject writes activity_progress rows (value = 1, source = CREDIT) for every activity of the subject that has no progress yet.
  • A completed mission writes a user_training_flights row with flight_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_reason holds 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}.

Optional assessment (task #1116): { "by": "A. Instructor", "date": "2026-10-01", "note": "result or reference" } (date is Y-m-d, by up to 120 chars, note up to 255). Stored in trainings_users.prior_assessment_by / prior_assessment_date / prior_assessment_note. When omitted on prior/{id}, the stored assessment is kept. It travels with the student on a move or credit.

Credit limits (task #1116). When the course has limits (below), the flight-type hours of the payload are checked after validation, and the call is refused with code: "CREDIT_LIMIT" and violations: [...] (enroll: nothing is created). Each violation is { "code": ... } with BLOCKED_TYPE (flight_type_id, credited), OVER_PERCENT (limit, allowed, credited), OVER_MAX (allowed, credited), OVER_PIC (allowed, credited) or ASSESSMENT_REQUIRED. All times in seconds. Only FLIGHT_TYPE_HOURS are limited. INVALID_ASSESSMENT_DATE is a plain refusal.

Codes (both paths): CREDIT_LIMIT, INVALID_ASSESSMENT_DATE, 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/credit_context/{trainingId}.json?user_id={userId} (task #1116, staff ≤ 135; the pilot must belong to the company) → { "credit": { "limits": {…} | null, "planned_seconds", "pic_seconds", "pic_by_category": { "L": 223200 }, "suggestions": { "L": 22320 } } }. planned_seconds is the sum of the course’s mission hours (the base of max_percent); pic_seconds the pilot’s logged PIC block time in this company, simulators excluded, rolled up like the pilot card (pic, fi, picus, instructor and examiner classes); pic_by_category splits it by the first letter of aircraft_models.type (L landplane, S seaplane, A amphibian, H helicopter, G gyroplane, T tiltrotor, ? unknown); suggestions applies other_percent and other_max_seconds per category (empty when no percentage is set). limits is null when the course has none.

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 of activity_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

Section titled “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

Section titled “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 as MIGRATED) or credit — a COMPLETED, STOPPED or FAILED enrollment 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 has credited_from_id set; migrated_from_id stays NULL. New refusal code: NOT_CREDITABLE (credit mode, status not COMPLETED / STOPPED / FAILED). NOT_ACTIVE now 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. Absent overrides = 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:

Key Content
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).

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:

{ "graph": {
"nodes": [{ "root_id", "training_id", "name", "version", "active", "focus", "revision_moves" }],
"edges": [{ "from": "<rootId>", "to": "<rootId>", "moved": 0, "credited": 1,
"pathways": [{ "id", "source_training_id", "label", "stale",
"coverage": { "missions": [12, 13], "subjects": [9, 9] } }] }]
} }

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.

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.

Field Type Meaning
min_instruction, min_dual, min_solo, min_solo_xc seconds minimums
instructor_certificate_types string[] task #1115: user_certificates types that qualify an instructor for this course (catalogue keys, e.g. fi_rating); holding any one, valid on the day of the flight, qualifies. Returned as an array, stored as a comma list
min_dual_qualified, min_solo_qualified seconds minimum dual / supervised solo flown with a qualified instructor. Require instructor_certificate_types (400 otherwise)
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:

{ "alternative": true,
"lines": [{ "key": "instruction|dual|solo|solo_xc|dual_qualified|solo_qualified", "required": 144000, "done": 63000, "credited": 0, "met": false }],
"fstd": { "done": 0, "counted": 0, "limit": 18000 },
"xc": { "required_km": 270, "required_landings": 2, "met": false,
"best": { "date": "2026-08-16", "origin": "LECU", "km": 317, "landings_away": 1,
"legs": [{ "from": "LECU", "to": "LESA", "km": 158 }], "incomplete": false } },
"instructor": { "flagged": [{ "flight_id": "…", "date": "2026-08-10", "role": "DUAL", "seconds": 3600,
"instructor_id": 42, "instructor_name": "Jane Doe" }],
"unrecorded_seconds": 7200 },
"met": false }

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.

Instructor qualification (task #1115, FCL.210.A(b)(2)). When a mission flight is saved (flights/add|edit), each student’s user_training_flights row records instructor_id — the other pilot on board (dual), else supervisor_id (supervised solo) — and instructor_snapshot, a JSON list of that person’s licences, ratings and training certificates at that moment (type, name, issue, expiration, comments up to 500 chars; medicals, IDs and revoked certificates are left out). Re-saving the flight keeps the snapshot while the instructor is unchanged, so later certificate edits never rewrite history. Moves and credits copy both columns. Whether a flight qualifies is decided on read against the current instructor_certificate_types of the course and the certificate validity on the flight date. dual_qualified / solo_qualified lines count only flights that qualify (previous-experience credits never do). instructor is null unless the course sets instructor types or a qualified minimum; flagged lists dual/solo flights whose instructor did not qualify, unrecorded_seconds the dual/solo time with no snapshot (logged before this existed), which is neither counted nor flagged. Flagging alone does not block completion: only a failed *_qualified minimum does, with enforce.

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.”

Task #1116. Limits on previous-experience credit (FCL.110.A(c), FCL.210.A(d) as examples; every value is typed by the school).

GET /manager/trainings/trainings/credit_limits/{trainingId}.json → { "limits": {…} | null }
POST same URL, JSON body below → saves and answers the same. Empty / null = no rule; nothing set at all removes the limits. Staff ≤ 135; invalid values answer 400.

Field Type Meaning
max_percent int 1–100 max share of the course’s planned flight hours (sum of mission hours)
max_seconds seconds max credited flight seconds
blocked_flight_types int[] flight type ids (of this company) that can never be credited
cap_pic bool credit may not exceed the student’s logged PIC seconds
require_assessment bool assessment.by and assessment.date must be on record before credit is saved
other_percent, other_max_seconds int, seconds calculator: share of PIC time in one aircraft category, and its cap

Limits are copied with the course (course copy and new course revisions). The move preview (migrate_preview) adds credit_warnings: the same violations for the PRIOR credit the move would carry (never refused; MIGRATION credit is not limited; BLOCKED_TYPE adds flight_type_name).

Deploy: migrations/2026-10-17-credit-limits.sql (training_credit_limits, trainings_users.prior_assessment_*) before the backend; aco_sync for Trainings/manager_credit_limits and Students/manager_credit_context.

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-16-instructor-qualification.sql (task #1115: user_training_flights.instructor_id / instructor_snapshot, training_experience_requirements.instructor_certificate_types / min_dual_qualified / min_solo_qualified) before the backend; no new endpoint or ACL. 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.

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.