Files
Paperclip CTO 4f7f72798f
Some checks failed
CI Build / Build Container (push) Successful in 5s
CI Quality / Ruff Lint (push) Failing after 6s
CI Security / Bandit + pip-audit (push) Successful in 19s
CI Tests / Django Tests (push) Failing after 32s
CI Tests / OpenAPI Schema (push) Has been skipped
TRA-437 Add course frontend and backend API
Backend (courses app):
- courses/serializers.py: Course, Module, Lesson, Page, Enrollment, PageProgress serializers
- courses/views.py: CourseListView, CourseDetailView, CourseEnrollView,
  CourseMyProgressView, MyEnrollmentsView, PageDetailView
- courses/urls.py: expose all 6 endpoints under /api/v1/courses/

Frontend:
- courses.html + courses.js: course catalog with "Meine Kurse" and "Alle Kurse"
  sections, enroll button with toast feedback, progress bars
- course-player.html + course-player.js: full-screen player with collapsible
  sidebar nav (module → lesson → page tree), page content renderer
  (text/video/embed), dwell gate, prev/next navigation, enrollment CTA,
  completion screen
- courses.css: course grid, player layout, sidebar nav, progress bar, dwell gate
- Add Kurse nav link to meetings.html, attendance.html
- Update index.html hero and workflow grid to include Kurse

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-20 00:13:21 +02:00

186 lines
7.0 KiB
Python

from django.db.models import Count, Prefetch
from django.shortcuts import get_object_or_404
from rest_framework import generics, status
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView
from tracking.models import Enrollment, PageProgress
from .models import Course, Lesson, Module, Page
from .serializers import (
CourseSummarySerializer,
CourseDetailSerializer,
EnrollmentSerializer,
PageDetailSerializer,
PageProgressSerializer,
)
def _attach_enrollment(course, user):
"""Attach _my_enrollment (with _progress_percent) to a Course instance."""
if not user.is_authenticated:
course._my_enrollment = None
return
try:
enrollment = Enrollment.objects.get(user=user, course=course)
except Enrollment.DoesNotExist:
course._my_enrollment = None
return
total = Page.objects.filter(lesson__module__course=course, is_required=True).count()
completed = PageProgress.objects.filter(enrollment=enrollment, is_complete=True).count()
enrollment._progress_percent = (completed * 100 // total) if total > 0 else 0
course._my_enrollment = enrollment
class CourseListView(generics.ListAPIView):
"""List all published courses with the current user's enrollment status."""
permission_classes = [IsAuthenticated]
serializer_class = CourseSummarySerializer
def get_queryset(self):
return Course.objects.filter(is_published=True).annotate(
module_count=Count("modules", distinct=True)
)
def list(self, request, *args, **kwargs):
queryset = self.get_queryset()
user = request.user
enrollments_by_course = {
e.course_id: e
for e in Enrollment.objects.filter(user=user, course__in=queryset)
}
if enrollments_by_course:
completed_counts = {
p["enrollment_id"]: p["completed"]
for p in PageProgress.objects.filter(
enrollment__in=enrollments_by_course.values(), is_complete=True
).values("enrollment_id").annotate(completed=Count("id"))
}
page_counts = {
c.id: Page.objects.filter(
lesson__module__course=c, is_required=True
).count()
for c in queryset
if c.id in enrollments_by_course
}
for course in queryset:
enrollment = enrollments_by_course.get(course.id)
if enrollment:
total = page_counts.get(course.id, 0) if enrollments_by_course else 0
completed = completed_counts.get(enrollment.id, 0) if enrollments_by_course else 0
enrollment._progress_percent = (completed * 100 // total) if total > 0 else 0
course._my_enrollment = enrollment
else:
course._my_enrollment = None
return Response(self.get_serializer(queryset, many=True).data)
class MyEnrollmentsView(generics.ListAPIView):
"""List all courses the current user is enrolled in, with progress."""
permission_classes = [IsAuthenticated]
serializer_class = EnrollmentSerializer
def get_queryset(self):
return Enrollment.objects.filter(user=self.request.user).select_related("course")
def list(self, request, *args, **kwargs):
queryset = self.get_queryset()
completed_counts = {
p["enrollment_id"]: p["completed"]
for p in PageProgress.objects.filter(
enrollment__in=queryset, is_complete=True
).values("enrollment_id").annotate(completed=Count("id"))
}
for enrollment in queryset:
total = Page.objects.filter(
lesson__module__course=enrollment.course, is_required=True
).count()
completed = completed_counts.get(enrollment.id, 0)
enrollment._progress_percent = (completed * 100 // total) if total > 0 else 0
return Response(self.get_serializer(queryset, many=True).data)
class CourseDetailView(generics.RetrieveAPIView):
"""Course detail with full module → lesson → page structure."""
permission_classes = [IsAuthenticated]
serializer_class = CourseDetailSerializer
lookup_field = "slug"
def get_queryset(self):
return Course.objects.filter(is_published=True).prefetch_related(
Prefetch(
"modules",
queryset=Module.objects.prefetch_related(
Prefetch(
"lessons",
queryset=Lesson.objects.prefetch_related(
Prefetch("pages", queryset=Page.objects.order_by("order"))
).order_by("order"),
)
).order_by("order"),
)
)
def get_object(self):
obj = super().get_object()
_attach_enrollment(obj, self.request.user)
return obj
class CourseEnrollView(APIView):
"""Enroll the current user in a course."""
permission_classes = [IsAuthenticated]
def post(self, request, slug):
course = get_object_or_404(Course, slug=slug, is_published=True)
user = request.user
org_id = user.role_bindings.values_list("org_id", flat=True).first()
if org_id is None:
return Response(
{"detail": "Kein Org-Kontext für diesen Benutzer gefunden."},
status=status.HTTP_400_BAD_REQUEST,
)
enrollment, created = Enrollment.objects.get_or_create(
user=user,
course=course,
defaults={"org_id": org_id},
)
enrollment._progress_percent = 0
s = EnrollmentSerializer(enrollment)
return Response(s.data, status=status.HTTP_201_CREATED if created else status.HTTP_200_OK)
class CourseMyProgressView(APIView):
"""Page-level progress for the current user in a specific course."""
permission_classes = [IsAuthenticated]
def get(self, request, slug):
course = get_object_or_404(Course, slug=slug, is_published=True)
try:
enrollment = Enrollment.objects.get(user=request.user, course=course)
except Enrollment.DoesNotExist:
return Response([], status=status.HTTP_200_OK)
progresses = PageProgress.objects.filter(enrollment=enrollment).select_related("page")
return Response(PageProgressSerializer(progresses, many=True).data)
class PageDetailView(APIView):
"""Full page content. Requires enrollment in the parent course."""
permission_classes = [IsAuthenticated]
def get(self, request, slug, page_id):
course = get_object_or_404(Course, slug=slug, is_published=True)
page = get_object_or_404(Page, id=page_id, lesson__module__course=course)
if not Enrollment.objects.filter(user=request.user, course=course).exists():
return Response(
{"detail": "Nicht eingeschrieben."},
status=status.HTTP_403_FORBIDDEN,
)
return Response(PageDetailSerializer(page).data)