Spaces:
Sleeping
Sleeping
| 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}" | |