Ilo/apps/courses/api/serializers.py
2026-05-02 20:01:30 +03:30

435 lines
14 KiB
Python

from datetime import timedelta
from django.conf import settings
from django.utils import timezone
from rest_framework import serializers
from apps.courses.models import (
Category,
Course,
CourseFAQ,
CourseHighlight,
Lesson,
Section,
)
# --- Categories ---------------------------------------------------------------
class CategorySerializer(serializers.ModelSerializer):
course_count = serializers.IntegerField(read_only=True, default=0)
class Meta:
model = Category
fields = ["id", "slug", "name", "name_fa", "name_en", "order", "course_count"]
read_only_fields = ["id", "course_count"]
# --- Course bits --------------------------------------------------------------
class CourseHighlightSerializer(serializers.ModelSerializer):
"""Read-only nested representation."""
class Meta:
model = CourseHighlight
fields = ["id", "text", "order"]
read_only_fields = ["id"]
class CourseHighlightWriteSerializer(serializers.ModelSerializer):
course = serializers.PrimaryKeyRelatedField(queryset=Course.objects.all())
class Meta:
model = CourseHighlight
fields = ["id", "course", "text", "order"]
read_only_fields = ["id"]
class CourseFAQSerializer(serializers.ModelSerializer):
"""Read-only nested representation."""
class Meta:
model = CourseFAQ
fields = ["id", "question", "answer", "order"]
read_only_fields = ["id"]
class CourseFAQWriteSerializer(serializers.ModelSerializer):
course = serializers.PrimaryKeyRelatedField(queryset=Course.objects.all())
class Meta:
model = CourseFAQ
fields = ["id", "course", "question", "answer", "order"]
read_only_fields = ["id"]
# --- Lessons ------------------------------------------------------------------
class LessonWriteSerializer(serializers.ModelSerializer):
"""Used for create/update by the instructor."""
section = serializers.PrimaryKeyRelatedField(queryset=Section.objects.all())
class Meta:
model = Lesson
fields = [
"id",
"section",
"title",
"kind",
"body",
"video_url",
"pdf_file",
"duration_seconds",
"is_preview",
"is_downloadable",
"status",
"order",
]
read_only_fields = ["id"]
def validate(self, attrs):
kind = attrs.get("kind") or getattr(self.instance, "kind", None)
body = attrs.get("body") or (getattr(self.instance, "body", "") if self.instance else "")
video_url = attrs.get("video_url") or (
getattr(self.instance, "video_url", "") if self.instance else ""
)
pdf_file = attrs.get("pdf_file") or (
getattr(self.instance, "pdf_file", None) if self.instance else None
)
if kind == "video" and not video_url:
raise serializers.ValidationError({"video_url": "Required when kind=video."})
if kind == "pdf" and not pdf_file:
raise serializers.ValidationError({"pdf_file": "Required when kind=pdf."})
if kind in {"text", "task"} and not body:
raise serializers.ValidationError({"body": "Required when kind=text or kind=task."})
return attrs
class LessonReadSerializer(serializers.ModelSerializer):
"""Used for nested reads. Hides body/video_url/pdf for non-preview lessons in public detail
(the public detail serializer overrides those with a PreviewLessonSerializer below)."""
class Meta:
model = Lesson
fields = [
"id",
"title",
"kind",
"body",
"video_url",
"pdf_file",
"duration_seconds",
"is_preview",
"is_downloadable",
"status",
"order",
]
class PublicLessonSerializer(serializers.ModelSerializer):
"""For public catalog: only expose content for free previews; otherwise hide it."""
body = serializers.SerializerMethodField()
video_url = serializers.SerializerMethodField()
pdf_file = serializers.SerializerMethodField()
class Meta:
model = Lesson
fields = [
"id",
"title",
"kind",
"body",
"video_url",
"pdf_file",
"duration_seconds",
"is_preview",
"is_downloadable",
"order",
]
def _gated(self, obj, attr):
return getattr(obj, attr) if obj.is_preview else None
def get_body(self, obj):
return self._gated(obj, "body")
def get_video_url(self, obj):
return self._gated(obj, "video_url")
def get_pdf_file(self, obj):
f = self._gated(obj, "pdf_file")
request = self.context.get("request")
if f and request:
return request.build_absolute_uri(f.url)
return f.url if f else None
# --- Sections -----------------------------------------------------------------
class SectionWriteSerializer(serializers.ModelSerializer):
course = serializers.PrimaryKeyRelatedField(queryset=Course.objects.all())
class Meta:
model = Section
fields = ["id", "course", "title", "order"]
read_only_fields = ["id"]
class SectionReadSerializer(serializers.ModelSerializer):
lessons = LessonReadSerializer(many=True, read_only=True)
class Meta:
model = Section
fields = ["id", "title", "order", "lessons"]
class PublicSectionSerializer(serializers.ModelSerializer):
lessons = serializers.SerializerMethodField()
class Meta:
model = Section
fields = ["id", "title", "order", "lessons"]
def get_lessons(self, obj):
published = obj.lessons.filter(status="published")
return PublicLessonSerializer(published, many=True, context=self.context).data
# --- Courses ------------------------------------------------------------------
class _CourseStatsMixin(serializers.Serializer):
"""Adds annotated stats + computed badges to any Course serializer.
Requires the queryset to be passed through `annotate_course_stats()` first;
otherwise the values default to 0 / False."""
lesson_count = serializers.IntegerField(read_only=True, default=0)
enrolled_count = serializers.IntegerField(read_only=True, default=0)
total_minutes_seconds = serializers.IntegerField(read_only=True, default=0)
is_new = serializers.SerializerMethodField()
is_bestseller = serializers.SerializerMethodField()
discount_active = serializers.SerializerMethodField()
def get_is_new(self, obj):
window = getattr(settings, "COURSE_NEW_WINDOW_DAYS", 30)
return (timezone.now() - obj.created_at) < timedelta(days=window)
def get_is_bestseller(self, obj):
threshold = getattr(settings, "COURSE_BESTSELLER_THRESHOLD", 10)
return getattr(obj, "popularity", 0) >= threshold
def get_discount_active(self, obj):
return obj.discount_is_active
class CourseListSerializer(_CourseStatsMixin, serializers.ModelSerializer):
"""Shape used in /api/instructor/courses/ list and /api/courses/ list."""
instructor_id = serializers.IntegerField(read_only=True)
instructor_name = serializers.CharField(source="instructor.full_name", read_only=True)
category = CategorySerializer(read_only=True)
cover_image = serializers.SerializerMethodField()
class Meta:
model = Course
fields = [
"id",
"slug",
"title",
"tagline",
"level",
"language",
"color",
"cover_image",
"status",
"is_coming_soon",
"category",
"instructor_id",
"instructor_name",
"price_toman",
"original_price_toman",
"created_at",
"updated_at",
"published_at",
"lesson_count",
"enrolled_count",
"total_minutes_seconds",
"is_new",
"is_bestseller",
"discount_active",
"discount_starts_at",
"discount_ends_at",
]
read_only_fields = fields
def get_cover_image(self, obj):
if not obj.cover_image:
return None
request = self.context.get("request")
if request:
return request.build_absolute_uri(obj.cover_image.url)
return obj.cover_image.url
class CourseInstructorWriteSerializer(serializers.ModelSerializer):
"""Used by the instructor for create / update. Slug is auto-generated if omitted."""
class Meta:
model = Course
fields = [
"id",
"slug",
"title",
"tagline",
"description",
"level",
"language",
"category",
"cover_image",
"color",
"is_coming_soon",
"price_toman",
"original_price_toman",
]
read_only_fields = ["id", "slug"]
class CourseInstructorDetailSerializer(_CourseStatsMixin, serializers.ModelSerializer):
"""Full instructor-side view: includes drafts, sections, lessons, highlights, FAQ."""
category = CategorySerializer(read_only=True)
sections = SectionReadSerializer(many=True, read_only=True)
highlights = CourseHighlightSerializer(many=True, read_only=True)
faqs = CourseFAQSerializer(many=True, read_only=True)
cover_image = serializers.SerializerMethodField()
class Meta:
model = Course
fields = [
"id",
"slug",
"title",
"tagline",
"description",
"level",
"language",
"color",
"cover_image",
"status",
"is_coming_soon",
"published_at",
"created_at",
"updated_at",
"category",
"price_toman",
"original_price_toman",
"sections",
"highlights",
"faqs",
"lesson_count",
"enrolled_count",
"total_minutes_seconds",
"is_new",
"is_bestseller",
"discount_active",
"discount_starts_at",
"discount_ends_at",
]
read_only_fields = fields
def get_cover_image(self, obj):
if not obj.cover_image:
return None
request = self.context.get("request")
if request:
return request.build_absolute_uri(obj.cover_image.url)
return obj.cover_image.url
class CoursePublicDetailSerializer(_CourseStatsMixin, serializers.ModelSerializer):
"""Public course detail: only published sections + published lessons; preview-gated content."""
instructor_id = serializers.IntegerField(read_only=True)
instructor_name = serializers.CharField(source="instructor.full_name", read_only=True)
instructor = serializers.SerializerMethodField()
category = CategorySerializer(read_only=True)
sections = serializers.SerializerMethodField()
highlights = CourseHighlightSerializer(many=True, read_only=True)
faqs = CourseFAQSerializer(many=True, read_only=True)
cover_image = serializers.SerializerMethodField()
class Meta:
model = Course
fields = [
"id",
"slug",
"title",
"tagline",
"description",
"level",
"language",
"color",
"cover_image",
"published_at",
"created_at",
"updated_at",
"category",
"instructor_id",
"instructor_name",
"instructor",
"price_toman",
"original_price_toman",
"highlights",
"faqs",
"sections",
"lesson_count",
"enrolled_count",
"total_minutes_seconds",
"is_new",
"is_bestseller",
"discount_active",
"discount_starts_at",
"discount_ends_at",
"is_coming_soon",
]
read_only_fields = fields
def get_instructor(self, obj):
# Embedded "About instructor" card. Falls back gracefully if no profile yet.
u = obj.instructor
tp = getattr(u, "teacher_profile", None)
request = self.context.get("request")
avatar_url = None
if tp and tp.avatar:
avatar_url = request.build_absolute_uri(tp.avatar.url) if request else tp.avatar.url
return {
"id": u.id,
"full_name": u.full_name,
"headline": getattr(tp, "headline", "") if tp else "",
"bio": getattr(tp, "bio", "") if tp else "",
"is_verified": getattr(tp, "is_verified", False) if tp else False,
"years_experience": getattr(tp, "years_experience", None) if tp else None,
"avatar": avatar_url,
}
def get_cover_image(self, obj):
if not obj.cover_image:
return None
request = self.context.get("request")
if request:
return request.build_absolute_uri(obj.cover_image.url)
return obj.cover_image.url
def get_sections(self, obj):
published = obj.sections.all().prefetch_related("lessons")
return PublicSectionSerializer(published, many=True, context=self.context).data
# --- Reorder ------------------------------------------------------------------
class IDListSerializer(serializers.Serializer):
ids = serializers.ListField(child=serializers.IntegerField(), allow_empty=False)