Spaces:
Sleeping
Sleeping
| 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=<id>` 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 | |
| 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) | |
| 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 | |