from django.contrib.auth import get_user_model from django.utils import timezone from django.utils.translation import gettext_lazy as _ from rest_framework import serializers from drf_spectacular.utils import extend_schema_field from apps.cms.models.post import ( PostCategory, PostTag, Post, PostRevision, PostComment, PostLike, MediaAsset, ) from apps.profiles.models.institution import Institution from apps.cms.permissions import can_publish_or_moderate_post User = get_user_model() class PostCategorySerializer(serializers.ModelSerializer): posts_count = serializers.SerializerMethodField() class Meta: model = PostCategory fields = [ 'id', 'name', 'slug', 'language', 'description', 'created_at', 'posts_count', ] read_only_fields = ['id', 'slug', 'created_at', 'posts_count'] @extend_schema_field(serializers.IntegerField) def get_posts_count(self, obj) -> int: return obj.posts.filter(status=Post.Status.PUBLISHED).count() class PostTagSerializer(serializers.ModelSerializer): class Meta: model = PostTag fields = ['id', 'name', 'slug'] read_only_fields = ['id', 'slug'] class PostAuthorMiniSerializer(serializers.ModelSerializer): class Meta: model = User fields = ['id', 'email', 'fullname', 'avatar'] read_only_fields = fields class PostInstitutionMiniSerializer(serializers.ModelSerializer): type_display = serializers.CharField(source='get_type_display', read_only=True) class Meta: model = Institution fields = ['id', 'name', 'slug', 'type', 'type_display', 'avatar', 'city', 'country'] read_only_fields = fields class PostAuthorDisplaySerializer(serializers.Serializer): type = serializers.CharField(help_text="Author type: 'user' or 'institution'") id = serializers.IntegerField(help_text="ID of the user or institution", allow_null=True) name = serializers.CharField(help_text="Name of the author or institution") slug = serializers.CharField(allow_null=True, required=False) avatar = serializers.CharField(allow_null=True, required=False) role_label = serializers.CharField(allow_null=True, required=False) submitted_by = serializers.DictField(allow_null=True, required=False) def resolve_post_author_display(obj, request=None) -> dict: custom_avatar = obj.author_custom_avatar.url if getattr(obj, 'author_custom_avatar', None) else None if custom_avatar and request and not custom_avatar.startswith(('http://', 'https://')): custom_avatar = request.build_absolute_uri(custom_avatar) if obj.author_type == Post.AuthorType.INSTITUTION and obj.institution: inst = obj.institution avatar_url = custom_avatar or (inst.avatar.url if getattr(inst, 'avatar', None) else None) if avatar_url and request and not avatar_url.startswith(('http://', 'https://')): avatar_url = request.build_absolute_uri(avatar_url) user = obj.author submitted_by = None if user: submitted_by = { 'id': user.id, 'name': getattr(user, 'fullname', None) or user.get_full_name() or user.email, } return { 'type': 'institution', 'id': inst.id, 'name': inst.name, 'slug': inst.slug, 'avatar': avatar_url, 'role_label': obj.author_role_label, 'submitted_by': submitted_by, } user = obj.author avatar_url = custom_avatar or (user.avatar.url if (user and getattr(user, 'avatar', None)) else None) if avatar_url and request and not avatar_url.startswith(('http://', 'https://')): avatar_url = request.build_absolute_uri(avatar_url) return { 'type': 'user', 'id': user.id if user else None, 'name': (getattr(user, 'fullname', None) or user.get_full_name() or user.email) if user else '', 'slug': getattr(user, 'username', None), 'avatar': avatar_url, 'role_label': obj.author_role_label, 'submitted_by': None, } class PostRevisionSerializer(serializers.ModelSerializer): author = PostAuthorMiniSerializer(read_only=True) reviewed_by = PostAuthorMiniSerializer(read_only=True) status_display = serializers.CharField(source='get_status_display', read_only=True) category_name = serializers.CharField(source='category.name', read_only=True, allow_null=True) class Meta: model = PostRevision fields = [ 'id', 'post', 'author', 'title', 'excerpt', 'content', 'tags', 'featured_image', 'category', 'category_name', 'status', 'status_display', 'rejection_reason', 'reviewed_by', 'reviewed_at', 'created_at', 'updated_at', ] read_only_fields = fields class PostRevisionCreateSerializer(serializers.ModelSerializer): class Meta: model = PostRevision fields = [ 'title', 'excerpt', 'content', 'tags', 'featured_image', 'category', ] class PostCommentSerializer(serializers.ModelSerializer): user = PostAuthorMiniSerializer(read_only=True) status_display = serializers.CharField(source='get_status_display', read_only=True) class Meta: model = PostComment fields = [ 'id', 'post', 'user', 'author_name', 'author_email', 'content', 'status', 'status_display', 'created_at', ] read_only_fields = ['id', 'post', 'user', 'status', 'status_display', 'created_at'] class PostCommentCreateSerializer(serializers.Serializer): content = serializers.CharField(required=True) author_name = serializers.CharField(required=False, allow_blank=True, max_length=255) author_email = serializers.EmailField(required=False, allow_blank=True) class PostListSerializer(serializers.ModelSerializer): post_type_display = serializers.CharField(source='get_post_type_display', read_only=True) author_type_display = serializers.CharField(source='get_author_type_display', read_only=True) author_display = serializers.SerializerMethodField() category_name = serializers.CharField(source='category.name', read_only=True, allow_null=True) author = PostAuthorMiniSerializer(read_only=True) institution = PostInstitutionMiniSerializer(read_only=True) comments_count = serializers.SerializerMethodField() is_liked = serializers.SerializerMethodField() has_pending_revision = serializers.BooleanField(read_only=True) class Meta: model = Post fields = [ 'id', 'title', 'slug', 'post_type', 'post_type_display', 'author_type', 'author_type_display', 'author_display', 'category', 'category_name', 'language', 'status', 'rejection_reason', 'author', 'institution', 'author_role_label', 'author_custom_avatar', 'featured_image', 'excerpt', 'tags', 'reading_time_minutes', 'views_count', 'likes_count', 'comments_count', 'is_featured', 'publish_date', 'is_liked', 'has_pending_revision', 'reviewed_at', 'created_at', ] read_only_fields = fields @extend_schema_field(PostAuthorDisplaySerializer) def get_author_display(self, obj) -> dict: return resolve_post_author_display(obj, self.context.get('request')) @extend_schema_field(serializers.IntegerField) def get_comments_count(self, obj) -> int: return obj.comments.filter(status=PostComment.Status.APPROVED).count() @extend_schema_field(serializers.BooleanField) def get_is_liked(self, obj) -> bool: request = self.context.get('request') if not request or not request.user.is_authenticated: return False return obj.is_liked_by(request.user) class PostDetailSerializer(serializers.ModelSerializer): post_type_display = serializers.CharField(source='get_post_type_display', read_only=True) author_type_display = serializers.CharField(source='get_author_type_display', read_only=True) author_display = serializers.SerializerMethodField() category = PostCategorySerializer(read_only=True) author = PostAuthorMiniSerializer(read_only=True) institution = PostInstitutionMiniSerializer(read_only=True) reviewed_by = PostAuthorMiniSerializer(read_only=True) comments = serializers.SerializerMethodField() comments_count = serializers.SerializerMethodField() is_liked = serializers.SerializerMethodField() has_pending_revision = serializers.BooleanField(read_only=True) pending_revision = serializers.SerializerMethodField() class Meta: model = Post fields = [ 'id', 'title', 'slug', 'post_type', 'post_type_display', 'author_type', 'author_type_display', 'author_display', 'category', 'language', 'status', 'rejection_reason', 'author', 'institution', 'author_role_label', 'author_custom_avatar', 'featured_image', 'excerpt', 'content', 'tags', 'reading_time_minutes', 'views_count', 'likes_count', 'comments_count', 'is_featured', 'publish_date', 'is_liked', 'has_pending_revision', 'pending_revision', 'comments', 'reviewed_by', 'reviewed_at', 'created_at', 'updated_at', ] read_only_fields = [ 'id', 'slug', 'reading_time_minutes', 'views_count', 'likes_count', 'comments_count', 'is_liked', 'has_pending_revision', 'pending_revision', 'comments', 'reviewed_by', 'reviewed_at', 'created_at', 'updated_at', ] @extend_schema_field(PostAuthorDisplaySerializer) def get_author_display(self, obj) -> dict: return resolve_post_author_display(obj, self.context.get('request')) @extend_schema_field(PostRevisionSerializer) def get_pending_revision(self, obj): request = self.context.get('request') if not request or not request.user.is_authenticated: return None user = request.user is_author = (obj.author_id == user.id) is_editor = can_publish_or_moderate_post(user, obj.institution) if is_author or is_editor: pending = obj.get_pending_revision() if pending: return PostRevisionSerializer(pending, context=self.context).data return None @extend_schema_field(PostCommentSerializer(many=True)) def get_comments(self, obj): approved_comments = obj.comments.filter(status=PostComment.Status.APPROVED).order_by('-created_at') return PostCommentSerializer(approved_comments, many=True).data @extend_schema_field(serializers.IntegerField) def get_comments_count(self, obj) -> int: return obj.comments.filter(status=PostComment.Status.APPROVED).count() @extend_schema_field(serializers.BooleanField) def get_is_liked(self, obj) -> bool: request = self.context.get('request') if not request or not request.user.is_authenticated: return False return obj.is_liked_by(request.user) class PostCreateUpdateSerializer(serializers.ModelSerializer): class Meta: model = Post fields = [ 'title', 'post_type', 'author_type', 'category', 'language', 'status', 'rejection_reason', 'institution', 'author_role_label', 'author_custom_avatar', 'featured_image', 'excerpt', 'content', 'tags', 'is_featured', 'publish_date', ] def validate(self, attrs): request = self.context.get('request') user = request.user if request else None instance = getattr(self, 'instance', None) target_author_type = attrs.get('author_type') or (instance.author_type if instance else Post.AuthorType.USER) target_institution = attrs.get('institution') or (instance.institution if instance else None) if target_author_type == Post.AuthorType.INSTITUTION: if not target_institution: raise serializers.ValidationError({ "institution": _("An institution is required when authoring on behalf of an institution.") }) if user and not (user.is_superuser or getattr(user, 'is_super_admin', False)): is_inst_editor = target_institution.is_editor(user) has_reg_perm = False if getattr(user, 'is_regional_admin', False) and hasattr(user, 'can_manage_region'): has_reg_perm = user.can_manage_region(target_institution.region) if not (is_inst_editor or has_reg_perm): raise serializers.ValidationError({ "institution": _("You are not authorized to author content on behalf of this institution.") }) target_status = attrs.get('status') if target_status: has_mod_cap = can_publish_or_moderate_post(user, target_institution) if target_status == Post.Status.PUBLISHED: if not has_mod_cap: raise serializers.ValidationError({ "status": _("You do not have direct publishing capability for this institution. Please save as draft or submit for review.") }) elif target_status == Post.Status.REJECTED: if not has_mod_cap: raise serializers.ValidationError({ "status": _("You do not have authority to reject posts for this institution.") }) reason = attrs.get('rejection_reason', '') or (instance.rejection_reason if instance else '') if not reason or not str(reason).strip(): raise serializers.ValidationError({ "rejection_reason": _("A rejection reason is required when rejecting a post.") }) elif target_status == Post.Status.ARCHIVED: if not has_mod_cap: raise serializers.ValidationError({ "status": _("You do not have authority to archive posts for this institution.") }) elif target_status in [Post.Status.PENDING_REVIEW, Post.Status.DRAFT]: # If resubmitting from rejected to draft/pending_review, clear old rejection reason if instance and instance.status == Post.Status.REJECTED and 'rejection_reason' not in attrs: attrs['rejection_reason'] = '' else: if not instance: attrs['status'] = Post.Status.DRAFT return attrs def create(self, validated_data): request = self.context.get('request') if request and request.user.is_authenticated: validated_data['author'] = request.user target_status = validated_data.get('status') if target_status in [Post.Status.PUBLISHED, Post.Status.REJECTED]: validated_data['reviewed_by'] = request.user validated_data['reviewed_at'] = timezone.now() return super().create(validated_data) def update(self, instance, validated_data): request = self.context.get('request') target_status = validated_data.get('status') if target_status and target_status != instance.status: if target_status in [Post.Status.PUBLISHED, Post.Status.REJECTED]: if request and request.user.is_authenticated: validated_data['reviewed_by'] = request.user validated_data['reviewed_at'] = timezone.now() return super().update(instance, validated_data) class PostLikeResponseSerializer(serializers.Serializer): is_liked = serializers.BooleanField() likes_count = serializers.IntegerField() message = serializers.CharField() class MediaAssetSerializer(serializers.ModelSerializer): media_type_display = serializers.CharField(source='get_media_type_display', read_only=True) uploaded_by_email = serializers.EmailField(source='uploaded_by.email', read_only=True, allow_null=True) class Meta: model = MediaAsset fields = [ 'id', 'file', 'title', 'media_type', 'media_type_display', 'file_size', 'dimensions', 'uploaded_by_email', 'created_at', ] read_only_fields = ['id', 'media_type_display', 'file_size', 'uploaded_by_email', 'created_at'] def create(self, validated_data): request = self.context.get('request') if request and request.user.is_authenticated: validated_data['uploaded_by'] = request.user return super().create(validated_data)