gapguide-api / apps /roles /models.py
arifRB's picture
Deploy GapGuide backend (Docker)
ffd36e0 verified
Raw
History Blame Contribute Delete
4.77 kB
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}"