from django.db import IntegrityError, transaction from django.db.models import Exists, OuterRef from django.utils import timezone from django_filters import FilterSet, NumberFilter from django_filters.rest_framework import DjangoFilterBackend from rest_framework import generics, permissions, status, viewsets from rest_framework.decorators import action from rest_framework.response import Response from config.mixins import IntegrityConflictMixin from config.pagination import StandardResultsSetPagination from .importers import extract_checkpoints from .models import Resource, ResourceCheckpoint, SkillResource from .serializers import ( ResourceAdminSerializer, ResourceCheckpointAdminSerializer, ResourceDetailSerializer, ResourceListSerializer, SkillResourceAdminSerializer, ) class ResourceFilterSet(FilterSet): skill = NumberFilter(field_name='skillresource__skill_id', distinct=True) class Meta: model = Resource fields = { 'provider': ['exact'], 'type': ['exact'], 'difficulty_level': ['exact'], } class ResourceListView(generics.ListAPIView): # Annotate `has_checkpoints` via an Exists subquery so the serializer can # read it as a plain field — avoids an N+1 from `obj.checkpoints.exists()` # on every row (.exists() bypasses any prefetch cache). queryset = Resource.objects.annotate( has_checkpoints=Exists( ResourceCheckpoint.objects.filter(resource=OuterRef('pk')) ) ) serializer_class = ResourceListSerializer permission_classes = [permissions.IsAuthenticated] filter_backends = [DjangoFilterBackend] filterset_class = ResourceFilterSet pagination_class = StandardResultsSetPagination class ResourceDetailView(generics.RetrieveAPIView): queryset = Resource.objects.all().prefetch_related('checkpoints') serializer_class = ResourceDetailSerializer permission_classes = [permissions.IsAuthenticated] def get_serializer_context(self): """Preload the current user's completed-checkpoint IDs for this resource. Without this, the frontend checkpoint checkboxes render unchecked on every page load even after the user has ticked them — the biggest UX wart flagged in the 7b polish list. """ ctx = super().get_serializer_context() resource_id = self.kwargs.get('pk') if resource_id and self.request.user.is_authenticated: # Import here to avoid a circular import from progress->resources. from apps.progress.models import UserCheckpointProgress ctx['completed_checkpoint_ids'] = set( UserCheckpointProgress.objects .filter( user=self.request.user, checkpoint__resource_id=resource_id, completed_at__isnull=False, ) .values_list('checkpoint_id', flat=True) ) return ctx class ResourceAdminViewSet(IntegrityConflictMixin, viewsets.ModelViewSet): """Admin-only CRUD on Resource. Cascade (model-level) cleans up linked SkillResource and ResourceCheckpoint rows on delete — but a delete is refused (409) while any student has tracked progress on the resource, so deleting never silently discards a learner's history. """ queryset = Resource.objects.all().order_by('title') serializer_class = ResourceAdminSerializer permission_classes = [permissions.IsAuthenticated, permissions.IsAdminUser] pagination_class = StandardResultsSetPagination conflict_detail = 'A resource with this URL already exists.' def destroy(self, request, *args, **kwargs): resource = self.get_object() # Import locally (matches ResourceDetailView's pattern) and to keep the # resources<->progress dependency one-directional. UserProgress is the # sufficient signal: CheckpointToggleView always get_or_creates a # UserProgress before any UserCheckpointProgress, so UCP ⟹ UP. from apps.progress.models import UserProgress if UserProgress.objects.filter(resource=resource).exists(): return Response( {'detail': ( 'Cannot delete: one or more students have tracked progress ' 'on this resource. Unlink it from roles instead.' )}, status=status.HTTP_409_CONFLICT, ) return super().destroy(request, *args, **kwargs) class ResourceCheckpointAdminViewSet(viewsets.ModelViewSet): """Admin-only CRUD on ResourceCheckpoint. Accepts `?resource=` for scoped fetches. The `bulk` action mirrors the Django admin's textarea paste at apps/resources/admin.py:58-92 — splits the input on newlines and creates N rows transactionally. """ queryset = ResourceCheckpoint.objects.all().order_by('resource_id', 'order_index') serializer_class = ResourceCheckpointAdminSerializer permission_classes = [permissions.IsAuthenticated, permissions.IsAdminUser] pagination_class = None def get_queryset(self): qs = super().get_queryset() resource_id = self.request.query_params.get('resource') if resource_id: # Guard non-numeric input: filtering on a non-int pk raises a # ValueError at query prep (→ 500). isascii() also rejects Unicode # digits (e.g. '²') that isdigit() accepts but int() can't parse. qs = qs.filter(resource_id=resource_id) if ( str(resource_id).isascii() and str(resource_id).isdigit() ) else qs.none() return qs @action(detail=False, methods=['post'], url_path='bulk') def bulk(self, request): resource_id = request.data.get('resource') text = (request.data.get('bulk') or '').strip() # `source` records provenance for imported lists (jsonld/html/youtube_api). # bulk_create below bypasses model `choices` validation (Django choices # are app-level, not a DB constraint), so validate it explicitly here. source = request.data.get('source') or 'manual' if source not in dict(ResourceCheckpoint.SOURCE_CHOICES): return Response( {'detail': f'Invalid source {source!r}.'}, status=status.HTTP_400_BAD_REQUEST, ) if not resource_id: return Response( {'detail': 'resource is required.'}, status=status.HTTP_400_BAD_REQUEST, ) try: resource = Resource.objects.get(pk=resource_id) except Resource.DoesNotExist: return Response( {'detail': 'Resource not found.'}, status=status.HTTP_404_NOT_FOUND, ) except (ValueError, TypeError): # Non-numeric resource id in the body → ValueError at pk lookup. return Response( {'detail': 'resource must be a numeric id.'}, status=status.HTTP_400_BAD_REQUEST, ) lines = [line.strip() for line in text.splitlines() if line.strip()] if not lines: return Response( {'detail': 'No non-empty lines provided.'}, status=status.HTTP_400_BAD_REQUEST, ) # Match Django admin behavior (apps/resources/admin.py:66-77): refuse # bulk-append when checkpoints already exist. Edit existing rows via # the per-row CRUD endpoint instead of bulk-adding. if resource.checkpoints.exists(): return Response( {'detail': ( 'Resource already has checkpoints. Edit individual rows ' 'via the checkpoint endpoints instead of bulk-adding.' )}, status=status.HTTP_409_CONFLICT, ) # The exists() check above and the bulk_create below are a TOCTOU: two # concurrent bulk pastes both see no checkpoints, then collide on # unique_together(resource, order_index). Catch the loser's # IntegrityError outside the atomic (clean rollback) → 409 not 500. # Stamp extraction time for non-manual (imported) lists; manual stays null. extracted_at = timezone.now() if source != 'manual' else None try: with transaction.atomic(): created = ResourceCheckpoint.objects.bulk_create([ ResourceCheckpoint( resource=resource, order_index=i, title=line, source=source, extracted_at=extracted_at, ) for i, line in enumerate(lines, start=1) ]) except IntegrityError: return Response( {'detail': ( 'Checkpoints were just added for this resource. Edit ' 'individual rows via the checkpoint endpoints instead.' )}, status=status.HTTP_409_CONFLICT, ) serializer = self.get_serializer(created, many=True) return Response(serializer.data, status=status.HTTP_201_CREATED) @action(detail=False, methods=['post'], url_path='import') def import_outline(self, request): """Fetch a resource's course page and return a *draft* checkpoint list. Read-only: writes nothing. The admin reviews/edits the returned titles, then persists them through the `bulk` action (which validates `source`). `extract_checkpoints` never raises — a blocked/empty page comes back as an empty list + a human `note` so the admin falls back to manual. """ resource_id = request.data.get('resource') if not resource_id: return Response( {'detail': 'resource is required.'}, status=status.HTTP_400_BAD_REQUEST, ) try: resource = Resource.objects.get(pk=resource_id) except Resource.DoesNotExist: return Response( {'detail': 'Resource not found.'}, status=status.HTTP_404_NOT_FOUND, ) except (ValueError, TypeError): return Response( {'detail': 'resource must be a numeric id.'}, status=status.HTTP_400_BAD_REQUEST, ) result = extract_checkpoints(resource.url) return Response( { 'source': result.source, 'provider': result.provider, 'note': result.note, 'checkpoints': [ { 'order_index': c.order_index, 'title': c.title, 'url_fragment': c.url_fragment, 'estimated_minutes': c.estimated_minutes, } for c in result.checkpoints ], }, status=status.HTTP_200_OK, ) class SkillResourceAdminViewSet(IntegrityConflictMixin, viewsets.ModelViewSet): """Admin-only CRUD on the Skill <-> Resource junction (with relevance).""" queryset = SkillResource.objects.all().select_related('skill', 'resource') serializer_class = SkillResourceAdminSerializer permission_classes = [permissions.IsAuthenticated, permissions.IsAdminUser] pagination_class = None conflict_detail = 'This skill is already linked to the resource.' def get_queryset(self): qs = super().get_queryset() resource_id = self.request.query_params.get('resource') if resource_id: # Guard non-numeric input: filtering on a non-int pk raises a # ValueError at query prep (→ 500). isascii() also rejects Unicode # digits (e.g. '²') that isdigit() accepts but int() can't parse. qs = qs.filter(resource_id=resource_id) if ( str(resource_id).isascii() and str(resource_id).isdigit() ) else qs.none() return qs