Spaces:
Sleeping
Sleeping
| 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] | |
| 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) | |
| 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) | |
| class UpgradeSuggestionListView(APIView): | |
| permission_classes = [permissions.IsAuthenticated] | |
| def get(self, request): | |
| suggestions = compute_upgrade_suggestions(request.user) | |
| return Response({'suggestions': suggestions}) | |
| 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) | |
| 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) | |
| class CheckpointToggleView(APIView): | |
| serializer_class = UserProgressSerializer | |
| permission_classes = [permissions.IsAuthenticated] | |
| 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) | |