gapguide-api / apps /accounts /views.py
arifRB's picture
Deploy GapGuide backend (Docker)
ffd36e0 verified
Raw
History Blame Contribute Delete
11 kB
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 {"<field>": [...]}).',
),
},
)
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)