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(), }, ) @extend_schema( responses={ 201: _AUTH_RESPONSE, 400: OpenApiResponse( description='Validation error — duplicate email, weak/mismatched ' 'password, or missing fields (body is {"": [...]}).', ), }, ) 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) @extend_schema( request=LoginSerializer, responses={200: _AUTH_RESPONSE}, ) 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) @extend_schema( request=inline_serializer( name='LogoutRequest', fields={'refresh_token': serializers.CharField()}, ), responses={ 204: OpenApiResponse(description='Token blacklisted.'), 400: OpenApiResponse(description='Missing or invalid refresh token.'), }, ) 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 @extend_schema( request={ 'multipart/form-data': inline_serializer( name='ResumeUpload', fields={'resume': serializers.FileField()}, ), }, responses={ 200: inline_serializer( name='ParseResumeResponse', fields={ 'skills': serializers.ListField(child=serializers.DictField()), 'parser_version': serializers.ListField(child=serializers.CharField()), 'note': serializers.CharField(), }, ), 400: OpenApiResponse(description='Empty / missing file or parse error.'), }, ) 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] @extend_schema(responses=ProfileWithSkillsSerializer) 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) @extend_schema( request=inline_serializer( name='ProfileUpdateRequest', fields={ 'degree': serializers.CharField(required=False, allow_blank=True), 'semester': serializers.IntegerField(required=False, allow_null=True), 'interests': serializers.CharField(required=False, allow_blank=True), 'bio': serializers.CharField(required=False, allow_blank=True), 'skills': serializers.ListField( child=inline_serializer( name='ProfileSkillItem', fields={ 'skill_id': serializers.IntegerField(), 'proficiency': serializers.IntegerField(min_value=0, max_value=100), }, ), required=False, ), }, ), responses=ProfileWithSkillsSerializer, ) @transaction.atomic 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)