Files
training-software/training/services.py
Paperclip CTO 87cbf23a9d
Some checks failed
CI / Tests (Python 3.12) (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / OpenAPI Schema (push) Has been cancelled
feat(TRA-241): training modes and trainer signoff state machine
- TrainingRecord model with completion_mode (online/offline/blended) and CompletionStatus state machine
- TrainerSignoff model with decision, notes, trainer FK, and audit timestamp
- SignoffEvidence model for uploaded proof files
- services.py state transitions:
  - mark_in_progress: not_started → in_progress
  - mark_online_passed: online → completed; blended → pending_signoff
  - submit_trainer_signoff: offline/blended approved → completed; rejected → in_progress
  - InvalidTransitionError on illegal state moves
- IsTrainer permission class based on training:signoff capability
- API: record detail, start, mark-online-passed, trainer-signoff, pending-signoff list
- 20 unit + integration tests covering all mode paths, invalid transitions, and access control
- Blended completion requires both online pass AND trainer approval

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-07 09:20:34 +02:00

105 lines
3.3 KiB
Python

from __future__ import annotations
from django.utils.timezone import now
from .models import (
CompletionMode,
CompletionStatus,
SignoffDecision,
SignoffEvidence,
TrainerSignoff,
TrainingRecord,
)
class InvalidTransitionError(Exception):
"""Raised when a state transition is not allowed from the current status."""
def _require_status(record: TrainingRecord, *allowed: str) -> None:
if record.status not in allowed:
raise InvalidTransitionError(
f"Cannot perform action from status '{record.status}'. "
f"Allowed: {', '.join(allowed)}"
)
def mark_in_progress(record: TrainingRecord) -> TrainingRecord:
_require_status(record, CompletionStatus.NOT_STARTED)
record.status = CompletionStatus.IN_PROGRESS
record.save(update_fields=["status", "updated_at"])
return record
def mark_online_passed(record: TrainingRecord) -> TrainingRecord:
"""
Signal that the online (quiz) component has been passed.
- Online mode → completed
- Blended mode → pending_signoff (trainer still needed)
"""
_require_status(record, CompletionStatus.IN_PROGRESS)
record.online_passed_at = now()
if record.completion_mode == CompletionMode.ONLINE:
record.status = CompletionStatus.COMPLETED
record.completed_at = now()
elif record.completion_mode == CompletionMode.BLENDED:
record.status = CompletionStatus.PENDING_SIGNOFF
else:
raise InvalidTransitionError(
"mark_online_passed is only valid for online or blended modes."
)
record.save(update_fields=["online_passed_at", "status", "completed_at", "updated_at"])
return record
def submit_trainer_signoff(
record: TrainingRecord,
trainer,
decision: str,
notes: str = "",
evidence_paths: list[tuple[str, str]] | None = None,
) -> TrainerSignoff:
"""
Record a trainer's signoff decision.
- Offline mode: can be called from IN_PROGRESS; approved → completed
- Blended mode: must be called from PENDING_SIGNOFF; approved → completed
- Rejected: returns to IN_PROGRESS in either mode.
evidence_paths: list of (file_name, file_path) tuples.
"""
if record.completion_mode == CompletionMode.ONLINE:
raise InvalidTransitionError("Online-mode training does not require trainer signoff.")
if record.completion_mode == CompletionMode.OFFLINE:
_require_status(record, CompletionStatus.IN_PROGRESS, CompletionStatus.PENDING_SIGNOFF)
else: # blended
_require_status(record, CompletionStatus.PENDING_SIGNOFF)
signoff = TrainerSignoff.objects.create(
training_record=record,
trainer=trainer,
decision=decision,
notes=notes,
)
if evidence_paths:
SignoffEvidence.objects.bulk_create([
SignoffEvidence(
training_record=record,
file_name=fname,
file_path=fpath,
uploaded_by=trainer,
)
for fname, fpath in evidence_paths
])
if decision == SignoffDecision.APPROVED:
record.status = CompletionStatus.COMPLETED
record.completed_at = now()
record.save(update_fields=["status", "completed_at", "updated_at"])
else:
record.status = CompletionStatus.IN_PROGRESS
record.save(update_fields=["status", "updated_at"])
return signoff