- 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>
105 lines
3.3 KiB
Python
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
|