from drf_spectacular.utils import OpenApiResponse, extend_schema from rest_framework import generics, permissions, status, viewsets from rest_framework.response import Response from rest_framework.views import APIView from django.db import transaction from django.utils import timezone from django_filters.rest_framework import DjangoFilterBackend from django_filters import FilterSet from .models import Role, RoleSkill, UserTargetRole from .serializers import ( RoleAdminSerializer, RoleDetailSerializer, RoleListSerializer, RoleSkillAdminSerializer, UserTargetRoleSerializer, ) class RoleFilterSet(FilterSet): class Meta: model = Role fields = { 'industry': ['exact'], } class RoleListView(generics.ListAPIView): # Intentionally unpaginated: the role catalog is a small, curated set # (~10 seeded, expected ceiling ~50). A plain list keeps the frontend # dropdown trivial. Revisit if the catalog ever grows past a few hundred. queryset = Role.objects.filter(is_active=True) serializer_class = RoleListSerializer permission_classes = [permissions.IsAuthenticated] filter_backends = [DjangoFilterBackend] filterset_class = RoleFilterSet pagination_class = None class RoleDetailView(generics.RetrieveAPIView): queryset = Role.objects.filter(is_active=True) serializer_class = RoleDetailSerializer permission_classes = [permissions.IsAuthenticated] class UserTargetRoleView(APIView): serializer_class = UserTargetRoleSerializer permission_classes = [permissions.IsAuthenticated] @extend_schema( responses={ 200: UserTargetRoleSerializer, 404: OpenApiResponse(description='No active target role.'), }, ) def get(self, request): try: target_role = UserTargetRole.objects.get(user=request.user, is_active=True) serializer = UserTargetRoleSerializer(target_role, context={'request': request}) return Response(serializer.data) except UserTargetRole.DoesNotExist: return Response( {"detail": "No active target role."}, status=status.HTTP_404_NOT_FOUND ) @extend_schema( request=UserTargetRoleSerializer, responses=UserTargetRoleSerializer, ) @transaction.atomic def post(self, request): in_serializer = UserTargetRoleSerializer( data=request.data, context={'request': request}, ) in_serializer.is_valid(raise_exception=True) role_id = in_serializer.validated_data['role_id'] try: role = Role.objects.get(id=role_id, is_active=True) except Role.DoesNotExist: # The role was concurrently deactivated between validate_role_id and # here. 400 (not 404): the request was well-formed but the resource # state changed; key on role_id to match the validation-time shape. return Response( {'role_id': ['Role not found or inactive.']}, status=status.HTTP_400_BAD_REQUEST, ) # Lock any existing active rows for this user so concurrent POSTs # serialize on the row and the partial unique constraint holds. existing = list( UserTargetRole.objects .select_for_update() .filter(user=request.user, is_active=True) ) for row in existing: if row.role_id == role.id: # Same role reselected — upsert semantics: refresh selected_at # so the frontend sees the re-activation event. row.selected_at = timezone.now() row.save(update_fields=['selected_at']) serializer = UserTargetRoleSerializer(row, context={'request': request}) return Response(serializer.data, status=status.HTTP_200_OK) UserTargetRole.objects.filter(pk__in=[r.pk for r in existing]).update(is_active=False) target_role = UserTargetRole.objects.create( user=request.user, role=role, is_active=True, ) serializer = UserTargetRoleSerializer(target_role, context={'request': request}) return Response(serializer.data, status=status.HTTP_201_CREATED) @extend_schema( request=None, responses={ 204: OpenApiResponse(description='Active target role deactivated.'), 404: OpenApiResponse(description='No active target role to deactivate.'), }, ) @transaction.atomic def delete(self, request): try: target_role = UserTargetRole.objects.get(user=request.user, is_active=True) target_role.is_active = False target_role.save() return Response(status=status.HTTP_204_NO_CONTENT) except UserTargetRole.DoesNotExist: return Response( {"detail": "No active target role to deactivate."}, status=status.HTTP_404_NOT_FOUND ) class RoleAdminViewSet(viewsets.ModelViewSet): """Admin-only CRUD on Role. List includes inactive roles (admins need to see them). DELETE is a soft delete — sets is_active=False — per the spec in CLAUDE.md (roles are never hard-deleted because historical UserTargetRole rows still reference them). """ queryset = Role.objects.all().order_by('role_name') serializer_class = RoleAdminSerializer permission_classes = [permissions.IsAuthenticated, permissions.IsAdminUser] pagination_class = None def destroy(self, request, *args, **kwargs): role = self.get_object() role.is_active = False role.save(update_fields=['is_active']) return Response(status=status.HTTP_204_NO_CONTENT) class RoleSkillAdminViewSet(viewsets.ModelViewSet): """Admin-only CRUD on a Role's required-skill rows. Accepts a `?role=` query param so the frontend role edit dialog can fetch only the RoleSkills for the role it is editing. """ queryset = RoleSkill.objects.all().select_related('skill', 'role') serializer_class = RoleSkillAdminSerializer permission_classes = [permissions.IsAuthenticated, permissions.IsAdminUser] pagination_class = None def get_queryset(self): qs = super().get_queryset() role_id = self.request.query_params.get('role') if role_id: # Guard non-numeric input: filtering on a non-int pk raises a # ValueError at query prep (→ 500). isascii() also rejects Unicode # digits (e.g. '²') that isdigit() accepts but int() can't parse. qs = qs.filter(role_id=role_id) if ( str(role_id).isascii() and str(role_id).isdigit() ) else qs.none() return qs