import math from django.db import models from django.core.validators import MinValueValidator from django.db.models import Q from django.conf import settings AUTH_USER_MODEL = settings.AUTH_USER_MODEL class Role(models.Model): role_name = models.CharField(max_length=255, unique=True) description = models.TextField(blank=True) industry = models.CharField(max_length=100) is_active = models.BooleanField(default=True) onet_soc_code = models.CharField(max_length=20, blank=True, default="") class Meta: verbose_name_plural = "Roles" def __str__(self): return self.role_name class RoleSkill(models.Model): LEVEL_CHOICES = [ ('BEGINNER', 'Beginner'), ('INTERMEDIATE', 'Intermediate'), ('ADVANCED', 'Advanced'), ] role = models.ForeignKey(Role, on_delete=models.CASCADE, related_name='role_skills') skill = models.ForeignKey('skills.Skill', on_delete=models.CASCADE) required_level = models.CharField(max_length=20, choices=LEVEL_CHOICES) weight = models.FloatField(default=1.0, validators=[MinValueValidator(0.0)]) is_mandatory = models.BooleanField(default=False) class Meta: constraints = [ models.UniqueConstraint(fields=['role', 'skill'], name='unique_role_skill'), # F16/F17: weight must be non-negative, and a mandatory skill must # carry positive weight (otherwise it is invisible to the weighted # readiness aggregate). Enforced at the DB so every write path is # covered uniformly — the seeder/import/bulk_create/DRF paths all # skip Model.full_clean(), so a model clean() alone is insufficient. models.CheckConstraint( condition=Q(weight__gte=0), name='roleskill_weight_nonneg', ), models.CheckConstraint( condition=Q(is_mandatory=False) | Q(weight__gt=0), name='roleskill_mandatory_has_weight', ), ] def __str__(self): return f"{self.role.role_name} - {self.skill.skill_name} ({self.required_level})" def normalize_roleskill_fields(required_level, weight, is_mandatory): """Normalize + validate RoleSkill field values for the bulk write paths (the O*NET import admin action and the role seeder) that bypass the DRF serializer / Model.full_clean and would otherwise hit the DB CheckConstraints as an IntegrityError that rolls back the whole atomic batch. Returns ``(level, weight, error)`` — ``error`` is None when the row is valid; callers should skip+report rows with a non-None error rather than writing them. Also guards the gap-engine ``LEVEL_THRESHOLDS[required_level]`` lookup (a bad level would otherwise KeyError → 500 at read time).""" valid_levels = {choice[0] for choice in RoleSkill.LEVEL_CHOICES} # Strip first, then default — so "" and whitespace-only both fall back to # BEGINNER consistently (rather than "" -> BEGINNER but " " -> error). level = (required_level or "").strip().upper() or "BEGINNER" if level not in valid_levels: return None, None, f"invalid required_level {required_level!r}" try: w = float(weight if weight is not None else 1.0) except (TypeError, ValueError): return None, None, f"invalid weight {weight!r}" if not math.isfinite(w): # NaN/Infinity pass float() and slip every < comparison; reject here so # they can't abort the atomic batch (constraint) or poison readiness. return None, None, f"non-finite weight {weight!r}" if w < 0: return None, None, f"negative weight {w}" if is_mandatory and w <= 0: return None, None, f"mandatory skill must have weight > 0 (got {w})" return level, w, None class UserTargetRole(models.Model): user = models.ForeignKey(AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='target_roles') role = models.ForeignKey(Role, on_delete=models.CASCADE) selected_at = models.DateTimeField(auto_now_add=True) is_active = models.BooleanField(default=True) class Meta: ordering = ['-selected_at'] indexes = [ models.Index(fields=['user', 'is_active']), ] constraints = [ # Enforces spec: at most one active target role per user (CLAUDE.md §76). models.UniqueConstraint( fields=['user'], condition=Q(is_active=True), name='one_active_target_role_per_user', ), ] def __str__(self): return f"{self.user.username} -> {self.role.role_name}"