435 lines
14 KiB
Python
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)
|