arifRB's picture
Deploy GapGuide backend (Docker)
ffd36e0 verified
Raw
History Blame Contribute Delete
12.4 kB
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
@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