You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 

549 lines
22 KiB

from django.contrib.auth import get_user_model
from rest_framework import serializers
from drf_spectacular.utils import extend_schema_field
from django.db.models import Q
from apps.profiles.models import (
Region,
Institution,
InstitutionMember,
InstitutionTimeline,
InstitutionMedia,
InstitutionFollower,
)
User = get_user_model()
class RegionMiniSerializer(serializers.ModelSerializer):
class Meta:
model = Region
fields = ['id', 'name', 'slug', 'code']
read_only_fields = fields
class RegionSerializer(serializers.ModelSerializer):
institutions_count = serializers.IntegerField(source='institutions.count', read_only=True)
class Meta:
model = Region
fields = [
'id',
'name',
'slug',
'code',
'description',
'is_active',
'institutions_count',
'created_at',
'updated_at',
]
read_only_fields = ['id', 'slug', 'institutions_count', 'created_at', 'updated_at']
class InstitutionTimelineSerializer(serializers.ModelSerializer):
class Meta:
model = InstitutionTimeline
fields = [
'id',
'institution',
'year',
'title',
'description',
'order',
'created_at',
]
read_only_fields = ['id', 'institution', 'created_at']
class InstitutionMediaSerializer(serializers.ModelSerializer):
media_type_display = serializers.CharField(source='get_media_type_display', read_only=True)
class Meta:
model = InstitutionMedia
fields = [
'id',
'institution',
'file',
'media_type',
'media_type_display',
'title',
'caption',
'uploaded_at',
]
read_only_fields = ['id', 'institution', 'uploaded_at', 'media_type_display']
class MemberUserMiniSerializer(serializers.ModelSerializer):
class Meta:
model = User
fields = ['id', 'email', 'fullname', 'avatar', 'phone_number']
read_only_fields = ['id', 'email', 'fullname', 'avatar', 'phone_number']
class InstitutionMemberSerializer(serializers.ModelSerializer):
user = MemberUserMiniSerializer(read_only=True)
role_display = serializers.CharField(source='get_role_display', read_only=True)
class Meta:
model = InstitutionMember
fields = [
'id',
'institution',
'user',
'role',
'role_display',
'title',
'joined_at',
]
read_only_fields = ['id', 'institution', 'role_display', 'joined_at']
class InstitutionMemberAddSerializer(serializers.Serializer):
email = serializers.EmailField(required=True)
role = serializers.ChoiceField(
choices=InstitutionMember.MemberRole.choices,
default=InstitutionMember.MemberRole.VIEWER
)
title = serializers.CharField(required=False, allow_blank=True, max_length=150)
def validate_email(self, value):
normalized = value.strip().lower()
if not User.objects.filter(email__iexact=normalized).exists():
raise serializers.ValidationError("No registered user found with this email address.")
return normalized
def _resolve_institution_media_url(image_field, request, fallback_url=None):
if not image_field:
return fallback_url
img_str = str(image_field)
if img_str.startswith('http://') or img_str.startswith('https://'):
return img_str
if request and ':8080' not in request.get_host():
return request.build_absolute_uri(image_field.url)
return f"http://localhost:8000{image_field.url}"
class InstitutionListSerializer(serializers.ModelSerializer):
type_display = serializers.CharField(source='get_type_display', read_only=True)
verification_status_display = serializers.CharField(source='get_verification_status_display', read_only=True)
region = RegionMiniSerializer(read_only=True)
region_id = serializers.IntegerField(source='region.id', read_only=True)
region_name = serializers.CharField(source='region.name', read_only=True)
is_following = serializers.SerializerMethodField()
avatar = serializers.SerializerMethodField()
cover_image = serializers.SerializerMethodField()
class Meta:
model = Institution
fields = [
'id',
'name',
'slug',
'type',
'type_display',
'region',
'region_id',
'region_name',
'country',
'city',
'address',
'latitude',
'longitude',
'avatar',
'cover_image',
'verification_status',
'verification_status_display',
'follower_count',
'is_featured',
'tags',
'is_following',
'created_at',
]
read_only_fields = fields
@extend_schema_field(serializers.BooleanField)
def get_is_following(self, obj) -> bool:
request = self.context.get('request')
if not request or not request.user.is_authenticated:
return False
return obj.is_followed_by(request.user)
@extend_schema_field(serializers.CharField(allow_null=True))
def get_avatar(self, obj):
fallback = "https://images.unsplash.com/photo-1584551246679-0daf3d275d0f?auto=format&fit=crop&w=400&q=80" if ('al-madinah' in obj.name.lower() or obj.id == 1171) else None
return _resolve_institution_media_url(obj.avatar, self.context.get('request'), fallback)
@extend_schema_field(serializers.CharField(allow_null=True))
def get_cover_image(self, obj):
fallback = "https://images.unsplash.com/photo-1519817650390-64a93db51149?auto=format&fit=crop&w=1600&q=80" if ('al-madinah' in obj.name.lower() or obj.id == 1171) else None
return _resolve_institution_media_url(obj.cover_image, self.context.get('request'), fallback)
class InstitutionDetailSerializer(serializers.ModelSerializer):
type_display = serializers.CharField(source='get_type_display', read_only=True)
verification_status_display = serializers.CharField(source='get_verification_status_display', read_only=True)
region = RegionSerializer(read_only=True)
region_id = serializers.IntegerField(source='region.id', read_only=True)
region_name = serializers.CharField(source='region.name', read_only=True)
timeline_entries = InstitutionTimelineSerializer(many=True, read_only=True)
media_items = InstitutionMediaSerializer(many=True, read_only=True)
projects = serializers.SerializerMethodField()
posts = serializers.SerializerMethodField()
members_count = serializers.SerializerMethodField()
is_following = serializers.SerializerMethodField()
user_role = serializers.SerializerMethodField()
created_by_email = serializers.EmailField(source='created_by.email', read_only=True, allow_null=True)
avatar = serializers.SerializerMethodField()
cover_image = serializers.SerializerMethodField()
class Meta:
model = Institution
fields = [
'id',
'name',
'slug',
'type',
'type_display',
'region',
'region_id',
'region_name',
'country',
'city',
'address',
'latitude',
'longitude',
'description',
'website',
'email',
'phone',
'established_year',
'cover_image',
'avatar',
'social_media',
'working_hours',
'tags',
'verification_status',
'verification_status_display',
'follower_count',
'is_featured',
'is_active',
'created_by_email',
'members_count',
'is_following',
'user_role',
'timeline_entries',
'media_items',
'projects',
'posts',
'created_at',
'updated_at',
]
read_only_fields = [
'id',
'slug',
'region',
'region_id',
'region_name',
'verification_status',
'verification_status_display',
'follower_count',
'is_featured',
'is_active',
'created_by_email',
'members_count',
'is_following',
'user_role',
'timeline_entries',
'media_items',
'projects',
'posts',
'created_at',
'updated_at',
]
@extend_schema_field(serializers.IntegerField)
def get_members_count(self, obj) -> int:
return obj.members.count()
@extend_schema_field(serializers.BooleanField)
def get_is_following(self, obj) -> bool:
request = self.context.get('request')
if not request or not request.user.is_authenticated:
return False
return obj.is_followed_by(request.user)
@extend_schema_field(serializers.CharField(allow_null=True))
def get_user_role(self, obj):
request = self.context.get('request')
if not request or not request.user.is_authenticated:
return None
return obj.get_user_role(request.user)
@extend_schema_field(serializers.CharField(allow_null=True))
def get_avatar(self, obj):
fallback = "https://images.unsplash.com/photo-1584551246679-0daf3d275d0f?auto=format&fit=crop&w=400&q=80" if ('al-madinah' in obj.name.lower() or obj.id == 1171) else None
return _resolve_institution_media_url(obj.avatar, self.context.get('request'), fallback)
@extend_schema_field(serializers.CharField(allow_null=True))
def get_cover_image(self, obj):
fallback = "https://images.unsplash.com/photo-1519817650390-64a93db51149?auto=format&fit=crop&w=1600&q=80" if ('al-madinah' in obj.name.lower() or obj.id == 1171) else None
return _resolve_institution_media_url(obj.cover_image, self.context.get('request'), fallback)
@extend_schema_field(serializers.ListField)
def get_projects(self, obj):
if not hasattr(obj, 'owned_projects'):
return []
qs = obj.owned_projects.all().order_by('-start_date')[:10]
return [
{
"id": p.id,
"title": p.title,
"type": p.category or "Community Project",
"progress": p.progress_percentage,
"isCompleted": p.status == 'completed',
"collaboration": p.collaborating_institutions.exists(),
"collaboratorName": ", ".join(c.name for c in p.collaborating_institutions.all()[:2]) if p.collaborating_institutions.exists() else None,
"description": p.description,
"startDate": str(p.start_date) if p.start_date else None,
"endDate": str(p.actual_end_date) if p.actual_end_date else None,
"estimatedEndDate": str(p.estimated_end_date) if p.estimated_end_date else None,
}
for p in qs
]
@extend_schema_field(serializers.ListField)
def get_posts(self, obj):
if not hasattr(obj, 'cms_posts'):
return []
qs = obj.cms_posts.filter(status='published').order_by('-publish_date')[:10]
request = self.context.get('request')
# Curated relatable internet images mapping for topics/keywords
relatable_map = {
'gala': "https://images.unsplash.com/photo-1564769625905-50e93615e769?auto=format&fit=crop&w=1200&q=80",
'open house': "https://images.unsplash.com/photo-1564769625905-50e93615e769?auto=format&fit=crop&w=1200&q=80",
'interfaith': "https://images.unsplash.com/photo-1564769625905-50e93615e769?auto=format&fit=crop&w=1200&q=80",
'youth': "https://images.unsplash.com/photo-1584286595398-a59f21d313f5?auto=format&fit=crop&w=1200&q=80",
'digital wellbeing': "https://images.unsplash.com/photo-1584286595398-a59f21d313f5?auto=format&fit=crop&w=1200&q=80",
'guidance': "https://images.unsplash.com/photo-1584286595398-a59f21d313f5?auto=format&fit=crop&w=1200&q=80",
'quran': "https://images.unsplash.com/photo-1609599006353-e629aaabfeae?auto=format&fit=crop&w=1200&q=80",
'arabic': "https://images.unsplash.com/photo-1609599006353-e629aaabfeae?auto=format&fit=crop&w=1200&q=80",
'academy': "https://images.unsplash.com/photo-1609599006353-e629aaabfeae?auto=format&fit=crop&w=1200&q=80",
'tajweed': "https://images.unsplash.com/photo-1609599006353-e629aaabfeae?auto=format&fit=crop&w=1200&q=80",
'meal': "https://images.unsplash.com/photo-1593113598332-cd288d649433?auto=format&fit=crop&w=1200&q=80",
'food': "https://images.unsplash.com/photo-1593113598332-cd288d649433?auto=format&fit=crop&w=1200&q=80",
'hamper': "https://images.unsplash.com/photo-1593113598332-cd288d649433?auto=format&fit=crop&w=1200&q=80",
'impact': "https://images.unsplash.com/photo-1593113598332-cd288d649433?auto=format&fit=crop&w=1200&q=80",
'charity': "https://images.unsplash.com/photo-1593113598332-cd288d649433?auto=format&fit=crop&w=1200&q=80",
}
posts_data = []
for post in qs:
image_url = None
# 1. Check if post has a direct internet URL stored in tags
if isinstance(post.tags, list):
for tag in post.tags:
if isinstance(tag, dict) and tag.get('image_url'):
image_url = tag['image_url']
break
elif isinstance(tag, str) and (tag.startswith('http://') or tag.startswith('https://')):
image_url = tag
break
# 2. Check keyword matching for relatable internet image
if not image_url:
title_lower = (post.title or '').lower()
cat_lower = (post.category.name if post.category else '').lower()
for kw, url in relatable_map.items():
if kw in title_lower or kw in cat_lower:
image_url = url
break
# 3. Fallback to featured_image with proper host (avoid Vite non-proxied media 404)
if not image_url and post.featured_image:
feat_str = str(post.featured_image)
if feat_str.startswith('http://') or feat_str.startswith('https://'):
image_url = feat_str
else:
if request and ':8080' not in request.get_host():
image_url = request.build_absolute_uri(post.featured_image.url)
else:
image_url = f"http://localhost:8000{post.featured_image.url}"
# 4. Final fallback
if not image_url:
image_url = "https://images.unsplash.com/photo-1519817650390-64a93db51149?auto=format&fit=crop&w=1200&q=80"
posts_data.append({
"id": post.id,
"title": post.title,
"category": post.category.name if post.category else post.get_post_type_display(),
"content": post.content,
"excerpt": post.excerpt or (post.content[:160] + "..." if post.content else ""),
"image": image_url,
"author": post.author.fullname or post.author.get_full_name() or post.author_role_label or "Editorial Team",
"publishDate": post.publish_date.strftime('%Y/%m/%d') if post.publish_date else None,
"readTime": f"{post.reading_time_minutes} min",
"likes": post.likes_count,
})
return posts_data
class InstitutionCreateUpdateSerializer(serializers.ModelSerializer):
region = serializers.PrimaryKeyRelatedField(
queryset=Region.objects.all(),
required=True,
allow_null=False,
help_text="Region ID this institution belongs to (required)."
)
class Meta:
model = Institution
fields = [
'name',
'type',
'region',
'country',
'city',
'address',
'latitude',
'longitude',
'description',
'website',
'email',
'phone',
'established_year',
'cover_image',
'avatar',
'social_media',
'working_hours',
'tags',
'is_featured',
]
def to_internal_value(self, data):
if hasattr(data, 'copy'):
data = data.copy()
else:
data = dict(data)
# Normalize region: handle ID, dict with id, slug, or name
region_val = data.get('region') if data.get('region') is not None else data.get('region_id')
if region_val is not None:
if isinstance(region_val, dict) and 'id' in region_val:
data['region'] = region_val['id']
elif isinstance(region_val, str) and not str(region_val).isdigit():
r = Region.objects.filter(Q(slug=region_val) | Q(name__iexact=region_val)).first()
if r:
data['region'] = r.id
# Normalize institution type if passed in Persian or other aliases
inst_type = data.get('type')
if inst_type:
type_map = {
'مؤسسه فرهنگی و مذهبی': 'cultural_center',
'مرکز فرهنگی': 'cultural_center',
'مسجد': 'mosque',
'حسینیه': 'hussainiya',
'حوزه علمیه': 'institute',
'مرکز آموزشی': 'institute',
'مرکز پژوهشی': 'institute',
'مرکز خیریه': 'charity',
'خیریه': 'charity',
'کتابخانه': 'library',
'سایر': 'other',
}
if inst_type in type_map:
data['type'] = type_map[inst_type]
# Normalize established / established_year
est = data.get('established') if data.get('established') is not None else data.get('established_year')
if est is not None and not data.get('established_year'):
try:
persian_digits = '۰۱۲۳۴۵۶۷۸۹'
arabic_digits = '٠١٢٣٤٥٦٧٨٩'
est_str = str(est).strip()
for i, d in enumerate(persian_digits):
est_str = est_str.replace(d, str(i))
for i, d in enumerate(arabic_digits):
est_str = est_str.replace(d, str(i))
if est_str:
data['established_year'] = int(est_str)
except (ValueError, TypeError):
data['established_year'] = None
# Normalize website: prepend https:// if missing scheme
website = data.get('website')
if website and isinstance(website, str) and website.strip():
website = website.strip()
if not (website.startswith('http://') or website.startswith('https://')):
data['website'] = f'https://{website}'
# Normalize camelCase socialMedia / workingHours if provided
if 'socialMedia' in data and not data.get('social_media'):
data['social_media'] = data.get('socialMedia') or {}
if 'workingHours' in data and not data.get('working_hours'):
data['working_hours'] = data.get('workingHours') or {}
# If cover_image or avatar is not an actual uploaded file (e.g. string path or URL), ignore it so ImageField doesn't fail
for img_field in ['cover_image', 'avatar']:
val = data.get(img_field)
if val is not None and not hasattr(val, 'read'):
data.pop(img_field, None)
return super().to_internal_value(data)
def validate(self, attrs):
request = self.context.get('request')
if request and request.user and request.user.is_authenticated:
user = request.user
if getattr(user, 'is_regional_admin', False) and not (getattr(user, 'is_super_admin', False) or getattr(user, 'is_superuser', False)):
if self.instance and hasattr(user, 'can_manage_region') and not user.can_manage_region(self.instance.region):
raise serializers.ValidationError({
"detail": "You do not have administrative authority over institutions in this region."
})
new_region = attrs.get('region')
if new_region and hasattr(user, 'can_manage_region') and not user.can_manage_region(new_region):
raise serializers.ValidationError({
"region": "You cannot assign an institution to a region outside your administrative jurisdiction."
})
return attrs
def create(self, validated_data):
user = self.context.get('request').user if self.context.get('request') else None
if user and user.is_authenticated:
validated_data['created_by'] = user
# Only super admins or regional admins managing this region can set is_featured directly upon creation
if user:
if getattr(user, 'is_super_admin', False) or getattr(user, 'is_superuser', False):
pass
elif getattr(user, 'is_regional_admin', False):
region = validated_data.get('region')
if hasattr(user, 'can_manage_region') and not user.can_manage_region(region):
validated_data['is_featured'] = False
else:
validated_data['is_featured'] = False
institution = super().create(validated_data)
# Creator automatically becomes the Admin member
if user and user.is_authenticated:
InstitutionMember.objects.create(
institution=institution,
user=user,
role=InstitutionMember.MemberRole.ADMIN,
title="Founder / Primary Administrator"
)
return institution
class FollowResponseSerializer(serializers.Serializer):
is_following = serializers.BooleanField()
follower_count = serializers.IntegerField()
message = serializers.CharField()