- 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>
90 lines
3.0 KiB
Python
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}"
|