gapguide-api / apps /progress /views.py
arifRB's picture
Deploy GapGuide backend (Docker)
ffd36e0 verified
Raw
History Blame Contribute Delete
11.9 kB
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)