Files
training-software/training/models.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

90 lines
3.0 KiB
Python

import uuid
from django.conf import settings
from django.db import models
class CompletionMode(models.TextChoices):
ONLINE = "online", "Online"
OFFLINE = "offline", "Offline"
BLENDED = "blended", "Blended"
class CompletionStatus(models.TextChoices):
NOT_STARTED = "not_started", "Not Started"
IN_PROGRESS = "in_progress", "In Progress"
PENDING_SIGNOFF = "pending_signoff", "Pending Trainer Signoff"
COMPLETED = "completed", "Completed"
FAILED = "failed", "Failed"
class SignoffDecision(models.TextChoices):
APPROVED = "approved", "Approved"
REJECTED = "rejected", "Rejected"
class TrainingRecord(models.Model):
"""
Completion state machine for one enrollment.
Extends tracking.Enrollment with completion mode and trainer workflow.
"""
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
enrollment = models.OneToOneField(
"tracking.Enrollment", on_delete=models.CASCADE, related_name="training_record"
)
completion_mode = models.CharField(
max_length=20, choices=CompletionMode.choices, default=CompletionMode.ONLINE
)
status = models.CharField(
max_length=20, choices=CompletionStatus.choices, default=CompletionStatus.NOT_STARTED
)
online_passed_at = models.DateTimeField(null=True, blank=True)
completed_at = models.DateTimeField(null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
db_table = "training_record"
def __str__(self):
return f"{self.enrollment_id} [{self.completion_mode}] {self.status}"
class TrainerSignoff(models.Model):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
training_record = models.ForeignKey(
TrainingRecord, on_delete=models.CASCADE, related_name="signoffs"
)
trainer = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="given_signoffs"
)
decision = models.CharField(max_length=20, choices=SignoffDecision.choices)
notes = models.TextField(blank=True)
signed_off_at = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = "training_trainer_signoff"
ordering = ["-signed_off_at"]
def __str__(self):
return f"Signoff {self.decision} by {self.trainer_id} on {self.training_record_id}"
class SignoffEvidence(models.Model):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
training_record = models.ForeignKey(
TrainingRecord, on_delete=models.CASCADE, related_name="evidence"
)
file_name = models.CharField(max_length=255)
file_path = models.CharField(max_length=1000)
uploaded_at = models.DateTimeField(auto_now_add=True)
uploaded_by = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="uploaded_evidence"
)
class Meta:
db_table = "training_signoff_evidence"
def __str__(self):
return f"{self.file_name} for {self.training_record_id}"