Spaces:
Sleeping
Sleeping
| 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] | |
| 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 | |
| ) | |
| 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) | |
| 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=<id>` 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 | |