Spaces:
Sleeping
Sleeping
| from drf_spectacular.types import OpenApiTypes | |
| from drf_spectacular.utils import OpenApiResponse, extend_schema, inline_serializer | |
| from rest_framework import generics, permissions, serializers, status | |
| from rest_framework.exceptions import ValidationError | |
| from rest_framework.response import Response | |
| from rest_framework.throttling import ScopedRateThrottle | |
| from rest_framework.views import APIView | |
| from rest_framework_simplejwt.exceptions import TokenError | |
| from rest_framework_simplejwt.tokens import RefreshToken | |
| from django.contrib.auth import get_user_model | |
| from django.db import transaction, IntegrityError | |
| from apps.skills.models import Skill, UserSkill | |
| from apps.skills.utils import proficiency_to_level | |
| from .models import UserProfile | |
| from .serializers import ( | |
| LoginSerializer, | |
| ProfileWithSkillsSerializer, | |
| RegisterSerializer, | |
| UserProfileSerializer, | |
| UserSerializer, | |
| ) | |
| User = get_user_model() | |
| # Shape for endpoints that issue JWTs (register, login). | |
| _AUTH_RESPONSE = inline_serializer( | |
| name='AuthTokenResponse', | |
| fields={ | |
| 'access': serializers.CharField(), | |
| 'refresh': serializers.CharField(), | |
| 'user': UserSerializer(), | |
| }, | |
| ) | |
| class RegisterView(generics.CreateAPIView): | |
| queryset = User.objects.all() | |
| permission_classes = [permissions.AllowAny] | |
| throttle_classes = [ScopedRateThrottle] | |
| throttle_scope = 'register' | |
| serializer_class = RegisterSerializer | |
| def create(self, request, *args, **kwargs): | |
| serializer = self.get_serializer(data=request.data) | |
| serializer.is_valid(raise_exception=True) | |
| try: | |
| user = serializer.save() | |
| except IntegrityError: | |
| # Concurrent-registration race: two simultaneous case-variant emails | |
| # can both pass the case-insensitive validate_email check; both then | |
| # insert the SAME app-lowercased value, and the plain `unique=True` | |
| # constraint on email/username rejects the loser. Surface a clean 400 | |
| # keyed on `email` (matching validate_email) instead of a 500. | |
| return Response( | |
| {'email': ['A user with this email already exists.']}, | |
| status=status.HTTP_400_BAD_REQUEST, | |
| ) | |
| refresh = RefreshToken.for_user(user) | |
| return Response({ | |
| 'access': str(refresh.access_token), | |
| 'refresh': str(refresh), | |
| 'user': UserSerializer(user).data | |
| }, status=status.HTTP_201_CREATED) | |
| class LoginView(APIView): | |
| serializer_class = LoginSerializer | |
| permission_classes = [permissions.AllowAny] | |
| throttle_classes = [ScopedRateThrottle] | |
| throttle_scope = 'login' | |
| def post(self, request): | |
| serializer = LoginSerializer(data=request.data, context={'request': request}) | |
| serializer.is_valid(raise_exception=True) | |
| user = serializer.validated_data | |
| refresh = RefreshToken.for_user(user) | |
| return Response({ | |
| 'access': str(refresh.access_token), | |
| 'refresh': str(refresh), | |
| 'user': UserSerializer(user).data | |
| }, status=status.HTTP_200_OK) | |
| class LogoutView(APIView): | |
| permission_classes = [permissions.IsAuthenticated] | |
| def post(self, request): | |
| try: | |
| refresh_token = request.data.get('refresh_token') | |
| if not refresh_token: | |
| return Response( | |
| {'detail': 'refresh_token is required'}, | |
| status=status.HTTP_400_BAD_REQUEST | |
| ) | |
| token = RefreshToken(refresh_token) | |
| token.blacklist() | |
| return Response(status=status.HTTP_204_NO_CONTENT) | |
| except TokenError: | |
| return Response( | |
| {'detail': 'Invalid or expired token'}, | |
| status=status.HTTP_400_BAD_REQUEST | |
| ) | |
| class UserMeView(generics.RetrieveAPIView): | |
| serializer_class = UserSerializer | |
| permission_classes = [permissions.IsAuthenticated] | |
| def get_object(self): | |
| return self.request.user | |
| class ParseResumeStubView(APIView): | |
| """Resume parsing — Module 8 MVP. | |
| Accepts a multipart PDF upload and returns pre-fill predictions scored | |
| against the Skill catalog. This is the "degraded fallback" layer in the | |
| research/06-dataset-sourcing.md §5 fallback chain — pdfplumber + lexical | |
| match. Higher NER layers can be plugged in behind | |
| `apps.accounts.resume_parser.extract_skills_from_pdf` when the ML | |
| env-setup track (SpaCy/BERT wheels + pgvector) lands. | |
| Class name kept as-is (`ParseResumeStubView`) so URLConf + existing routes | |
| don't churn — the view is no longer a stub but the URL name is stable. | |
| """ | |
| permission_classes = [permissions.IsAuthenticated] | |
| throttle_classes = [ScopedRateThrottle] | |
| throttle_scope = 'resume_parse' | |
| def post(self, request): | |
| upload = ( | |
| request.FILES.get('resume') | |
| or request.FILES.get('file') | |
| or next(iter(request.FILES.values()), None) | |
| ) | |
| if upload is None: | |
| return Response( | |
| {"detail": "Upload a PDF file under the 'resume' form field."}, | |
| status=status.HTTP_400_BAD_REQUEST, | |
| ) | |
| if upload.size == 0: | |
| return Response( | |
| {"detail": "Uploaded file is empty."}, | |
| status=status.HTTP_400_BAD_REQUEST, | |
| ) | |
| from .resume_parser import ResumeParseError, parse_resume_envelope | |
| try: | |
| envelope = parse_resume_envelope(upload.read()) | |
| except ResumeParseError as exc: | |
| return Response( | |
| {"detail": str(exc)}, | |
| status=status.HTTP_400_BAD_REQUEST, | |
| ) | |
| return Response( | |
| { | |
| "skills": envelope["skills"], | |
| "parser_version": envelope["parser_version"], | |
| "note": ( | |
| "Predictions are merged across the layers listed above. " | |
| "Review the proficiency values — they are heuristics from " | |
| "the surrounding context, not assertions." | |
| ), | |
| }, | |
| status=status.HTTP_200_OK, | |
| ) | |
| class ProfileView(APIView): | |
| serializer_class = ProfileWithSkillsSerializer | |
| permission_classes = [permissions.IsAuthenticated] | |
| def get(self, request): | |
| user = request.user | |
| UserProfile.objects.get_or_create(user=user) | |
| serializer = ProfileWithSkillsSerializer(user) | |
| return Response(serializer.data, status=status.HTTP_200_OK) | |
| def put(self, request): | |
| user = request.user | |
| profile, _ = UserProfile.objects.get_or_create(user=user) | |
| profile_serializer = UserProfileSerializer( | |
| profile, data=request.data, partial=True | |
| ) | |
| profile_serializer.is_valid(raise_exception=True) | |
| profile_serializer.save() | |
| # Upsert skills (transactional — any error rolls back all changes) | |
| skills_data = request.data.get('skills', []) or [] | |
| requested_ids = { | |
| int(item['skill_id']) for item in skills_data | |
| if item.get('skill_id') is not None and item.get('proficiency') is not None | |
| } | |
| known_ids = set( | |
| Skill.objects.filter(id__in=requested_ids).values_list('id', flat=True) | |
| ) if requested_ids else set() | |
| for item in skills_data: | |
| skill_id = item.get('skill_id') | |
| proficiency = item.get('proficiency') | |
| if skill_id is None or proficiency is None: | |
| continue | |
| skill_id = int(skill_id) | |
| proficiency = int(proficiency) | |
| if not (0 <= proficiency <= 100): | |
| raise ValidationError(f"Proficiency must be 0-100 for skill_id {skill_id}") | |
| if skill_id not in known_ids: | |
| raise ValidationError(f"Skill with id={skill_id} does not exist") | |
| UserSkill.objects.update_or_create( | |
| user=user, skill_id=skill_id, | |
| defaults={ | |
| 'proficiency': proficiency, | |
| 'user_level': proficiency_to_level(proficiency) | |
| } | |
| ) | |
| serializer = ProfileWithSkillsSerializer(user) | |
| return Response(serializer.data, status=status.HTTP_200_OK) | |