from drf_spectacular.types import OpenApiTypes from drf_spectacular.utils import OpenApiResponse, extend_schema, inline_serializer from django.db import transaction from django.db.models import Exists, OuterRef from django.shortcuts import get_object_or_404 from django.utils import timezone from rest_framework import generics, permissions, serializers, status from rest_framework.response import Response from rest_framework.views import APIView from apps.resources.models import Resource, ResourceCheckpoint, SkillResource from apps.roles.models import UserTargetRole from apps.skills.models import Skill from .models import UserCheckpointProgress, UserProgress from .serializers import UserProgressSerializer from .services import ( apply_upgrade, compute_upgrade_suggestions, dismiss_upgrade, ) class UserProgressListView(generics.ListAPIView): # Bounded per-user (≤ few dozen tracked resources). Intentionally # unpaginated so the frontend can render the full learning plan in one # pass. Revisit if a user ever tracks hundreds of resources. serializer_class = UserProgressSerializer permission_classes = [permissions.IsAuthenticated] pagination_class = None def get_queryset(self): # Annotate `resource_has_checkpoints` as a single Exists subquery so # UserProgressSerializer can read it without per-row queries. # # Also annotate `in_current_plan_annot`: whether each tracked resource # is linked to a skill required by the user's *active* target role. # Computed against a Python list of the active role's skill ids to # avoid a fragile nested OuterRef; no active target → empty list → # __in=[] → all False (matches the progress-write 403 gate). Annotate, # NEVER filter — the frontend renders out-of-plan rows as read-only and # the e2e flow asserts the full tracked-resource count. active_target = ( UserTargetRole.objects .filter(user=self.request.user, is_active=True) .select_related('role') .first() ) target_skill_ids = ( list(active_target.role.role_skills.values_list('skill_id', flat=True)) if active_target else [] ) return ( UserProgress.objects .filter(user=self.request.user) .select_related('resource') .annotate( resource_has_checkpoints=Exists( ResourceCheckpoint.objects.filter( resource=OuterRef('resource_id'), ) ), in_current_plan_annot=Exists( SkillResource.objects.filter( resource=OuterRef('resource_id'), skill_id__in=target_skill_ids, ) ), ) ) class ResourceProgressView(APIView): """Per-resource progress. Supports manual-slider mode when the resource has no checkpoints — otherwise the progress int is read-only and driven by the checkpoint rollup. """ serializer_class = UserProgressSerializer permission_classes = [permissions.IsAuthenticated] @extend_schema(responses=UserProgressSerializer) def get(self, request, resource_id): resource = get_object_or_404(Resource, id=resource_id) # Read-only: never write on GET. A get_or_create here pollutes the # progress table on mere reads and races with concurrent requests. # Synthesize an unsaved zero-progress instance when none exists — its # id serializes to None and has_checkpoints falls back to the resource. progress = ( UserProgress.objects .filter(user=request.user, resource=resource) .first() or UserProgress(user=request.user, resource=resource) ) return Response(UserProgressSerializer(progress).data) @extend_schema( request=inline_serializer( name='ResourceProgressUpdate', fields={ 'progress': serializers.IntegerField(min_value=0, max_value=100), }, ), responses=UserProgressSerializer, ) @transaction.atomic def post(self, request, resource_id): resource = get_object_or_404(Resource, id=resource_id) active_target = ( UserTargetRole.objects .filter(user=request.user, is_active=True) .select_related('role') .first() ) if active_target is None: return Response( {'detail': 'Select an active target role before tracking progress.'}, status=status.HTTP_403_FORBIDDEN, ) target_skill_ids = active_target.role.role_skills.values_list('skill_id', flat=True) if not resource.skillresource_set.filter(skill_id__in=target_skill_ids).exists(): return Response( {'detail': 'Resource is not part of your current learning plan.'}, status=status.HTTP_403_FORBIDDEN, ) progress, _ = UserProgress.objects.get_or_create( user=request.user, resource=resource, ) if resource.checkpoints.exists(): return Response( {'detail': 'Resource has checkpoints; update per-checkpoint progress instead.'}, status=status.HTTP_400_BAD_REQUEST, ) new_progress = request.data.get('progress') if new_progress is None: return Response( {'detail': 'progress (0-100) is required.'}, status=status.HTTP_400_BAD_REQUEST, ) try: new_progress = int(new_progress) except (TypeError, ValueError): return Response( {'detail': 'progress must be an integer 0-100.'}, status=status.HTTP_400_BAD_REQUEST, ) if not 0 <= new_progress <= 100: return Response( {'detail': 'progress must be in range 0-100.'}, status=status.HTTP_400_BAD_REQUEST, ) now = timezone.now() progress.progress = new_progress if new_progress == 0: progress.status = 'NOT_STARTED' progress.completed_at = None elif new_progress == 100: progress.status = 'COMPLETED' if not progress.started_at: progress.started_at = now if not progress.completed_at: progress.completed_at = now else: progress.status = 'IN_PROGRESS' if not progress.started_at: progress.started_at = now progress.completed_at = None progress.save() return Response(UserProgressSerializer(progress).data) @extend_schema(responses=OpenApiTypes.OBJECT) class UpgradeSuggestionListView(APIView): permission_classes = [permissions.IsAuthenticated] def get(self, request): suggestions = compute_upgrade_suggestions(request.user) return Response({'suggestions': suggestions}) @extend_schema( request=None, responses={ 200: OpenApiTypes.OBJECT, 404: OpenApiResponse(description='Skill not found.'), }, ) class UpgradeSuggestionApplyView(APIView): permission_classes = [permissions.IsAuthenticated] def post(self, request, skill_id): try: Skill.objects.get(id=skill_id) except Skill.DoesNotExist: return Response( {'detail': 'Skill not found.'}, status=status.HTTP_404_NOT_FOUND, ) result = apply_upgrade(request.user, skill_id) return Response(result) @extend_schema( request=None, responses={ 200: OpenApiTypes.OBJECT, 404: OpenApiResponse(description='Skill not found.'), }, ) class UpgradeSuggestionDismissView(APIView): permission_classes = [permissions.IsAuthenticated] def post(self, request, skill_id): try: Skill.objects.get(id=skill_id) except Skill.DoesNotExist: return Response( {'detail': 'Skill not found.'}, status=status.HTTP_404_NOT_FOUND, ) result = dismiss_upgrade(request.user, skill_id) return Response(result) @extend_schema( request=inline_serializer( name='CheckpointToggleRequest', fields={ 'completed': serializers.BooleanField( required=False, help_text='Explicit target state. Omit to toggle current state.', ), }, ), responses=UserProgressSerializer, ) class CheckpointToggleView(APIView): serializer_class = UserProgressSerializer permission_classes = [permissions.IsAuthenticated] @transaction.atomic def post(self, request, checkpoint_id): checkpoint = get_object_or_404( ResourceCheckpoint.objects.select_related('resource'), id=checkpoint_id, ) # Scope toggles to resources linked to the user's active target-role # skills. Without this, any authenticated user could fake progress on # arbitrary resources. An active target role is required — without one, # there is no learning plan to attribute progress to. active_target = ( UserTargetRole.objects .filter(user=request.user, is_active=True) .select_related('role') .first() ) if active_target is None: return Response( {'detail': 'Select an active target role before tracking progress.'}, status=status.HTTP_403_FORBIDDEN, ) target_skill_ids = active_target.role.role_skills.values_list('skill_id', flat=True) resource_in_plan = checkpoint.resource.skillresource_set.filter( skill_id__in=target_skill_ids, ).exists() if not resource_in_plan: return Response( {'detail': 'Resource is not part of your current learning plan.'}, status=status.HTTP_403_FORBIDDEN, ) # Materialize the UserProgress row race-safely, THEN lock it. A bare # select_for_update().first() locks nothing when no row exists yet, so # two concurrent first-toggles both create → IntegrityError on # unique_together(user, resource) → 500. get_or_create (savepoint-safe # inside this atomic) absorbs that race; the re-fetch with # select_for_update then takes the row lock for the sibling rollup so # concurrent toggles on sibling checkpoints see a consistent count. UserProgress.objects.get_or_create( user=request.user, resource=checkpoint.resource, ) progress = UserProgress.objects.select_for_update().get( user=request.user, resource=checkpoint.resource, ) cp_progress, _ = UserCheckpointProgress.objects.get_or_create( user=request.user, checkpoint=checkpoint, ) desired = request.data.get('completed') if desired is None: cp_progress.completed_at = None if cp_progress.completed_at else timezone.now() else: cp_progress.completed_at = timezone.now() if desired else None cp_progress.save() progress.recalculate_from_checkpoints() return Response(UserProgressSerializer(progress).data)