diff --git a/apps/account/admin/user.py b/apps/account/admin/user.py
index 221b0cb..18fb6ac 100644
--- a/apps/account/admin/user.py
+++ b/apps/account/admin/user.py
@@ -16,6 +16,7 @@ from unfold.contrib.filters.admin import RangeDateTimeFilter
from apps.account.models import User, ClientUser, LocationHistory
from utils.admin import project_admin_site
from apps.account.admin.location import LocationHistoryInline
+from apps.account.admin.verification import VerificationDocumentInline
# =========================================================
@@ -25,12 +26,10 @@ from apps.account.admin.location import LocationHistoryInline
class UserAdminCreationForm(UserCreationForm):
class Meta(UserCreationForm.Meta):
model = User
- fields = ("fullname", "email")
+ fields = ("first_name", "last_name", "fullname", "email")
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
- if 'fullname' in self.fields:
- self.fields['fullname'].required = True
if 'email' in self.fields:
self.fields['email'].required = True
@@ -47,8 +46,6 @@ class UserAdminChangeForm(UserChangeForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
- if 'fullname' in self.fields:
- self.fields['fullname'].required = True
if 'email' in self.fields:
self.fields['email'].required = True
@@ -58,9 +55,9 @@ class UserAdmin(ModelAdmin, BaseUserAdmin):
add_form = UserAdminCreationForm
change_password_form = AdminPasswordChangeForm
compressed_fields = False
- list_display = ('fullname', 'email', 'user_type', 'is_staff', 'is_active', 'display_date_joined',)
+ list_display = ('first_name', 'last_name', 'fullname', 'email', 'user_type', 'is_staff', 'is_active', 'display_date_joined',)
ordering = ("-id",)
- search_fields = ('email', 'fullname', 'username',)
+ search_fields = ('email', 'first_name', 'last_name', 'fullname', 'username',)
list_filter = [
"user_type",
"is_active",
@@ -68,18 +65,31 @@ class UserAdmin(ModelAdmin, BaseUserAdmin):
("last_login", RangeDateTimeFilter),
("date_joined", RangeDateTimeFilter),
]
- inlines = [LocationHistoryInline]
+ inlines = [LocationHistoryInline, VerificationDocumentInline]
add_fieldsets = (
(None, {
'classes': ('wide',),
- 'fields': (('fullname', 'email'), 'phone_number', 'birthdate', 'gender', 'avatar', 'skill', 'info'),
+ 'fields': (
+ ('first_name', 'last_name'),
+ ('fullname', 'email'),
+ 'phone_number',
+ ('title', 'rank'),
+ ('institution_name', 'website'),
+ 'address',
+ 'birthdate',
+ 'gender',
+ 'avatar',
+ 'skill',
+ 'info'
+ ),
}),
)
fieldsets = (
(None, {
"fields": (
+ ("first_name", "last_name"),
("fullname", "email"),
("password",),
),
@@ -89,6 +99,9 @@ class UserAdmin(ModelAdmin, BaseUserAdmin):
"fields": (
("avatar",),
("phone_number", "gender"),
+ ("title", "rank"),
+ ("institution_name", "website"),
+ ("address",),
("birthdate", "language"),
("skill",),
("info",),
diff --git a/apps/account/admin/verification.py b/apps/account/admin/verification.py
index 6c0d8ea..d210399 100644
--- a/apps/account/admin/verification.py
+++ b/apps/account/admin/verification.py
@@ -11,22 +11,55 @@ from utils.admin import project_admin_site
class VerificationDocumentInline(StackedInline):
model = VerificationDocument
+ fk_name = 'user'
extra = 0
- fields = ('document_type', 'title', 'document_file', 'status', 'admin_notes', 'reviewed_by', 'uploaded_at')
- readonly_fields = ('uploaded_at',)
+ fields = ('document_type', 'title', 'document_file', 'document_preview', 'status', 'admin_notes', 'reviewed_by', 'uploaded_at')
+ readonly_fields = ('uploaded_at', 'document_preview')
+
+ @display(description=_('Preview / Download'))
+ def document_preview(self, obj):
+ if not obj or not obj.document_file:
+ return _("No file uploaded")
+ file_url = obj.document_file.url
+ ext = obj.document_file.name.lower().split('.')[-1] if obj.document_file.name else ''
+ if ext in ['jpg', 'jpeg', 'png', 'webp', 'gif']:
+ return format_html(
+ ''
+ '
'
+ '
↗ Open Full File',
+ url=file_url
+ )
+ return format_html(
+ ''
+ '↗ Open / Download Document'
+ '',
+ url=file_url
+ )
@admin.register(VerificationDocument, site=project_admin_site)
class VerificationDocumentAdmin(ModelAdmin):
- list_display = ('id', 'user_display', 'document_type', 'status_badge', 'uploaded_at', 'reviewed_by', 'reviewed_at')
+ list_display = (
+ 'id',
+ 'user_display',
+ 'document_type',
+ 'title',
+ 'document_link',
+ 'status_badge',
+ 'uploaded_at',
+ 'reviewed_by',
+ 'reviewed_at'
+ )
+ list_display_links = ('id', 'user_display', 'document_type', 'title')
list_filter = ('status', 'document_type', 'uploaded_at')
search_fields = ('user__email', 'user__fullname', 'title', 'admin_notes')
- readonly_fields = ('uploaded_at', 'reviewed_at')
+ readonly_fields = ('uploaded_at', 'reviewed_at', 'document_preview')
+ autocomplete_fields = ('user', 'reviewed_by')
actions = ['approve_documents', 'reject_documents']
fieldsets = (
(_('Document Information'), {
- 'fields': ('user', 'document_type', 'title', 'document_file')
+ 'fields': ('user', 'document_type', 'title', 'document_file', 'document_preview')
}),
(_('Review Status'), {
'fields': ('status', 'admin_notes', 'reviewed_by', 'reviewed_at', 'uploaded_at')
@@ -37,6 +70,56 @@ class VerificationDocumentAdmin(ModelAdmin):
def user_display(self, obj):
return obj.user.fullname or obj.user.email
+ @display(description=_('File'))
+ def document_link(self, obj):
+ if not obj.document_file:
+ return "-"
+ return format_html(
+ ''
+ 'View File ↗'
+ '',
+ url=obj.document_file.url
+ )
+
+ @display(description=_('Document Preview / File'))
+ def document_preview(self, obj):
+ if not obj or not obj.document_file:
+ return _("No file uploaded")
+
+ file_url = obj.document_file.url
+ ext = obj.document_file.name.lower().split('.')[-1] if obj.document_file.name else ''
+
+ if ext in ['jpg', 'jpeg', 'png', 'webp', 'gif']:
+ return format_html(
+ '
',
+ url=file_url
+ )
+ elif ext == 'pdf':
+ return format_html(
+ '',
+ url=file_url
+ )
+ else:
+ return format_html(
+ ''
+ '📄 Open / Download File ({name}) ↗'
+ '',
+ url=file_url,
+ name=obj.document_file.name.split('/')[-1]
+ )
+
@display(description=_('Status'), label={
VerificationDocument.Status.VERIFIED: "success",
VerificationDocument.Status.PENDING: "warning",
@@ -45,6 +128,14 @@ class VerificationDocumentAdmin(ModelAdmin):
def status_badge(self, obj):
return obj.get_status_display()
+ def save_model(self, request, obj, form, change):
+ if obj.status in [VerificationDocument.Status.VERIFIED, VerificationDocument.Status.REJECTED]:
+ if not obj.reviewed_by:
+ obj.reviewed_by = request.user
+ if not obj.reviewed_at:
+ obj.reviewed_at = timezone.now()
+ super().save_model(request, obj, form, change)
+
@action(description=_('Approve selected verification documents'))
def approve_documents(self, request, queryset):
queryset.update(
diff --git a/apps/account/management/commands/seed_fake_profiles.py b/apps/account/management/commands/seed_fake_profiles.py
new file mode 100644
index 0000000..618a22c
--- /dev/null
+++ b/apps/account/management/commands/seed_fake_profiles.py
@@ -0,0 +1,266 @@
+import os
+from django.core.management.base import BaseCommand
+from django.utils import timezone
+from apps.account.models import User, VerificationDocument
+
+
+class Command(BaseCommand):
+ help = 'Seeds realistic scholar and academic fake profiles similar to Ahmad Hashemi.'
+
+ def handle(self, *args, **kwargs):
+ admin_user = User.objects.filter(is_superuser=True).first() or User.objects.filter(is_staff=True).first()
+
+ # Update Ahmad Hashemi's avatar to downloaded real scholar photo
+ ahmad = User.objects.filter(email='ahmad@hashemi.com').first()
+ if ahmad:
+ ahmad.avatar = 'users/avatars/2026/10/avatar_ahmad_hashemi.jpg'
+ ahmad.save(update_fields=['avatar'])
+ self.stdout.write(self.style.SUCCESS("Updated Ahmad Hashemi avatar to real downloaded scholar photo."))
+
+ # Profiles definition
+ profiles_data = [
+ {
+ 'email': 'hosseini@mustafa.ac.ir',
+ 'username': 'hosseini@mustafa.ac.ir',
+ 'first_name': 'سید محمد',
+ 'last_name': 'حسینی',
+ 'fullname': 'سید محمد حسینی',
+ 'title': 'حجتالاسلام والمسلمین دکتر',
+ 'rank': 'دانشیار فلسفه و کلام اسلامی',
+ 'institution_name': 'جامعة المصطفی العالمیة',
+ 'city': 'قم',
+ 'country': 'ایران',
+ 'phone_number': '+989127514320',
+ 'website': 'https://sm-hosseini.ir',
+ 'address': 'قم، بلوار امین، کوچه ۲۱، پلاک ۱۴',
+ 'language': 'fa',
+ 'languages': ['fa', 'ar', 'en'],
+ 'skills': ['فلسفه اسلامی', 'کلام تطبیقی', 'حکمت متعالیه', 'روششناسی پژوهش', 'گفتگوی ادیان'],
+ 'info': (
+ 'دکترای تخصصی فلسفه و حکمت اسلامی از جامعة المصطفی العالمیة و دارای مدرک سطح چهار حوزه علمیه قم. '
+ 'با بیش از ۱۲ سال سابقه تدریس در مقاطع کارشناسی ارشد و دکتری و راهنمایی دهها رساله علمی در حوزه کلام جدید و فلسفه تطبیقی. '
+ 'مؤلف کتابهای «درآمدی بر معرفتشناسی دینی در عصر مدرن» و «خوانش فلسفی گفتوگوی بینالمذاهب». '
+ 'تمرکز پژوهشی فعلی بر بازخوانی میراث عقلانی تمدن اسلامی در مواجهه با چالشهای معاصر و توسعه بسترهای دیالوگ میانفرهنگی است.'
+ ),
+ 'avatar': 'users/avatars/2026/10/avatar_seyed_hosseini.jpg',
+ 'documents': [
+ {
+ 'title': 'دانشنامه: سطح ۴ حوزوی (گرایش فلسفه و کلام اسلامی)',
+ 'document_type': 'student_card',
+ 'status': 'verified',
+ 'admin_notes': 'مدارک با استعلام از مدیریت حوزههای علمیه تایید شد.',
+ },
+ {
+ 'title': 'دانشنامه: دکتری تخصصی فلسفه و حکمت اسلامی (جامعة المصطفی)',
+ 'document_type': 'student_card',
+ 'status': 'verified',
+ 'admin_notes': 'دانشنامه رسمی معتبر است.',
+ },
+ {
+ 'title': 'حکم رسمی عضویت در هیئت علمی جامعة المصطفی العالمیة',
+ 'document_type': 'recommendation_letter',
+ 'status': 'verified',
+ 'admin_notes': 'حکم کارگزینی بررسی و تایید شد.',
+ },
+ {
+ 'title': 'گواهی گذرنامه و احراز هویت بینالمللی',
+ 'document_type': 'national_id',
+ 'status': 'verified',
+ 'admin_notes': '',
+ }
+ ]
+ },
+ {
+ 'email': 'mirzaei@ut.ac.ir',
+ 'username': 'mirzaei@ut.ac.ir',
+ 'first_name': 'فاطمهزهرا',
+ 'last_name': 'میرزایی',
+ 'fullname': 'فاطمهزهرا میرزایی',
+ 'title': 'دکتر',
+ 'rank': 'استاد تمام مطالعات خانواده و حقوق اسلامی',
+ 'institution_name': 'دانشگاه تهران',
+ 'city': 'تهران',
+ 'country': 'ایران',
+ 'phone_number': '+989123849102',
+ 'website': 'https://fz-mirzaei.ut.ac.ir',
+ 'address': 'تهران، خیابان کارگر شمالی، پردیس دانشکده الهیات دانشگاه تهران',
+ 'language': 'fa',
+ 'languages': ['fa', 'en', 'ar', 'fr'],
+ 'skills': ['فقه و حقوق خانواده', 'مطالعات زنان در اسلام', 'حقوق تطبیقی', 'جامعهشناسی ادیان', 'روش تحقیق کیفی'],
+ 'info': (
+ 'استاد تمام دانشکده الهیات و معارف اسلامی دانشگاه تهران و دارنده مدرک دکتری فقه و مبانی حقوق اسلامی '
+ 'با بیش از ۱۶ سال سابقه تدریس و پژوهش دانشگاهی. عضو شورای علمی پژوهشکده مطالعات اسلامی زنان و مؤلف آثار مرجع در زمینه '
+ 'حقوق تطبیقی زن در فقه فریقین. داور مجلات بینالمللی مطالعات خاورمیانه و سرپرست علمی کارگروه توانمندسازی حقوقی زنان در کشورهای اسلامی.'
+ ),
+ 'avatar': 'users/avatars/2026/10/avatar_fatemeh_mirzaei.jpg',
+ 'documents': [
+ {
+ 'title': 'دانشنامه: دکتری فقه و مبانی حقوق اسلامی (دانشگاه تهران)',
+ 'document_type': 'student_card',
+ 'status': 'verified',
+ 'admin_notes': 'تایید شده توسط دبیرخانه آموزش دانشگاه تهران.',
+ },
+ {
+ 'title': 'حکم ارتقا به مرتبه استاد تمامی (دانشگاه تهران)',
+ 'document_type': 'recommendation_letter',
+ 'status': 'verified',
+ 'admin_notes': 'مصوبه هیئت ممیزه تایید شد.',
+ },
+ {
+ 'title': 'کارت شناسایی هیئت علمی و کارت ملی هوشمند',
+ 'document_type': 'national_id',
+ 'status': 'verified',
+ 'admin_notes': '',
+ }
+ ]
+ },
+ {
+ 'email': 'm.kamali@um.ac.ir',
+ 'username': 'm.kamali@um.ac.ir',
+ 'first_name': 'مصطفی',
+ 'last_name': 'کمالی',
+ 'fullname': 'مصطفی کمالی',
+ 'title': 'استاد',
+ 'rank': 'پژوهشگر ارشد تمدن اسلامی و نسخ خطی',
+ 'institution_name': 'دانشگاه فردوسی مشهد',
+ 'city': 'مشهد',
+ 'country': 'ایران',
+ 'phone_number': '+989151248901',
+ 'website': 'https://mkamali.ir',
+ 'address': 'مشهد، میدان آزادی، پردیس دانشگاه فردوسی، دانشکده ادبیات و علوم انسانی',
+ 'language': 'fa',
+ 'languages': ['fa', 'ar', 'en'],
+ 'skills': ['نسخهشناسی', 'تصحیح متون کهن', 'تاریخ تمدن اسلامی', 'دیجیتالسازی اسناد', 'عربی کلاسیک'],
+ 'info': (
+ 'پژوهشگر ارشد تاریخ تمدن و ملل اسلامی و متخصص احیا و تصحیح انتقادی نسخ خطی سدههای میانه اسلامی. '
+ 'دارای همکاریهای گسترده علمی با کتابخانه مرکزی آستان قدس رضوی، کتابخانه ملی ایران و مؤسسات شرقشناسی دانشگاههای لیدن و هایدلبرگ. '
+ 'سرپرست پروژه فهرستنویسی توصیفی دستنوشتههای کهن ریاضیات و نجوم دوره اسلامی.'
+ ),
+ 'avatar': 'users/avatars/2026/10/avatar_mostafa_kamali.jpg',
+ 'documents': [
+ {
+ 'title': 'دانشنامه: دکتری تاریخ و تمدن ملل اسلامی (دانشگاه فردوسی)',
+ 'document_type': 'student_card',
+ 'status': 'verified',
+ 'admin_notes': 'مدرک تحصیلی تایید شده است.',
+ },
+ {
+ 'title': 'معرفینامه پژوهشی: بنیاد پژوهشهای اسلامی آستان قدس رضوی',
+ 'document_type': 'recommendation_letter',
+ 'status': 'pending',
+ 'admin_notes': 'در حال استعلام نهایی از مرکز اسناد.',
+ },
+ {
+ 'title': 'کارت شناسایی هیئت علمی و پژوهشگر ارشد',
+ 'document_type': 'national_id',
+ 'status': 'verified',
+ 'admin_notes': '',
+ }
+ ]
+ },
+ {
+ 'email': 'altamimi@taghrib.org',
+ 'username': 'altamimi@taghrib.org',
+ 'first_name': 'حیدر',
+ 'last_name': 'التمیمی',
+ 'fullname': 'حیدر التمیمی',
+ 'title': 'آیتالله دکتر',
+ 'rank': 'رئیس مرکز پژوهشهای تقریب و مذاهب اسلامی',
+ 'institution_name': 'مجمع جهانی تقریب مذاهب اسلامی',
+ 'city': 'نجف',
+ 'country': 'عراق',
+ 'phone_number': '+9647801234567',
+ 'website': 'https://altamimi-center.org',
+ 'address': 'نجف اشرف، شارع الرسول، مرکز الدراسات الاسلامیة الموحدة',
+ 'language': 'ar',
+ 'languages': ['ar', 'fa', 'en'],
+ 'skills': ['فقه مقارن', 'تقریب مذاهب', 'علوم حدیث و رجال', 'اصول استنباط', 'گفتگوی وحدت اسلامی'],
+ 'info': (
+ 'استاد درس خارج فقه و اصول حوزه علمیه نجف اشرف و دارنده دکترای فقه مقارن با بیش از بیست سال فعالیت در ترویج گفتوگو '
+ 'و همگرایی میان علمای جهان اسلام. عضو هیئت امنای مجمع جهانی تقریب مذاهب اسلامی و نویسنده موسوعه فقهی تطبیقی مذاهب خمسه. '
+ 'سخنران اصلی در نشستهای بینالمللی الازهر، مکه مکرمه، تهران و استانبول.'
+ ),
+ 'avatar': 'users/avatars/2026/10/avatar_haidar_tamimi.jpg',
+ 'documents': [
+ {
+ 'title': 'اجازهنامه اجتهاد و تدریس خارج فقه (حوزه علمیه نجف اشرف)',
+ 'document_type': 'student_card',
+ 'status': 'verified',
+ 'admin_notes': 'گواهی سطح عالی حوزوی تایید گردید.',
+ },
+ {
+ 'title': 'دانشنامه: دکتری تخصصی فقه مقارن و حقوق اسلامی',
+ 'document_type': 'student_card',
+ 'status': 'verified',
+ 'admin_notes': 'دانشنامه تطبیقی مورد تایید است.',
+ },
+ {
+ 'title': 'حکم ریاست شورای پژوهشی تقریب مذاهب اسلامی',
+ 'document_type': 'institution_license',
+ 'status': 'verified',
+ 'admin_notes': 'حکم رسمی معتبر است.',
+ },
+ {
+ 'title': 'گواهی گذرنامه و احراز هویت بینالمللی',
+ 'document_type': 'national_id',
+ 'status': 'pending',
+ 'admin_notes': 'در انتظار تایید مدارک هویتی تکمیلی.',
+ }
+ ]
+ }
+ ]
+
+ sample_doc_file = 'users/documents/2026/10/institution_license.pdf'
+
+ for p_data in profiles_data:
+ email = p_data['email']
+ user, created = User.objects.get_or_create(email=email, defaults={'username': p_data['username']})
+
+ user.first_name = p_data['first_name']
+ user.last_name = p_data['last_name']
+ user.fullname = p_data['fullname']
+ user.title = p_data['title']
+ user.rank = p_data['rank']
+ user.institution_name = p_data['institution_name']
+ user.city = p_data['city']
+ user.country = p_data['country']
+ user.phone_number = p_data['phone_number']
+ user.website = p_data['website']
+ user.address = p_data['address']
+ user.language = p_data['language']
+ user.languages = p_data['languages']
+ user.skills = p_data['skills']
+ user.info = p_data['info']
+ user.user_type = User.UserType.CLIENT
+ user.is_active = True
+ user.avatar = p_data['avatar']
+
+ # Standardized password (12345678)
+ user.set_password('12345678')
+ if hasattr(user, 'set_plain_password'):
+ try:
+ user.set_plain_password('12345678')
+ except Exception:
+ pass
+
+ user.save()
+
+ # Verification documents
+ for doc_item in p_data['documents']:
+ VerificationDocument.objects.get_or_create(
+ user=user,
+ title=doc_item['title'],
+ defaults={
+ 'document_type': doc_item['document_type'],
+ 'document_file': sample_doc_file,
+ 'status': doc_item['status'],
+ 'admin_notes': doc_item['admin_notes'],
+ 'reviewed_by': admin_user if doc_item['status'] == 'verified' else None,
+ 'reviewed_at': timezone.now() if doc_item['status'] == 'verified' else None,
+ }
+ )
+
+ action_text = "Created" if created else "Updated"
+ self.stdout.write(self.style.SUCCESS(f"{action_text} profile: {user.fullname} ({user.email})"))
+
+ self.stdout.write(self.style.SUCCESS("All fake profiles seeded successfully!"))
diff --git a/apps/account/migrations/0003_user_first_name_user_last_name.py b/apps/account/migrations/0003_user_first_name_user_last_name.py
new file mode 100644
index 0000000..bf5f7b2
--- /dev/null
+++ b/apps/account/migrations/0003_user_first_name_user_last_name.py
@@ -0,0 +1,41 @@
+# Generated by Django 4.2.30 on 2026-10-03 09:07
+
+from django.db import migrations, models
+
+
+def populate_first_and_last_names(apps, schema_editor):
+ User = apps.get_model('account', 'User')
+ for user in User.objects.all():
+ if user.fullname and not user.first_name and not user.last_name:
+ parts = user.fullname.strip().split(maxsplit=1)
+ if len(parts) == 2:
+ user.first_name = parts[0]
+ user.last_name = parts[1]
+ elif len(parts) == 1:
+ user.first_name = parts[0]
+ user.last_name = ""
+ user.save(update_fields=['first_name', 'last_name'])
+
+def reverse_populate(apps, schema_editor):
+ pass
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('account', '0002_user_languages_user_skills_alter_user_language_and_more'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='user',
+ name='first_name',
+ field=models.CharField(blank=True, max_length=150, null=True, verbose_name='First Name'),
+ ),
+ migrations.AddField(
+ model_name='user',
+ name='last_name',
+ field=models.CharField(blank=True, max_length=150, null=True, verbose_name='Last Name'),
+ ),
+ migrations.RunPython(populate_first_and_last_names, reverse_populate),
+ ]
diff --git a/apps/account/migrations/0004_user_address_user_institution_name_user_rank_and_more.py b/apps/account/migrations/0004_user_address_user_institution_name_user_rank_and_more.py
new file mode 100644
index 0000000..5bc9362
--- /dev/null
+++ b/apps/account/migrations/0004_user_address_user_institution_name_user_rank_and_more.py
@@ -0,0 +1,38 @@
+# Generated by Django 4.2.30 on 2026-10-03 09:33
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('account', '0003_user_first_name_user_last_name'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='user',
+ name='address',
+ field=models.TextField(blank=True, null=True, verbose_name='Postal Address'),
+ ),
+ migrations.AddField(
+ model_name='user',
+ name='institution_name',
+ field=models.CharField(blank=True, max_length=255, null=True, verbose_name='Affiliated Institution'),
+ ),
+ migrations.AddField(
+ model_name='user',
+ name='rank',
+ field=models.CharField(blank=True, max_length=255, null=True, verbose_name='Academic / Clerical Rank'),
+ ),
+ migrations.AddField(
+ model_name='user',
+ name='title',
+ field=models.CharField(blank=True, max_length=255, null=True, verbose_name='Title / Honorific'),
+ ),
+ migrations.AddField(
+ model_name='user',
+ name='website',
+ field=models.CharField(blank=True, max_length=512, null=True, verbose_name='Personal Website'),
+ ),
+ ]
diff --git a/apps/account/models/user.py b/apps/account/models/user.py
index d6fbc96..8f75f76 100644
--- a/apps/account/models/user.py
+++ b/apps/account/models/user.py
@@ -36,8 +36,18 @@ class User(AbstractUser):
FEMALE = 'female', _('Female')
OTHER = 'other', _('Other')
- last_name = None
- first_name = None
+ first_name = models.CharField(
+ max_length=150,
+ verbose_name=_("First Name"),
+ null=True,
+ blank=True
+ )
+ last_name = models.CharField(
+ max_length=150,
+ verbose_name=_("Last Name"),
+ null=True,
+ blank=True
+ )
username = models.CharField(
unique=True,
null=True,
@@ -104,6 +114,11 @@ class User(AbstractUser):
city = models.CharField(verbose_name=_('City'), max_length=255, null=True, blank=True)
country = models.CharField(max_length=255, verbose_name=_('Country'), null=True, blank=True)
+ title = models.CharField(max_length=255, null=True, blank=True, verbose_name=_('Title / Honorific'))
+ rank = models.CharField(max_length=255, null=True, blank=True, verbose_name=_('Academic / Clerical Rank'))
+ institution_name = models.CharField(max_length=255, null=True, blank=True, verbose_name=_('Affiliated Institution'))
+ website = models.CharField(max_length=512, null=True, blank=True, verbose_name=_('Personal Website'))
+ address = models.TextField(null=True, blank=True, verbose_name=_('Postal Address'))
device_id = models.CharField(verbose_name=_('Device ID'), max_length=255, null=True, blank=True)
device_os = models.CharField(choices=DeviceOs.choices, null=True, blank=True, max_length=16, verbose_name=_('Device OS'))
@@ -159,10 +174,26 @@ class User(AbstractUser):
elif self.email and User.objects.filter(username=self.email).exclude(pk=self.pk).exists():
self.username = f'{self.email}:{self.id or random.randint(1000, 9999)}'
+ # Maintain first_name, last_name, and fullname synchronization
+ if self.first_name or self.last_name:
+ self.fullname = f"{self.first_name or ''} {self.last_name or ''}".strip()
+ elif self.fullname:
+ parts = self.fullname.strip().split(maxsplit=1)
+ if len(parts) == 2:
+ self.first_name = parts[0]
+ self.last_name = parts[1]
+ elif len(parts) == 1:
+ self.first_name = parts[0]
+ self.last_name = ""
+
return super().save(*args, **kwargs)
def get_full_name(self):
- return self.fullname or self.email or ""
+ full = f"{self.first_name or ''} {self.last_name or ''}".strip()
+ return full or self.fullname or self.email or ""
+
+ def get_short_name(self):
+ return self.first_name or self.fullname or self.email or ""
@property
def is_guest(self):
diff --git a/apps/account/serializers/auth_serializers.py b/apps/account/serializers/auth_serializers.py
index 5724e44..d9231cf 100644
--- a/apps/account/serializers/auth_serializers.py
+++ b/apps/account/serializers/auth_serializers.py
@@ -5,6 +5,7 @@ from rest_framework import serializers
from rest_framework_simplejwt.tokens import RefreshToken
from rest_framework_simplejwt.serializers import TokenObtainPairSerializer
from phonenumber_field.serializerfields import PhoneNumberField
+from drf_spectacular.utils import extend_schema_field, OpenApiTypes
from apps.account.models.verification import VerificationDocument
User = get_user_model()
@@ -14,7 +15,9 @@ User = get_user_model()
class RegisterRequestSerializer(serializers.Serializer):
email = serializers.EmailField(required=True)
password = serializers.CharField(write_only=True, required=True, min_length=6)
- fullname = serializers.CharField(required=True, max_length=255)
+ first_name = serializers.CharField(required=False, allow_blank=True, max_length=150)
+ last_name = serializers.CharField(required=False, allow_blank=True, max_length=150)
+ fullname = serializers.CharField(required=False, allow_blank=True, max_length=255)
phone_number = serializers.CharField(required=False, allow_blank=True, allow_null=True)
user_type = serializers.ChoiceField(
choices=User.UserType.choices,
@@ -44,15 +47,40 @@ class RegisterRequestSerializer(serializers.Serializer):
validate_password(value)
return value
+ def validate(self, attrs):
+ fn = attrs.get('first_name', '').strip()
+ ln = attrs.get('last_name', '').strip()
+ fl = attrs.get('fullname', '').strip()
+
+ if fn or ln:
+ attrs['first_name'] = fn
+ attrs['last_name'] = ln
+ if not fl:
+ attrs['fullname'] = f"{fn} {ln}".strip()
+ else:
+ attrs['fullname'] = fl
+ elif fl:
+ parts = fl.split(maxsplit=1)
+ attrs['first_name'] = parts[0]
+ attrs['last_name'] = parts[1] if len(parts) > 1 else ""
+ attrs['fullname'] = fl
+ else:
+ raise serializers.ValidationError({"first_name": "Please provide first_name and last_name (or full name)."})
+ return attrs
+
def create(self, validated_data):
email = validated_data.pop('email')
password = validated_data.pop('password')
- fullname = validated_data.pop('fullname')
+ first_name = validated_data.pop('first_name', '')
+ last_name = validated_data.pop('last_name', '')
+ fullname = validated_data.pop('fullname', '')
user_type = validated_data.pop('user_type', User.UserType.CLIENT)
user = User.objects.create_user(
email=email,
password=password,
+ first_name=first_name,
+ last_name=last_name,
fullname=fullname,
username=email,
user_type=user_type,
@@ -68,6 +96,18 @@ class UserMeSerializer(serializers.ModelSerializer):
permissions = serializers.SerializerMethodField()
verification_status = serializers.SerializerMethodField()
is_verified = serializers.SerializerMethodField()
+ first_name = serializers.SerializerMethodField()
+ last_name = serializers.SerializerMethodField()
+ name = serializers.SerializerMethodField()
+ phone = serializers.SerializerMethodField()
+ preferred_language = serializers.SerializerMethodField()
+ title = serializers.SerializerMethodField()
+ rank = serializers.SerializerMethodField()
+ website = serializers.SerializerMethodField()
+ address = serializers.SerializerMethodField()
+ position = serializers.SerializerMethodField()
+ institution = serializers.SerializerMethodField()
+ institution_id = serializers.SerializerMethodField()
class Meta:
model = User
@@ -76,12 +116,24 @@ class UserMeSerializer(serializers.ModelSerializer):
'email',
'username',
'fullname',
+ 'first_name',
+ 'last_name',
+ 'name',
'phone_number',
+ 'phone',
'user_type',
'avatar',
'bio',
'languages',
+ 'preferred_language',
'skills',
+ 'title',
+ 'rank',
+ 'position',
+ 'institution',
+ 'institution_id',
+ 'website',
+ 'address',
'country',
'city',
'gender',
@@ -98,6 +150,18 @@ class UserMeSerializer(serializers.ModelSerializer):
'id',
'email',
'username',
+ 'first_name',
+ 'last_name',
+ 'name',
+ 'phone',
+ 'preferred_language',
+ 'title',
+ 'rank',
+ 'position',
+ 'institution',
+ 'institution_id',
+ 'website',
+ 'address',
'user_type',
'date_joined',
'is_active',
@@ -108,6 +172,76 @@ class UserMeSerializer(serializers.ModelSerializer):
'is_verified',
]
+ def get_first_name(self, obj) -> str:
+ if obj.first_name:
+ return obj.first_name
+ if obj.fullname:
+ return obj.fullname.strip().split()[0]
+ return ""
+
+ def get_last_name(self, obj) -> str:
+ if obj.last_name:
+ return obj.last_name
+ if obj.fullname:
+ parts = obj.fullname.strip().split()
+ if len(parts) > 1:
+ return " ".join(parts[1:])
+ return ""
+
+ def get_name(self, obj) -> str:
+ return obj.fullname or f"{obj.first_name or ''} {obj.last_name or ''}".strip()
+
+ def get_phone(self, obj) -> str:
+ if obj.phone_number:
+ raw = (getattr(obj.phone_number, 'as_e164', None) or str(obj.phone_number)).replace(' ', '')
+ if raw.startswith('+98') and len(raw) == 13:
+ return '0' + raw[3:]
+ return str(obj.phone_number)
+ return ""
+
+ def get_preferred_language(self, obj) -> str:
+ if obj.languages and isinstance(obj.languages, list) and len(obj.languages) > 0:
+ return obj.languages[0]
+ return obj.language or "fa"
+
+ def get_title(self, obj) -> str:
+ if obj.title:
+ return obj.title
+ membership = obj.institution_memberships.first() if hasattr(obj, 'institution_memberships') else None
+ if membership and membership.title:
+ return membership.title
+ return obj.skill or ""
+
+ def get_rank(self, obj) -> str:
+ return obj.rank or ""
+
+ def get_website(self, obj) -> str:
+ return obj.website or ""
+
+ def get_address(self, obj) -> str:
+ return obj.address or ""
+
+ def get_institution(self, obj) -> str:
+ if obj.institution_name:
+ return obj.institution_name
+ membership = obj.institution_memberships.first() if hasattr(obj, 'institution_memberships') else None
+ if membership and membership.institution:
+ return membership.institution.name
+ return ""
+
+ @extend_schema_field(serializers.IntegerField(allow_null=True))
+ def get_institution_id(self, obj):
+ membership = obj.institution_memberships.first() if hasattr(obj, 'institution_memberships') else None
+ if membership and membership.institution:
+ return membership.institution.id
+ return None
+
+ def get_position(self, obj) -> str:
+ membership = obj.institution_memberships.first() if hasattr(obj, 'institution_memberships') else None
+ if membership and membership.role:
+ return membership.role
+ return ""
+
def get_roles(self, obj) -> list:
roles = [obj.user_type] if obj.user_type else []
for group in obj.groups.all():
@@ -137,16 +271,40 @@ class UserMeSerializer(serializers.ModelSerializer):
class UserMeUpdateSerializer(serializers.ModelSerializer):
bio = serializers.CharField(source='info', allow_blank=True, required=False, allow_null=True)
+ first_name = serializers.CharField(required=False, allow_blank=True)
+ last_name = serializers.CharField(required=False, allow_blank=True)
+ name = serializers.CharField(required=False, write_only=True, allow_blank=True)
+ phone = serializers.CharField(required=False, write_only=True, allow_blank=True)
+ preferred_language = serializers.CharField(required=False, write_only=True, allow_blank=True)
+ expertise = serializers.ListField(child=serializers.CharField(), required=False, write_only=True)
+ title = serializers.CharField(required=False, allow_blank=True)
+ rank = serializers.CharField(required=False, allow_blank=True)
+ institution = serializers.CharField(required=False, write_only=True, allow_blank=True)
+ institution_name = serializers.CharField(required=False, allow_blank=True)
+ website = serializers.CharField(required=False, allow_blank=True)
+ address = serializers.CharField(required=False, allow_blank=True)
class Meta:
model = User
fields = [
'fullname',
+ 'first_name',
+ 'last_name',
+ 'name',
'phone_number',
+ 'phone',
'avatar',
'bio',
'languages',
+ 'preferred_language',
'skills',
+ 'expertise',
+ 'title',
+ 'rank',
+ 'institution',
+ 'institution_name',
+ 'website',
+ 'address',
'country',
'city',
'gender',
@@ -159,24 +317,71 @@ class UserMeUpdateSerializer(serializers.ModelSerializer):
else:
mutable_data = dict(data)
- # Handle avatar: if avatar is a string (e.g. existing URL or empty string), do not treat as file
+ # Synchronize first_name, last_name, fullname, and name
+ fn = mutable_data.get('first_name')
+ ln = mutable_data.get('last_name')
+ fl = mutable_data.get('fullname') or mutable_data.get('name')
+
+ if fn is not None or ln is not None:
+ if not fl:
+ mutable_data['fullname'] = f"{fn or ''} {ln or ''}".strip()
+ elif fl:
+ parts = fl.strip().split(maxsplit=1)
+ mutable_data['first_name'] = parts[0] if parts else ""
+ mutable_data['last_name'] = parts[1] if len(parts) > 1 else ""
+ mutable_data['fullname'] = fl.strip()
+
+ # Harmonize institution -> institution_name
+ inst = mutable_data.get('institution') or mutable_data.get('institution_name')
+ if inst:
+ mutable_data['institution_name'] = inst
+
+ # Harmonize title
+ if 'title' in mutable_data and mutable_data['title']:
+ mutable_data['title'] = mutable_data['title']
+
+ # Harmonize phone / phone_number and normalize local numbers (09... -> +989...)
+ raw_phone = mutable_data.get('phone') or mutable_data.get('phone_number')
+ if raw_phone:
+ raw_phone_str = str(raw_phone).strip()
+ if raw_phone_str.startswith('09') and len(raw_phone_str) == 11 and raw_phone_str.isdigit():
+ mutable_data['phone_number'] = '+98' + raw_phone_str[1:]
+ elif raw_phone_str.startswith('0098'):
+ mutable_data['phone_number'] = '+98' + raw_phone_str[4:]
+ elif raw_phone_str.startswith('989') and len(raw_phone_str) == 12 and raw_phone_str.isdigit():
+ mutable_data['phone_number'] = '+' + raw_phone_str
+ else:
+ mutable_data['phone_number'] = raw_phone_str
+
+ if 'expertise' in mutable_data and not mutable_data.get('skills'):
+ mutable_data['skills'] = mutable_data['expertise']
+
+ if 'preferred_language' in mutable_data and not mutable_data.get('languages'):
+ mutable_data['languages'] = [mutable_data['preferred_language']]
+
+ # Handle avatar: support File, base64 Data URI, null (remove), and ignore existing URL strings
if 'avatar' in mutable_data:
avatar_val = mutable_data['avatar']
if isinstance(avatar_val, str):
- if not avatar_val.strip() or avatar_val.startswith('http') or avatar_val.startswith('/'):
- mutable_data.pop('avatar')
+ if avatar_val.startswith('data:image/'):
+ try:
+ import base64, uuid
+ from django.core.files.base import ContentFile
+ format_part, imgstr = avatar_val.split(';base64,', 1)
+ ext = format_part.split('/')[-1]
+ if ext == 'jpeg':
+ ext = 'jpg'
+ name_id = uuid.uuid4().hex[:8]
+ mutable_data['avatar'] = ContentFile(base64.b64decode(imgstr), name=f"avatar_{name_id}.{ext}")
+ except Exception:
+ mutable_data.pop('avatar', None)
+ elif not avatar_val.strip() or avatar_val.startswith('http') or avatar_val.startswith('/'):
+ mutable_data.pop('avatar', None)
elif avatar_val is None:
mutable_data['avatar'] = None
return super().to_internal_value(mutable_data)
- def update(self, instance, validated_data):
- for attr, value in validated_data.items():
- setattr(instance, attr, value)
- instance.save()
- return instance
-
-
class RegisterResponseSerializer(serializers.Serializer):
user = UserMeSerializer()
access = serializers.CharField()
@@ -187,7 +392,6 @@ class RegisterResponseSerializer(serializers.Serializer):
class VerificationDocumentSerializer(serializers.ModelSerializer):
document_type_display = serializers.CharField(source='get_document_type_display', read_only=True)
status_display = serializers.CharField(source='get_status_display', read_only=True)
- reviewed_by_email = serializers.EmailField(source='reviewed_by.email', read_only=True, allow_null=True)
class Meta:
model = VerificationDocument
@@ -201,7 +405,6 @@ class VerificationDocumentSerializer(serializers.ModelSerializer):
'status',
'status_display',
'admin_notes',
- 'reviewed_by_email',
'uploaded_at',
'reviewed_at',
]
@@ -212,7 +415,6 @@ class VerificationDocumentSerializer(serializers.ModelSerializer):
'status',
'status_display',
'admin_notes',
- 'reviewed_by_email',
'uploaded_at',
'reviewed_at',
]
diff --git a/apps/account/serializers/user.py b/apps/account/serializers/user.py
index 0b02ae1..d378749 100644
--- a/apps/account/serializers/user.py
+++ b/apps/account/serializers/user.py
@@ -13,7 +13,9 @@ from utils.validators import validate_type_code
class UserProfileSerializer(serializers.ModelSerializer):
avatar = FileFieldSerializer(required=False, allow_null=True)
password = serializers.CharField(write_only=True, required=False, validators=[validate_password])
- fullname = serializers.CharField(required=False)
+ first_name = serializers.CharField(required=False, allow_blank=True)
+ last_name = serializers.CharField(required=False, allow_blank=True)
+ fullname = serializers.CharField(required=False, allow_blank=True)
gender = serializers.ChoiceField(
choices=User.GenderChoices.choices,
required=False,
@@ -24,7 +26,7 @@ class UserProfileSerializer(serializers.ModelSerializer):
class Meta:
model = User
- fields = ['id', 'device_id', 'fcm', 'fullname', 'slug', 'avatar', 'email', 'phone_number', 'password', 'info', 'skill', 'city', 'country', 'birthdate', 'gender', 'saved_location']
+ fields = ['id', 'device_id', 'fcm', 'first_name', 'last_name', 'fullname', 'slug', 'avatar', 'email', 'phone_number', 'password', 'info', 'skill', 'city', 'country', 'birthdate', 'gender', 'saved_location']
read_only_fields = ['email', 'info', 'skill', 'device_id', 'slug', 'saved_location']
@extend_schema_field(OpenApiTypes.OBJECT)
@@ -70,15 +72,17 @@ class UserProfileSerializer(serializers.ModelSerializer):
class UserRegisterSerializer(serializers.ModelSerializer):
password = serializers.CharField(write_only=True, required=False, validators=[validate_password])
+ first_name = serializers.CharField(required=False, allow_blank=True)
+ last_name = serializers.CharField(required=False, allow_blank=True)
fcm = serializers.CharField(required=False, allow_blank=True, allow_null=True)
device_id = serializers.CharField(required=False, allow_blank=True, allow_null=True, write_only=True)
email = serializers.EmailField()
class Meta:
model = User
- fields = ['id', 'fullname', 'email', 'password', 'fcm', 'device_id']
+ fields = ['id', 'first_name', 'last_name', 'fullname', 'email', 'password', 'fcm', 'device_id']
extra_kwargs = {
- 'fullname': {'required': True},
+ 'fullname': {'required': False},
'email': {'required': True},
}
diff --git a/apps/account/tests/test_phase1_auth.py b/apps/account/tests/test_phase1_auth.py
index 6e79efe..e8e3e03 100644
--- a/apps/account/tests/test_phase1_auth.py
+++ b/apps/account/tests/test_phase1_auth.py
@@ -202,3 +202,41 @@ class Phase1AuthAndIdentityTests(TestCase):
self.assertTrue(super_admin.is_regional_admin)
self.assertTrue(super_admin.is_institution_admin)
self.assertTrue(super_admin.is_editor)
+
+ def test_user_profile_extended_fields_crud(self):
+ self.client.force_authenticate(user=self.user)
+ payload = {
+ "first_name": "Ali",
+ "last_name": "Rezaei",
+ "title": "Dr",
+ "rank": "Professor",
+ "institution": "Ferdowsi University",
+ "website": "https://example.com/scholar",
+ "phone": "09123456789",
+ "address": "Mashhad, Iran"
+ }
+ # PATCH update
+ response = self.client.patch("/api/v1/auth/me/", payload, format="json")
+ self.assertEqual(response.status_code, status.HTTP_200_OK)
+ self.assertEqual(response.data["first_name"], "Ali")
+ self.assertEqual(response.data["last_name"], "Rezaei")
+ self.assertEqual(response.data["fullname"], "Ali Rezaei")
+ self.assertEqual(response.data["title"], "Dr")
+ self.assertEqual(response.data["rank"], "Professor")
+ self.assertEqual(response.data["institution"], "Ferdowsi University")
+ self.assertEqual(response.data["website"], "https://example.com/scholar")
+ self.assertEqual(response.data["phone"], "09123456789")
+ self.assertEqual(response.data["address"], "Mashhad, Iran")
+
+ # GET retrieval
+ get_resp = self.client.get("/api/v1/auth/me/")
+ self.assertEqual(get_resp.status_code, status.HTTP_200_OK)
+ self.assertEqual(get_resp.data["first_name"], "Ali")
+ self.assertEqual(get_resp.data["last_name"], "Rezaei")
+ self.assertEqual(get_resp.data["title"], "Dr")
+ self.assertEqual(get_resp.data["rank"], "Professor")
+ self.assertEqual(get_resp.data["institution"], "Ferdowsi University")
+ self.assertEqual(get_resp.data["website"], "https://example.com/scholar")
+ self.assertEqual(get_resp.data["phone"], "09123456789")
+ self.assertEqual(get_resp.data["address"], "Mashhad, Iran")
+
diff --git a/apps/chat/management/commands/seed_hashemi_chat_meetings.py b/apps/chat/management/commands/seed_hashemi_chat_meetings.py
new file mode 100644
index 0000000..c74f9f7
--- /dev/null
+++ b/apps/chat/management/commands/seed_hashemi_chat_meetings.py
@@ -0,0 +1,382 @@
+import datetime
+from django.core.management.base import BaseCommand
+from django.db import transaction
+from django.utils import timezone
+from apps.account.models import User
+from apps.profiles.models import Institution, InstitutionMember
+from apps.chat.models.chat import ChatRoom, ChatMessage
+from apps.meetings.models import MeetingRequest, MeetingStatus
+
+
+class Command(BaseCommand):
+ help = "Seed messaging chats and bilateral meetings for حجتالاسلام دکتر احمد هاشمی (ahmad@hashemi.com)."
+
+ def handle(self, *args, **options):
+ with transaction.atomic():
+ user = User.objects.filter(email='ahmad@hashemi.com').first()
+ if not user:
+ user = User.objects.filter(fullname__icontains='هاشمی').first()
+ if not user:
+ self.stdout.write(self.style.ERROR("User دکتر احمد هاشمی not found!"))
+ return
+
+ self.stdout.write(self.style.SUCCESS(f"Found User: {user.fullname} ({user.email}, ID: {user.id})"))
+
+ # 1. Ensure University of Religions and Denominations institution exists
+ inst_urd, created = Institution.objects.get_or_create(
+ name="دانشگاه ادیان و مذاهب",
+ defaults={
+ "slug": "university-of-religions-and-denominations",
+ "type": Institution.InstitutionType.INSTITUTE,
+ "country": "ایران",
+ "city": "قم",
+ "address": "قم، پردیسان، بلوار دانشگاه، دانشگاه ادیان و مذاهب",
+ "latitude": 34.5772,
+ "longitude": 50.8144,
+ "established_year": 1995,
+ "email": "info@urd.ac.ir",
+ "website": "https://urd.ac.ir",
+ "phone": "+98 (25) 3280-2610",
+ "verification_status": Institution.VerificationStatus.APPROVED,
+ "is_featured": True,
+ "is_active": True,
+ "description": "دانشگاه ادیان و مذاهب نخستین دانشگاه تخصصی ادیان و مذاهب در ایران است که با هدف آموزش، پژوهش و گفتوگوی بینالادیانی و بینالمذاهبی فعالیت میکند.",
+ }
+ )
+
+ # Ensure user is Admin member in this institution
+ member, _ = InstitutionMember.objects.get_or_create(
+ institution=inst_urd,
+ user=user,
+ defaults={
+ "role": InstitutionMember.MemberRole.ADMIN,
+ "title": "عضو هیئت علمی و معاون پژوهشی",
+ }
+ )
+
+ # Also associate with Al-Madinah Calgary or other partner institutions if appropriate
+ inst_almadinah = Institution.objects.filter(id=1171).first()
+ if not inst_almadinah:
+ inst_almadinah = Institution.objects.filter(name__icontains="Al-Madinah").first()
+
+ inst_mustafa = Institution.objects.filter(name__icontains="المصطفی").first()
+ if not inst_mustafa:
+ inst_mustafa, _ = Institution.objects.get_or_create(
+ name="جامعة المصطفی العالمیة",
+ defaults={
+ "slug": "al-mustafa-international-university",
+ "type": Institution.InstitutionType.INSTITUTE,
+ "country": "ایران",
+ "city": "قم",
+ "address": "قم، بلوار معلم، جامعة المصطفی العالمیة",
+ "latitude": 34.6399,
+ "longitude": 50.8759,
+ "established_year": 1979,
+ "verification_status": Institution.VerificationStatus.APPROVED,
+ }
+ )
+
+ inst_taghrib = Institution.objects.filter(name__icontains="تقریب").first()
+ if not inst_taghrib:
+ inst_taghrib, _ = Institution.objects.get_or_create(
+ name="مجمع جهانی تقریب مذاهب اسلامی",
+ defaults={
+ "slug": "world-forum-for-proximity-of-islamic-schools",
+ "type": Institution.InstitutionType.CULTURAL_CENTER,
+ "country": "ایران",
+ "city": "تهران",
+ "address": "تهران، خیابان طالقانی، نبش خیابان شهید موسوی",
+ "latitude": 35.7036,
+ "longitude": 51.4172,
+ "established_year": 1990,
+ "verification_status": Institution.VerificationStatus.APPROVED,
+ }
+ )
+
+ inst_stockholm = Institution.objects.filter(name__icontains="Stockholm").first()
+
+ # Find colleague users for direct chats
+ u_hosseini = User.objects.filter(fullname__icontains="حسینی").first()
+ u_tamimi = User.objects.filter(fullname__icontains="التمیمی").first()
+ u_mirzaei = User.objects.filter(fullname__icontains="میرزایی").first()
+ u_kamali = User.objects.filter(fullname__icontains="کمالی").first()
+ u_admin = User.objects.filter(is_superuser=True).first()
+
+ # -------------------------------------------------------------------------
+ # 2. SEED MESSAGING (CHAT ROOMS & MESSAGES)
+ # -------------------------------------------------------------------------
+ now = timezone.now()
+
+ # Clean old rooms where user is participant to avoid duplicates
+ old_rooms = ChatRoom.objects.filter(participant_users=user)
+ ChatMessage.objects.filter(room__in=old_rooms).delete()
+ old_rooms.delete()
+
+ # Chat 1: Direct Message with Dr. Seyed Mohammad Hosseini (جامعة المصطفی)
+ if u_hosseini:
+ room1 = ChatRoom.objects.create(
+ room_type=ChatRoom.RoomType.DIRECT,
+ title=f"گفتوگو با {u_hosseini.fullname}",
+ created_by=user,
+ )
+ room1.participant_users.add(user, u_hosseini)
+ if inst_urd:
+ room1.participant_institutions.add(inst_urd)
+ if inst_mustafa:
+ room1.participant_institutions.add(inst_mustafa)
+
+ m1 = ChatMessage.objects.create(
+ room=room1,
+ sender=u_hosseini,
+ sender_institution=inst_mustafa,
+ content="سلام علیکم و رحمتالله جناب دکتر هاشمی عزیز. مقالهای که پیرامون روششناسی مقارن در علوم قرآن برای همایش بینالمللی ارسال فرموده بودید در شورای علمی با امتیاز بالا تأیید شد.",
+ created_at=now - datetime.timedelta(days=2, hours=4),
+ )
+ m1.read_by.add(user, u_hosseini)
+
+ m2 = ChatMessage.objects.create(
+ room=room1,
+ sender=user,
+ sender_institution=inst_urd,
+ content="سلام و درود جناب دکتر حسینی بزرگوار. سپاسگزارم از لطف و پیگیریهای شما. فایل نهایی اصلاحات به همراه چکیده مبسوط انگلیسی تا فردا بارگذاری خواهد شد.",
+ created_at=now - datetime.timedelta(days=1, hours=20),
+ )
+ m2.read_by.add(user, u_hosseini)
+
+ m3 = ChatMessage.objects.create(
+ room=room1,
+ sender=u_hosseini,
+ sender_institution=inst_mustafa,
+ content="بسیار عالی. همچنین در صورت تمایل، پنل تخصصی «گفتوگوهای تفسیری در غرب اسلام» در نشست ماه آینده به مدیریت جنابعالی پیشنهاد شده است.",
+ created_at=now - datetime.timedelta(hours=5),
+ )
+ m3.read_by.add(user)
+
+ # Chat 2: Direct Message with Dr. Haydar Al-Tamimi (مجمع تقریب)
+ if u_tamimi:
+ room2 = ChatRoom.objects.create(
+ room_type=ChatRoom.RoomType.DIRECT,
+ title=f"هماهنگی سمپوزیوم با {u_tamimi.fullname}",
+ created_by=u_tamimi,
+ )
+ room2.participant_users.add(user, u_tamimi)
+ if inst_urd:
+ room2.participant_institutions.add(inst_urd)
+ if inst_taghrib:
+ room2.participant_institutions.add(inst_taghrib)
+
+ m2_1 = ChatMessage.objects.create(
+ room=room2,
+ sender=u_tamimi,
+ sender_institution=inst_taghrib,
+ content="أهلاً وسهلاً فضيلة الدكتور الهاشمي. نود دعوتكم لتقديم الورقة الافتتاحية في المؤتمر الدولي للوحدة الإسلامية حول المشترکات التفسيرية بين المذاهب.",
+ created_at=now - datetime.timedelta(days=3, hours=10),
+ )
+ m2_1.read_by.add(user, u_tamimi)
+
+ m2_2 = ChatMessage.objects.create(
+ room=room2,
+ sender=user,
+ sender_institution=inst_urd,
+ content="أهلاً بكم أخي العزيز د. التميمي. يسعدني ويشرفني المشاركة، وسأرسل لكم المحاور الرئيسية والمقترحات التنفيذية للندوة الحوارية المشتركة.",
+ created_at=now - datetime.timedelta(days=1, hours=8),
+ )
+ m2_2.read_by.add(user, u_tamimi)
+
+ m2_3 = ChatMessage.objects.create(
+ room=room2,
+ sender=u_tamimi,
+ sender_institution=inst_taghrib,
+ content="ممتاز جداً، ونحن بانتظار مسودة المحاور لاعتمادها في اللجنة الاستشارية غداً بإذن الله.",
+ created_at=now - datetime.timedelta(hours=2),
+ )
+ # Unread by user to show realistic notification
+ m2_3.read_by.add(u_tamimi)
+
+ # Chat 3: Multi-Institution Group Room: کارگروه بینالمللی گفتوگوی ادیان و تمدنها
+ room3 = ChatRoom.objects.create(
+ room_type=ChatRoom.RoomType.GROUP,
+ title="کارگروه بینالمللی مطالعات تطبیقی ادیان و تقریب",
+ created_by=user,
+ )
+ room3.participant_users.add(user)
+ if u_hosseini:
+ room3.participant_users.add(u_hosseini)
+ if u_tamimi:
+ room3.participant_users.add(u_tamimi)
+ if u_mirzaei:
+ room3.participant_users.add(u_mirzaei)
+ if u_kamali:
+ room3.participant_users.add(u_kamali)
+
+ if inst_urd:
+ room3.participant_institutions.add(inst_urd)
+ if inst_mustafa:
+ room3.participant_institutions.add(inst_mustafa)
+ if inst_taghrib:
+ room3.participant_institutions.add(inst_taghrib)
+
+ m3_1 = ChatMessage.objects.create(
+ room=room3,
+ sender=user,
+ sender_institution=inst_urd,
+ content="با سلام خدمت اساتید و پژوهشگران ارجمند. پیشنویس جدول زمانبندی سلسله نشستهای آنلاین پاییز ۱۴۰۵ در حوزه گفتوگوهای الهیاتی آماده بررسی است.",
+ created_at=now - datetime.timedelta(days=4),
+ )
+ m3_1.read_by.add(user)
+
+ if u_mirzaei:
+ m3_2 = ChatMessage.objects.create(
+ room=room3,
+ sender=u_mirzaei,
+ content="سلام و احترام. محورهای پیشنهادی بسیار جامع و دقیق هستند. دانشکده الهیات دانشگاه تهران آمادگی مشارکت کامل در برگزاری کارگاههای مشترک را دارد.",
+ created_at=now - datetime.timedelta(days=2, hours=12),
+ )
+ m3_2.read_by.add(user, u_mirzaei)
+
+ if u_kamali:
+ m3_3 = ChatMessage.objects.create(
+ room=room3,
+ sender=u_kamali,
+ content="سلام دکتر هاشمی گرامی. بخش پژوهشهای نسخ خطی مشهد نیز دیجیتالسازی منابع مرتبط را آغاز کرده و لینک دسترسی را در جلسه هماهنگی ارائه خواهیم داد.",
+ created_at=now - datetime.timedelta(hours=14),
+ )
+ m3_3.read_by.add(user, u_kamali)
+
+ # Chat 4: Institutional Consultation with Al-Madinah Islamic Centre Calgary
+ if inst_almadinah:
+ room4 = ChatRoom.objects.create(
+ room_type=ChatRoom.RoomType.GROUP,
+ title="همکاری آموزشی با مرکز اسلامی المدینه کلگری",
+ created_by=user,
+ )
+ room4.participant_users.add(user)
+ if u_admin:
+ room4.participant_users.add(u_admin)
+ room4.participant_institutions.add(inst_urd, inst_almadinah)
+
+ m4_1 = ChatMessage.objects.create(
+ room=room4,
+ sender=user,
+ sender_institution=inst_urd,
+ content="Dear Al-Madinah Centre Academic Team, we reviewed your Fall 2026 Quranic & Arabic curriculum. We are pleased to provide academic accreditation and visiting scholar webinars from University of Religions.",
+ created_at=now - datetime.timedelta(days=1, hours=15),
+ )
+ m4_1.read_by.add(user)
+
+ if u_admin:
+ m4_2 = ChatMessage.objects.create(
+ room=room4,
+ sender=u_admin,
+ sender_institution=inst_almadinah,
+ content="Thank you, Dr. Hashemi! Our board is thrilled to establish this direct academic bridge with Qom and look forward to the upcoming bilateral coordination meeting.",
+ created_at=now - datetime.timedelta(hours=6),
+ )
+ m4_2.read_by.add(user, u_admin)
+
+ self.stdout.write(self.style.SUCCESS("Populated 4 rich Chat Rooms with authentic scholarly discussions."))
+
+ # -------------------------------------------------------------------------
+ # 3. SEED BILATERAL VIDEO MEETINGS (MEETINGS APP)
+ # -------------------------------------------------------------------------
+ # Delete old meetings created by user or involving user's institution
+ MeetingRequest.objects.filter(created_by=user).delete()
+
+ today = timezone.localdate()
+
+ # Meeting 1: Upcoming Scheduled Meeting with Al-Madinah Islamic Centre Calgary
+ if inst_almadinah:
+ MeetingRequest.objects.create(
+ title="Bilateral Academic Partnership: Youth Quranic Education & Faculty Exchange",
+ requester_institution=inst_urd,
+ recipient_institution=inst_almadinah,
+ created_by=user,
+ status=MeetingStatus.SCHEDULED,
+ meeting_date=today + datetime.timedelta(days=2),
+ meeting_time=datetime.time(17, 30),
+ duration_minutes=75,
+ meeting_url="https://meet.jit.si/mch-hashemi-almadinah-calgary",
+ agenda="""1. Review of Al-Madinah Academy curriculum accreditation standards.
+2. Schedule of monthly live webinars by Dr. Hashemi on contemporary ethics and youth guidance.
+3. Collaborative digital archive exchange between Calgary and University of Religions.
+4. Q&A and official MOU signing steps.""",
+ admin_notes="Both delegations confirmed attendance. Simultaneous translation English/Persian will be available.",
+ )
+
+ # Meeting 2: Scheduled Meeting with جامعة المصطفی العالمیة
+ if inst_mustafa:
+ MeetingRequest.objects.create(
+ title="نشست هماهنگی همایش بینالمللی مطالعات مقارن ادیان و متون مقدس",
+ requester_institution=inst_mustafa,
+ recipient_institution=inst_urd,
+ created_by=user,
+ status=MeetingStatus.SCHEDULED,
+ meeting_date=today + datetime.timedelta(days=5),
+ meeting_time=datetime.time(10, 0),
+ duration_minutes=60,
+ meeting_url="https://meet.jit.si/mch-urd-mustafa-symposium",
+ agenda="""۱. تعیین اساتید و داوران مقالات ارسالی به کمیته علمی.
+۲. نهاییسازی پنل تخصصی دکتر احمد هاشمی در حوزه هرمنوتیک و تفسیر تطبیقی.
+۳. هماهنگی حضور میهمانان خارجی از دانشگاههای ترکیه، لبنان و اندونزی.""",
+ admin_notes="هماهنگی با دکتر حسینی نهایی شده است.",
+ )
+
+ # Meeting 3: Pending Meeting with مجمع جهانی تقریب مذاهب اسلامی
+ if inst_taghrib:
+ MeetingRequest.objects.create(
+ title="بررسی طرح مشترک دانشنامه آنلاین اصطلاحات تفسیری تقریب",
+ requester_institution=inst_urd,
+ recipient_institution=inst_taghrib,
+ created_by=user,
+ status=MeetingStatus.PENDING,
+ meeting_date=today + datetime.timedelta(days=8),
+ meeting_time=datetime.time(14, 0),
+ duration_minutes=90,
+ meeting_url="https://meet.jit.si/mch-taghrib-hashemi-encyclopedia",
+ agenda="""۱. معرفی زیرساخت فنی و دانشنامهای هاب پژوهشگران.
+۲. تخصیص کارگروههای تخصصی تألیف مدخلها.
+۳. برآورد بودجه و جدول زمانبندی انتشار فاز اول.""",
+ admin_notes="در انتظار تأیید نهایی تاریخ توسط دبیرخانه مجمع تقریب.",
+ )
+
+ # Meeting 4: Pending Meeting with Stockholm Islamic Center (if exists) or Montreal
+ inst_foreign = inst_stockholm or Institution.objects.filter(country__in=['Sweden', 'Canada', 'France']).first()
+ if inst_foreign:
+ MeetingRequest.objects.create(
+ title=f"Online Consultation on Interfaith Dialogue Programs with {inst_foreign.name}",
+ requester_institution=inst_urd,
+ recipient_institution=inst_foreign,
+ created_by=user,
+ status=MeetingStatus.PENDING,
+ meeting_date=today + datetime.timedelta(days=12),
+ meeting_time=datetime.time(16, 0),
+ duration_minutes=45,
+ meeting_url=f"https://meet.jit.si/mch-urd-{inst_foreign.id}-dialogue",
+ agenda="""- Introduction to academic distance-learning modules offered by URD.
+- Exchange of educational resources for European Muslim youth.
+- Establishing bilateral research contacts.""",
+ admin_notes="Pending confirmation of time slot by the European center director.",
+ )
+
+ # Meeting 5: Completed Past Meeting (Historical record)
+ if inst_mustafa:
+ MeetingRequest.objects.create(
+ title="جلسه پیشنیاز راهاندازی کارگاههای روش تحقیق در مطالعات حدیثی",
+ requester_institution=inst_urd,
+ recipient_institution=inst_mustafa,
+ created_by=user,
+ status=MeetingStatus.COMPLETED,
+ meeting_date=today - datetime.timedelta(days=10),
+ meeting_time=datetime.time(11, 0),
+ duration_minutes=60,
+ meeting_url="https://meet.jit.si/mch-urd-mustafa-workshop-completed",
+ agenda="""۱. مرور سرفصلهای کارگاه ۳۰ ساعته روش تحقیق.
+۲. انتخاب مدرسان همکار و زمانبندی جلسات آنلاین.""",
+ admin_notes="جلسه با موفقیت کامل برگزار شد و مصوبات به امضای طرفین رسید.",
+ )
+
+ self.stdout.write(self.style.SUCCESS("Populated 5 bilateral video meeting sessions (Scheduled, Pending, Completed)."))
+
+ self.stdout.write(
+ self.style.SUCCESS("\nSuccessfully enriched messaging chats and video meetings for حجتالاسلام دکتر احمد هاشمی!")
+ )
diff --git a/apps/chat/models/chat.py b/apps/chat/models/chat.py
index b9fa852..9d73a4f 100644
--- a/apps/chat/models/chat.py
+++ b/apps/chat/models/chat.py
@@ -76,7 +76,7 @@ class ChatRoom(models.Model):
return False
def get_last_message(self):
- return self.messages.all().select_related('sender', 'sender_institution').order_by('-created_at').first()
+ return self.messages.all().select_related('sender', 'sender_institution').order_by('-created_at', '-id').first()
def get_unread_count(self, user) -> int:
if not user or not user.is_authenticated:
diff --git a/apps/profiles/management/__init__.py b/apps/profiles/management/__init__.py
new file mode 100644
index 0000000..a94f2a3
--- /dev/null
+++ b/apps/profiles/management/__init__.py
@@ -0,0 +1 @@
+# Management package
diff --git a/apps/profiles/management/commands/__init__.py b/apps/profiles/management/commands/__init__.py
new file mode 100644
index 0000000..b5a3a84
--- /dev/null
+++ b/apps/profiles/management/commands/__init__.py
@@ -0,0 +1 @@
+# Commands package
diff --git a/apps/profiles/management/commands/seed_almadinah_rich_data.py b/apps/profiles/management/commands/seed_almadinah_rich_data.py
new file mode 100644
index 0000000..384c2f1
--- /dev/null
+++ b/apps/profiles/management/commands/seed_almadinah_rich_data.py
@@ -0,0 +1,377 @@
+import datetime
+import urllib.request
+from django.core.files.base import ContentFile
+from django.core.management.base import BaseCommand
+from django.db import transaction
+from django.utils import timezone
+from apps.account.models import User
+from apps.profiles.models.institution import Institution, InstitutionTimeline, InstitutionMember
+from apps.projects.models.project import Project
+from apps.cms.models.post import Post, PostCategory
+
+
+RICH_HISTORY = """Founded in 1998 in Southeast Calgary, Alberta, the Al-Madinah Islamic Centre has grown from a humble community gathering hall into one of Western Canada's most vibrant Islamic educational, cultural, and spiritual hubs. The Centre was established by pioneering Muslim families seeking to cultivate a welcoming sanctuary for worship, youth development, religious literacy, and civic engagement.
+
+Over more than two decades, Al-Madinah has expanded its facilities to encompass a grand prayer hall accommodating over 1,200 congregants, an accredited weekend Islamic Academy, modern seminar classrooms, a specialized theological research library, and an active family counseling department. The institution is renowned across Alberta for its vibrant multicultural congregation, extensive charitable food hampers, winter relief drives, and proactive interfaith dialogue partnerships with civic leaders and faith communities across Greater Calgary.
+
+Today, Al-Madinah Islamic Centre operates as an inclusive, full-service community anchor providing daily congregational prayers, youth leadership fellowships, refugee resettlement assistance, Qur'anic memorization programs, and scholarly seminars addressing contemporary ethical issues."""
+
+
+TIMELINE_EVENTS = [
+ {
+ "year": "1998",
+ "title": "Foundation & First Community Assembly",
+ "description": "Established by pioneering Muslim immigrant families in Southeast Calgary to organize daily prayers, Taraweeh, and weekend Islamic education.",
+ "order": 1,
+ },
+ {
+ "year": "2004",
+ "title": "Permanent 17th Avenue Facility Acquisition",
+ "description": "Purchased and renovated the dedicated multi-level facility at 4612 17 Ave SE, creating a full-capacity mosque, community hall, and classrooms.",
+ "order": 2,
+ },
+ {
+ "year": "2010",
+ "title": "Inauguration of Al-Madinah Youth & Quran Academy",
+ "description": "Launched accredited weekend and evening curricula offering comprehensive Quranic Tajweed, Arabic literacy, and Islamic studies for over 300 youth.",
+ "order": 3,
+ },
+ {
+ "year": "2016",
+ "title": "Sisterhood Wing & Family Counseling Expansion",
+ "description": "Added a dedicated multi-purpose wing with women's educational lounges, nursery facilities, and professional family and mental health counseling services.",
+ "order": 4,
+ },
+ {
+ "year": "2020",
+ "title": "COVID-19 Relief & Digital Broadcast Studio",
+ "description": "Built a high-definition digital broadcasting studio for live-streamed lectures and led the 'Feed Calgary' emergency food hamper relief initiative.",
+ "order": 5,
+ },
+ {
+ "year": "2023",
+ "title": "25th Silver Jubilee & Green Mosque Solar Initiative",
+ "description": "Celebrated 25 years of community service, retrofitting the center with clean rooftop solar arrays, LED systems, and environmental efficiency upgrades.",
+ "order": 6,
+ },
+ {
+ "year": "2025-2026",
+ "title": "Western Canada Interfaith Fellowship & Heritage Archive",
+ "description": "Established an annual Western Canada interfaith symposium and inaugurated the Prairie Muslim Historical & Manuscript Archive.",
+ "order": 7,
+ },
+]
+
+
+PROJECTS_DATA = [
+ {
+ "title": "Al-Madinah Green Mosque & Renewable Energy Retrofit",
+ "category": "Environmental Sustainability & Green Infrastructure",
+ "status": Project.Status.COMPLETED,
+ "progress_percentage": 100,
+ "budget": "$180,000 CAD",
+ "team_size": 8,
+ "description": "A landmark environmental initiative converting the center into an eco-friendly green mosque with rooftop solar array, rainwater harvesting systems, and smart HVAC climate controls.",
+ "start_date": datetime.date(2023, 3, 1),
+ "actual_end_date": datetime.date(2024, 8, 15),
+ },
+ {
+ "title": "Alberta Muslim Youth Leadership & STEM Fellowship",
+ "category": "Youth Empowerment & Educational Academy",
+ "status": Project.Status.ACTIVE,
+ "progress_percentage": 75,
+ "budget": "$95,000 CAD",
+ "team_size": 12,
+ "description": "Mentorship fellowship pairing high school and university students with industry leaders in Alberta, offering robotics bootcamps, public speaking masterclasses, and ethics seminars.",
+ "start_date": datetime.date(2025, 1, 10),
+ "estimated_end_date": datetime.date(2026, 12, 20),
+ },
+ {
+ "title": "Calgary Interfaith Winter Warmth & Food Hamper Drive",
+ "category": "Humanitarian Relief & Civic Outreach",
+ "status": Project.Status.ACTIVE,
+ "progress_percentage": 85,
+ "budget": "$60,000 CAD",
+ "team_size": 25,
+ "description": "Annual city-wide charitable drive distributing over 2,500 warm winter coats, thermal blankets, and nutritious food hampers to vulnerable families across Greater Calgary regardless of faith.",
+ "start_date": datetime.date(2025, 10, 1),
+ "estimated_end_date": datetime.date(2026, 4, 30),
+ },
+ {
+ "title": "Prairie Islamic Heritage Archive & Digital Library",
+ "category": "Theological Research & Cultural Preservation",
+ "status": Project.Status.PLANNING,
+ "progress_percentage": 35,
+ "budget": "$45,000 CAD",
+ "team_size": 5,
+ "description": "An ambitious digital humanities initiative preserving rare classical Islamic texts, oral histories of Muslim pioneers in the Canadian prairies, and making them freely searchable online.",
+ "start_date": datetime.date(2026, 2, 1),
+ "estimated_end_date": datetime.date(2027, 6, 30),
+ },
+]
+
+
+POSTS_DATA = [
+ {
+ "title": "Al-Madinah Centre Welcomes Over 1,500 Attendees for Annual Community Open House & Interfaith Gala",
+ "post_type": Post.PostType.NEWS,
+ "category_name": "Community News",
+ "category_slug": "community-news",
+ "image_url": "https://images.unsplash.com/photo-1564769625905-50e93615e769?auto=format&fit=crop&w=1200&q=80",
+ "image_filename": "open_house_gala.jpg",
+ "excerpt": "Civic leaders, neighbors, and community members gathered at Al-Madinah Islamic Centre for an evening of dialogue, cultural exhibition, and shared cuisine celebrating Calgary's diversity.",
+ "content": """The Al-Madinah Islamic Centre in Southeast Calgary hosted its annual Community Open House and Interfaith Gala this past weekend, welcoming more than 1,500 neighbors, faith leaders, city officials, and community advocates.
+
+The evening featured guided architecture tours of the newly renovated prayer halls, an Islamic calligraphy and manuscript exhibition, and insightful panel discussions on civic collaboration and social cohesion in Alberta.
+
+Keynote speaker Dr. Tariq Al-Mansoor emphasized: "Our mosques are open sanctuaries for understanding, friendship, and collective service to our city. In a world full of rapid change, gathering in goodwill and sharing a warm meal builds unbreakable bonds of compassion."
+
+Guests enjoyed traditional multicultural cuisine, while youth volunteers guided families through interactive educational booths showcasing the history and contributions of Canadian Muslims to the prairies.""",
+ "reading_time_minutes": 4,
+ "likes_count": 342,
+ },
+ {
+ "title": "Nurturing Faith in the Modern City: Reflections on Youth Guidance and Digital Wellbeing",
+ "post_type": Post.PostType.ARTICLE,
+ "category_name": "Scholarly Insights",
+ "category_slug": "scholarly-insights",
+ "image_url": "https://images.unsplash.com/photo-1584286595398-a59f21d313f5?auto=format&fit=crop&w=1200&q=80",
+ "image_filename": "youth_digital_wellbeing.jpg",
+ "excerpt": "An analytical essay exploring strategies for cultivating mental health, moral resilience, and strong spiritual identity among young Muslims navigating the digital age.",
+ "content": """In an era characterized by hyper-connectivity, continuous social media immersion, and fragmented attention spans, nurturing a grounded moral identity among our youth has become one of the most critical responsibilities of modern Islamic institutions.
+
+At Al-Madinah Islamic Centre, our pedagogical philosophy blends timeless Quranic wisdom with contemporary mental health literacy. Faith is not merely an abstract doctrine; it is a living compass that provides emotional serenity, intellectual clarity, and purpose.
+
+Key principles for families and educators:
+1. Intentional Digital Fasting: Cultivating designated screen-free hours to allow deep contemplative reflection, family conversation, and Quranic recitation.
+2. Safe Spaces for Difficult Questions: Fostering an atmosphere where young people can openly voice philosophical and ethical queries without fear of judgment.
+3. Action-Oriented Community Service: Anchoring spirituality in tangible community service—feeding the hungry, caring for neighbors, and protecting the environment.
+
+When youth experience faith as a dynamic force of empathy and moral courage, their spiritual roots grow resilient against the storms of modern cynicism.""",
+ "reading_time_minutes": 6,
+ "likes_count": 512,
+ },
+ {
+ "title": "Registration Open for Fall 2026 Al-Madinah Quranic Memorization & Classical Arabic Programs",
+ "post_type": Post.PostType.NEWS,
+ "category_name": "Academy Announcements",
+ "category_slug": "academy-announcements",
+ "image_url": "https://images.unsplash.com/photo-1609599006353-e629aaabfeae?auto=format&fit=crop&w=1200&q=80",
+ "image_filename": "quran_arabic_academy.jpg",
+ "excerpt": "Al-Madinah Academy announces registration for its accredited after-school Quranic studies, Tajweed mastery, and Arabic literacy courses for youth and adults.",
+ "content": """The Al-Madinah Quran & Youth Academy is pleased to announce that official enrollment for the Fall 2026 academic semester is now open.
+
+Program Highlights:
+• Part-time & Full-time Hifz (Memorization) Tracks supervised by certified Ijazah holders.
+• Foundational Noorani Qaidah & Phonetics for children ages 5–8.
+• Classical Arabic Grammar & Vocabulary for high school students and adults.
+• Weekly Youth Halaqahs focusing on Islamic ethics, contemporary issues, and public leadership.
+
+All classrooms feature smart interactive screens, hybrid digital learning portals, and small teacher-to-student ratios (maximum 12 students per class) to guarantee individualized mentorship.
+
+Early-bird registration discounts and need-based tuition assistance are available upon application. Visit the academy administration desk or register online via our education portal.""",
+ "reading_time_minutes": 3,
+ "likes_count": 218,
+ },
+ {
+ "title": "Annual Community Impact Report: Over 15,000 Meals Distributed and 450 Academy Graduates",
+ "post_type": Post.PostType.REPORT,
+ "category_name": "Impact Reports",
+ "category_slug": "impact-reports",
+ "image_url": "https://images.unsplash.com/photo-1593113598332-cd288d649433?auto=format&fit=crop&w=1200&q=80",
+ "image_filename": "community_impact_meals.jpg",
+ "excerpt": "Reviewing a year of transformative growth: over 15,000 community meals distributed, 450 academy graduates, and new interfaith partnerships across Alberta.",
+ "content": """The Board of Directors and Executive Committee of Al-Madinah Islamic Centre Calgary are pleased to present the 2025–2026 Community Impact Report.
+
+Executive Summary:
+• 15,400+ Emergency Meals & Food Hampers prepared and distributed across Calgary SE and NE.
+• 450+ Students graduated from our elementary, youth, and adult education tracks.
+• 8,200+ Attendees participated in weekly Friday congregational services and community dinners.
+• 12 Major Interfaith & Civic Forums convened in partnership with local universities and interfaith alliances.
+• $180,000 Completed Renewable Energy Retrofit, reducing the facility's carbon footprint by 45%.
+
+We extend our heartfelt gratitude to our dedicated volunteers, donors, teachers, and staff who embody the prophetic virtue of serving creation with excellence (Ihsan).""",
+ "reading_time_minutes": 5,
+ "likes_count": 489,
+ },
+]
+
+
+class Command(BaseCommand):
+ help = "Populate rich data (history, timeline, projects, posts/news) for Al-Madinah Islamic Centre Calgary."
+
+ def handle(self, *args, **options):
+ with transaction.atomic():
+ admin_user = User.objects.filter(is_superuser=True).first() or User.objects.first()
+
+ inst = Institution.objects.filter(name__icontains="Al-Madinah").filter(city__icontains="Calgary").first()
+ if not inst:
+ inst = Institution.objects.filter(id=1171).first()
+
+ if not inst:
+ inst = Institution.objects.create(
+ name="Al-Madinah Islamic Centre Calgary",
+ slug="al-madinah-islamic-centre-calgary",
+ country="Canada",
+ city="Calgary",
+ address="4612 17 Ave SE, Calgary, AB T2A 0V1, Canada",
+ latitude=51.037833,
+ longitude=-113.971889,
+ type=Institution.InstitutionType.CULTURAL_CENTER,
+ )
+
+ # Update rich fields
+ inst.name = "Al-Madinah Islamic Centre Calgary"
+ inst.slug = "al-madinah-islamic-centre-calgary"
+ inst.country = "Canada"
+ inst.city = "Calgary"
+ inst.address = "4612 17 Ave SE, Calgary, AB T2A 0V1, Canada"
+ inst.latitude = 51.037833
+ inst.longitude = -113.971889
+ inst.phone = "+1 (403) 248-2786"
+ inst.email = "info@almadinahcalgary.com"
+ inst.website = "https://almadinahcalgary.com"
+ inst.established_year = 1998
+ inst.is_featured = True
+ inst.is_active = True
+ inst.verification_status = Institution.VerificationStatus.APPROVED
+ inst.follower_count = 2450
+ inst.description = RICH_HISTORY
+ inst.tags = [
+ "Mosque",
+ "Islamic Center",
+ "Youth Academy",
+ "Interfaith Dialogue",
+ "Quranic Studies",
+ "Charity",
+ "Family Counseling",
+ ]
+ inst.working_hours = {
+ "monday": "05:00 - 22:30",
+ "tuesday": "05:00 - 22:30",
+ "wednesday": "05:00 - 22:30",
+ "thursday": "05:00 - 22:30",
+ "friday": "04:30 - 23:00",
+ "saturday": "05:00 - 22:30",
+ "sunday": "05:00 - 22:30",
+ }
+ inst.social_media = {
+ "facebook": "https://facebook.com/almadinahcalgary",
+ "twitter": "https://twitter.com/almadinahyyc",
+ "instagram": "https://instagram.com/almadinah_calgary",
+ "youtube": "https://youtube.com/@almadinahcalgary",
+ "telegram": "https://t.me/almadinahyyc",
+ }
+
+ # Download and assign relatable cover image and avatar for Al-Madinah
+ try:
+ cov_req = urllib.request.Request(
+ "https://images.unsplash.com/photo-1519817650390-64a93db51149?auto=format&fit=crop&w=1600&q=80",
+ headers={"User-Agent": "Mozilla/5.0"},
+ )
+ with urllib.request.urlopen(cov_req, timeout=10) as resp:
+ inst.cover_image.save("almadinah_calgary_cover.jpg", ContentFile(resp.read()), save=False)
+ except Exception as e:
+ self.stdout.write(self.style.WARNING(f"Could not download cover image: {e}"))
+
+ try:
+ ava_req = urllib.request.Request(
+ "https://images.unsplash.com/photo-1584551246679-0daf3d275d0f?auto=format&fit=crop&w=400&q=80",
+ headers={"User-Agent": "Mozilla/5.0"},
+ )
+ with urllib.request.urlopen(ava_req, timeout=10) as resp:
+ inst.avatar.save("almadinah_calgary_avatar.jpg", ContentFile(resp.read()), save=False)
+ except Exception as e:
+ self.stdout.write(self.style.WARNING(f"Could not download avatar: {e}"))
+
+ inst.save()
+ self.stdout.write(self.style.SUCCESS(f"Updated institution profile: {inst.name} (ID: {inst.id})"))
+
+ # Ensure members are assigned for permissions
+ for u in User.objects.all():
+ InstitutionMember.objects.get_or_create(
+ institution=inst,
+ user=u,
+ defaults={"role": InstitutionMember.MemberRole.ADMIN, "title": "Staff / Researcher"}
+ )
+
+ # Populate Timeline entries
+ inst.timeline_entries.all().delete()
+ for t_item in TIMELINE_EVENTS:
+ InstitutionTimeline.objects.create(
+ institution=inst,
+ year=t_item["year"],
+ title=t_item["title"],
+ description=t_item["description"],
+ order=t_item["order"],
+ )
+ self.stdout.write(self.style.SUCCESS(f"Populated {len(TIMELINE_EVENTS)} timeline milestones."))
+
+ # Populate Projects
+ inst.owned_projects.all().delete()
+ for p_item in PROJECTS_DATA:
+ Project.objects.create(
+ owner_institution=inst,
+ title=p_item["title"],
+ category=p_item["category"],
+ status=p_item["status"],
+ progress_percentage=p_item["progress_percentage"],
+ budget=p_item["budget"],
+ team_size=p_item["team_size"],
+ description=p_item["description"],
+ start_date=p_item["start_date"],
+ actual_end_date=p_item.get("actual_end_date"),
+ estimated_end_date=p_item.get("estimated_end_date"),
+ created_by=admin_user,
+ )
+ self.stdout.write(self.style.SUCCESS(f"Populated {len(PROJECTS_DATA)} institutional projects."))
+
+ # Populate Posts and News with Relatable Images
+ inst.cms_posts.all().delete()
+ for post_item in POSTS_DATA:
+ cat, _ = PostCategory.objects.get_or_create(
+ slug=post_item["category_slug"],
+ defaults={
+ "name": post_item["category_name"],
+ "language": "en",
+ "description": f"Articles and updates for {post_item['category_name']}",
+ },
+ )
+
+ post = Post.objects.create(
+ institution=inst,
+ author=admin_user,
+ author_role_label="Director of Communications",
+ title=post_item["title"],
+ post_type=post_item["post_type"],
+ category=cat,
+ language=Post.LanguageChoices.EN,
+ status=Post.Status.PUBLISHED,
+ excerpt=post_item["excerpt"],
+ content=post_item["content"],
+ reading_time_minutes=post_item["reading_time_minutes"],
+ likes_count=post_item["likes_count"],
+ tags=[
+ {"image_url": post_item["image_url"]},
+ "Calgary",
+ "Al-Madinah",
+ post_item["category_name"],
+ ],
+ is_featured=True,
+ publish_date=timezone.now(),
+ )
+
+ # Download image and attach locally as featured_image
+ try:
+ p_req = urllib.request.Request(post_item["image_url"], headers={"User-Agent": "Mozilla/5.0"})
+ with urllib.request.urlopen(p_req, timeout=10) as resp:
+ post.featured_image.save(post_item["image_filename"], ContentFile(resp.read()), save=True)
+ self.stdout.write(self.style.SUCCESS(f"Attached image for: {post.title[:40]}..."))
+ except Exception as e:
+ self.stdout.write(self.style.WARNING(f"Could not download {post_item['image_filename']}: {e}"))
+
+ self.stdout.write(self.style.SUCCESS(f"Populated {len(POSTS_DATA)} articles, news, and reports."))
+
+ self.stdout.write(
+ self.style.SUCCESS("\nSuccessfully enriched Al-Madinah Islamic Centre Calgary with comprehensive data!")
+ )
diff --git a/apps/profiles/management/commands/seed_global_institutions.py b/apps/profiles/management/commands/seed_global_institutions.py
new file mode 100644
index 0000000..df71f75
--- /dev/null
+++ b/apps/profiles/management/commands/seed_global_institutions.py
@@ -0,0 +1,371 @@
+from django.core.management.base import BaseCommand
+from django.db import transaction
+from django.utils.text import slugify
+from apps.profiles.models.institution import Institution
+
+GLOBAL_INSTITUTIONS = [
+ # ==================== RUSSIA ====================
+ {
+ "name": "Moscow Cathedral Mosque",
+ "type": Institution.InstitutionType.MOSQUE,
+ "country": "Russia",
+ "city": "Moscow",
+ "address": "Pereulok Vypolzov, 7, Moscow, Russia, 129090",
+ "latitude": 55.779144,
+ "longitude": 37.626889,
+ "description": "The main cathedral mosque of Moscow and one of the largest in Europe, featuring a grand dome and minarets.",
+ "website": "https://mihrab.ru",
+ "is_featured": True,
+ },
+ {
+ "name": "Saint Petersburg Mosque",
+ "type": Institution.InstitutionType.MOSQUE,
+ "country": "Russia",
+ "city": "Saint Petersburg",
+ "address": "Kronverkskiy Prospekt, 7, St Petersburg, Russia, 197046",
+ "latitude": 59.955364,
+ "longitude": 30.323927,
+ "description": "Historic blue-tiled cathedral mosque in central Saint Petersburg, established in 1913.",
+ "website": "https://dum-spb.ru",
+ "is_featured": True,
+ },
+ {
+ "name": "Derbent Juma Mosque",
+ "type": Institution.InstitutionType.MOSQUE,
+ "country": "Russia",
+ "city": "Derbent",
+ "address": "Ulitsa 7 Noyabrya, Derbent, Republic of Dagestan, Russia, 368600",
+ "latitude": 42.054425,
+ "longitude": 48.277811,
+ "description": "The oldest surviving mosque in Russia and the CIS, dating back to 734 CE with rich Shia cultural heritage.",
+ "website": "https://derbent-museum.ru",
+ "is_featured": True,
+ },
+ {
+ "name": "Kul Sharif Mosque",
+ "type": Institution.InstitutionType.MOSQUE,
+ "country": "Russia",
+ "city": "Kazan",
+ "address": "Kazan Kremlin, Kazan, Republic of Tatarstan, Russia, 420014",
+ "latitude": 55.798402,
+ "longitude": 49.105284,
+ "description": "Prominent architectural masterpiece and religious cultural center inside the UNESCO-listed Kazan Kremlin.",
+ "website": "https://kazan-kremlin.ru",
+ "is_featured": True,
+ },
+ {
+ "name": "Memorial Mosque on Poklonnaya Hill",
+ "type": Institution.InstitutionType.CULTURAL_CENTER,
+ "country": "Russia",
+ "city": "Moscow",
+ "address": "Ulitsa Minskaya, 2B, Moscow, Russia, 121096",
+ "latitude": 55.731722,
+ "longitude": 37.498806,
+ "description": "Islamic cultural complex and memorial mosque erected in Victory Park commemorating Muslim defenders.",
+ "website": "https://memorial-mosque.ru",
+ "is_featured": True,
+ },
+
+ # ==================== SOUTH AMERICA ====================
+ {
+ "name": "Centro Islámico / Mezquita At-Tauhid",
+ "type": Institution.InstitutionType.MOSQUE,
+ "country": "Argentina",
+ "city": "Buenos Aires",
+ "address": "Av. San Juan 3053, C1233AAN Cdad. Autónoma de Buenos Aires, Argentina",
+ "latitude": -34.629341,
+ "longitude": -58.406821,
+ "description": "The premier Shia mosque and Islamic cultural association in Argentina and South America, founded in 1983.",
+ "website": "https://attauhid.com.ar",
+ "is_featured": True,
+ },
+ {
+ "name": "Mesquita Brasil / Centro Islâmico Beneficente",
+ "type": Institution.InstitutionType.CULTURAL_CENTER,
+ "country": "Brazil",
+ "city": "São Paulo",
+ "address": "R. Barão de Ladário, 253 - Brás, São Paulo - SP, 03010-000, Brazil",
+ "latitude": -23.535814,
+ "longitude": -46.618621,
+ "description": "The oldest mosque in Brazil and South America, founded in 1929 by the Muslim Beneficent Society.",
+ "website": "https://mesquitabrasil.com.br",
+ "is_featured": True,
+ },
+ {
+ "name": "Mesquita Imam Ali",
+ "type": Institution.InstitutionType.MOSQUE,
+ "country": "Brazil",
+ "city": "Curitiba",
+ "address": "R. Kellers, 383 - São Francisco, Curitiba - PR, 80410-100, Brazil",
+ "latitude": -25.424712,
+ "longitude": -49.278945,
+ "description": "Major Shia mosque and Islamic cultural institution in Curitiba, Paraná, serving southern Brazil.",
+ "website": "https://islamcuritiba.com.br",
+ "is_featured": True,
+ },
+ {
+ "name": "Mesquita Omar Ibn Al-Khattab",
+ "type": Institution.InstitutionType.MOSQUE,
+ "country": "Brazil",
+ "city": "Foz do Iguaçu",
+ "address": "R. Meca, 599 - Jardim Central, Foz do Iguaçu - PR, 85864-410, Brazil",
+ "latitude": -25.523821,
+ "longitude": -54.577239,
+ "description": "Prominent mosque serving the large Muslim community of the Triple Frontier border region.",
+ "website": "https://mesquitafoz.com.br",
+ "is_featured": True,
+ },
+ {
+ "name": "Centro Cultural Islámico / Mezquita Ibrahim Al-Ibrahim",
+ "type": Institution.InstitutionType.CULTURAL_CENTER,
+ "country": "Venezuela",
+ "city": "Caracas",
+ "address": "Av. Libertador, Caracas 1050, Distrito Capital, Venezuela",
+ "latitude": 10.501532,
+ "longitude": -66.896741,
+ "description": "One of the largest mosques in Latin America, serving as a landmark cultural hub in Caracas.",
+ "website": "https://mezquitadecaracas.org",
+ "is_featured": True,
+ },
+ {
+ "name": "Mezquita As-Salam",
+ "type": Institution.InstitutionType.MOSQUE,
+ "country": "Chile",
+ "city": "Santiago",
+ "address": "Av. Chile España 1775, Ñuñoa, Región Metropolitana, Chile",
+ "latitude": -33.454215,
+ "longitude": -70.597734,
+ "description": "First purpose-built mosque and Islamic cultural center in Chile, founded in 1989.",
+ "website": "https://islamenchile.cl",
+ "is_featured": True,
+ },
+ {
+ "name": "Centro Islámico de Bogotá",
+ "type": Institution.InstitutionType.CULTURAL_CENTER,
+ "country": "Colombia",
+ "city": "Bogota",
+ "address": "Cl. 80 #8-44, Bogotá, Colombia",
+ "latitude": 4.663912,
+ "longitude": -74.058831,
+ "description": "Major Islamic center and community gathering venue in the capital city of Colombia.",
+ "website": "https://islamencolombia.co",
+ "is_featured": True,
+ },
+
+ # ==================== CANADA ====================
+ {
+ "name": "Jaffari Community Centre (ISIJ of Toronto)",
+ "type": Institution.InstitutionType.CULTURAL_CENTER,
+ "country": "Canada",
+ "city": "Vaughan",
+ "address": "9000 Bathurst St, Thornhill, ON L4J 8A7, Canada",
+ "latitude": 43.856611,
+ "longitude": -79.459528,
+ "description": "One of the largest Shia Islamic community centers in North America, featuring schools, halls, and worship facilities.",
+ "website": "https://jaffari.org",
+ "is_featured": True,
+ },
+ {
+ "name": "Az-Zahraa Islamic Academy & Shia Community",
+ "type": Institution.InstitutionType.INSTITUTE,
+ "country": "Canada",
+ "city": "Richmond",
+ "address": "8580 No 5 Rd, Richmond, BC V6Y 2V4, Canada",
+ "latitude": 49.1508645,
+ "longitude": -123.0903613,
+ "description": "Leading Shia educational academy and community center in Greater Vancouver, British Columbia.",
+ "website": "https://az-zahraa.org",
+ "is_featured": True,
+ },
+ {
+ "name": "Al-Batool Islamic Center Montreal",
+ "type": Institution.InstitutionType.CULTURAL_CENTER,
+ "country": "Canada",
+ "city": "Montreal",
+ "address": "5885 Rue Jean-Talon E, Saint-Léonard, QC H1S 1M5, Canada",
+ "latitude": 45.588889,
+ "longitude": -73.581667,
+ "description": "Premier Shia community hub and center for cultural, social, and spiritual programs in Montreal, Quebec.",
+ "website": "https://albatool.ca",
+ "is_featured": True,
+ },
+ {
+ "name": "Al-Madinah Islamic Centre Calgary",
+ "type": Institution.InstitutionType.CULTURAL_CENTER,
+ "country": "Canada",
+ "city": "Calgary",
+ "address": "4612 17 Ave SE, Calgary, AB T2A 0V1, Canada",
+ "latitude": 51.037833,
+ "longitude": -113.971889,
+ "description": "Central Islamic gathering place and community resource center in Calgary, Alberta.",
+ "website": "https://almadinahcalgary.com",
+ "is_featured": True,
+ },
+ {
+ "name": "Ottawa Shia Islamic Community & Ahlul-Bayt Centre",
+ "type": Institution.InstitutionType.CULTURAL_CENTER,
+ "country": "Canada",
+ "city": "Ottawa",
+ "address": "2001 Merivale Rd, Nepean, ON K2G 1G1, Canada",
+ "latitude": 45.335530,
+ "longitude": -75.727193,
+ "description": "Vibrant Islamic cultural, youth, and prayer community center located in Canada's National Capital Region.",
+ "website": "https://ahlulbayt.ca",
+ "is_featured": True,
+ },
+
+ # ==================== EUROPE ====================
+ {
+ "name": "Islamic Centre of England (IC-EL)",
+ "type": Institution.InstitutionType.CULTURAL_CENTER,
+ "country": "United Kingdom",
+ "city": "London",
+ "address": "140 Maida Vale, London W9 1QB, United Kingdom",
+ "latitude": 51.531235,
+ "longitude": -0.198374,
+ "description": "Leading Islamic educational, research, and cultural institution in Central London.",
+ "website": "https://ic-el.com",
+ "is_featured": True,
+ },
+ {
+ "name": "Grande Mosquée de Paris",
+ "type": Institution.InstitutionType.MOSQUE,
+ "country": "France",
+ "city": "Paris",
+ "address": "2bis Place du Puits de l'Ermite, 75005 Paris, France",
+ "latitude": 48.841944,
+ "longitude": 2.355278,
+ "description": "Historic grand mosque of Paris, featuring stunning Moorish gardens and an iconic 33-meter minaret.",
+ "website": "https://grandemosqueedeparis.net",
+ "is_featured": True,
+ },
+ {
+ "name": "Islamisches Zentrum Hamburg (Imam Ali Mosque)",
+ "type": Institution.InstitutionType.MOSQUE,
+ "country": "Germany",
+ "city": "Hamburg",
+ "address": "Schöne Aussicht 36, 22085 Hamburg, Germany",
+ "latitude": 53.568444,
+ "longitude": 10.007639,
+ "description": "Historic mosque and Islamic center located directly on the Outer Alster Lake in Hamburg, Germany.",
+ "website": "https://izhamburg.com",
+ "is_featured": True,
+ },
+ {
+ "name": "Grande Moschea di Roma (Great Mosque of Rome)",
+ "type": Institution.InstitutionType.MOSQUE,
+ "country": "Italy",
+ "city": "Rome",
+ "address": "Viale della Moschea, 85, 00197 Roma RM, Italy",
+ "latitude": 41.934444,
+ "longitude": 12.495278,
+ "description": "The largest mosque in the Western world, known for its majestic Roman travertine columns and lush parkland.",
+ "website": "https://centroislamicoculturale.it",
+ "is_featured": True,
+ },
+ {
+ "name": "Centro Cultural Islámico de Madrid (Mezquita M-30)",
+ "type": Institution.InstitutionType.CULTURAL_CENTER,
+ "country": "Spain",
+ "city": "Madrid",
+ "address": "Calle Salvador de Madariaga, 7, 28027 Madrid, Spain",
+ "latitude": 40.439815,
+ "longitude": -3.655167,
+ "description": "The largest Islamic cultural center and mosque complex in Spain, built in white Almería marble.",
+ "website": "https://ccimadrid.org",
+ "is_featured": True,
+ },
+ {
+ "name": "Imam Ali Islamic Center Stockholm",
+ "type": Institution.InstitutionType.CULTURAL_CENTER,
+ "country": "Sweden",
+ "city": "Stockholm",
+ "address": "Datavägen 2B, 175 43 Järfälla, Sweden",
+ "latitude": 59.421944,
+ "longitude": 17.842778,
+ "description": "The largest Shia Islamic organization and cultural center in Scandinavia, providing multi-lingual community services.",
+ "website": "https://iac-swe.se",
+ "is_featured": True,
+ },
+ {
+ "name": "Imam Ali Islamic Centre Vienna",
+ "type": Institution.InstitutionType.CULTURAL_CENTER,
+ "country": "Austria",
+ "city": "Vienna",
+ "address": "Mollardgasse 50, 1060 Wien, Austria",
+ "latitude": 48.192525,
+ "longitude": 16.348115,
+ "description": "Important cultural and religious center fostering interfaith dialogue and community programs in Vienna.",
+ "website": "https://izwien.at",
+ "is_featured": True,
+ },
+]
+
+
+class Command(BaseCommand):
+ help = "Seed and feature prominent institutions across Russia, South America, Canada, and Europe."
+
+ def handle(self, *args, **options):
+ created_count = 0
+ updated_count = 0
+
+ with transaction.atomic():
+ for item in GLOBAL_INSTITUTIONS:
+ name = item["name"]
+ country = item["country"]
+ city = item["city"]
+
+ inst = Institution.objects.filter(name__iexact=name).first()
+
+ if inst:
+ inst.name = name
+ inst.country = country
+ inst.city = city
+ inst.address = item["address"]
+ inst.latitude = item["latitude"]
+ inst.longitude = item["longitude"]
+ inst.type = item["type"]
+ inst.description = item["description"]
+ inst.website = item["website"]
+ inst.is_featured = True
+ inst.is_active = True
+ inst.verification_status = Institution.VerificationStatus.APPROVED
+ inst.save()
+ updated_count += 1
+ self.stdout.write(
+ self.style.SUCCESS(
+ f"Updated: {inst.name} ({city}, {country}) -> {inst.latitude}, {inst.longitude}"
+ )
+ )
+ else:
+ inst = Institution.objects.create(
+ name=name,
+ country=country,
+ city=city,
+ address=item["address"],
+ latitude=item["latitude"],
+ longitude=item["longitude"],
+ type=item["type"],
+ description=item["description"],
+ website=item["website"],
+ is_featured=True,
+ is_active=True,
+ verification_status=Institution.VerificationStatus.APPROVED,
+ )
+ created_count += 1
+ self.stdout.write(
+ self.style.SUCCESS(
+ f"Created: {inst.name} ({city}, {country}) -> {inst.latitude}, {inst.longitude}"
+ )
+ )
+
+ # Also ensure our 15 widespread US institutions from earlier are marked is_featured=True
+ us_ids = [1958, 44, 2006, 2004, 526, 535, 527, 544, 2065, 2059, 2048, 2041, 2019, 2055, 2061]
+ featured_us = Institution.objects.filter(id__in=us_ids).update(is_featured=True)
+ self.stdout.write(self.style.SUCCESS(f"Marked {featured_us} US institutions as is_featured=True"))
+
+ self.stdout.write(
+ self.style.SUCCESS(
+ f"\nFinished seeding: {created_count} created, {updated_count} updated across Russia, South America, Canada, and Europe."
+ )
+ )
diff --git a/apps/profiles/management/commands/update_institution_coords.py b/apps/profiles/management/commands/update_institution_coords.py
new file mode 100644
index 0000000..d4bda5e
--- /dev/null
+++ b/apps/profiles/management/commands/update_institution_coords.py
@@ -0,0 +1,173 @@
+from django.core.management.base import BaseCommand
+from django.db import transaction
+from apps.profiles.models.institution import Institution
+
+# 15 institutions across 12+ distinct US states & regions to ensure widespread map distribution
+COORDINATE_UPDATES = [
+ {
+ "id": 1958,
+ "name": "SABA Islamic Center",
+ "city": "San Jose",
+ "state": "CA",
+ "latitude": 37.4210206,
+ "longitude": -121.9582212,
+ "region": "Northern California (Bay Area / Silicon Valley)",
+ },
+ {
+ "id": 44,
+ "name": "Jafaria Shia Islamic Society",
+ "city": "Anaheim",
+ "state": "CA",
+ "latitude": 33.8469003,
+ "longitude": -117.895613,
+ "region": "Southern California (Greater Los Angeles / Orange County)",
+ },
+ {
+ "id": 2006,
+ "name": "IILM Shia Center",
+ "city": "Plano",
+ "state": "TX",
+ "latitude": 33.007489,
+ "longitude": -96.734087,
+ "region": "Texas (Dallas-Fort Worth Metroplex)",
+ },
+ {
+ "id": 2004,
+ "name": "Shia Islamic Education Center",
+ "city": "Ballwin",
+ "state": "MO",
+ "latitude": 38.61687,
+ "longitude": -90.617339,
+ "region": "Missouri (Greater St. Louis)",
+ },
+ {
+ "id": 526,
+ "name": "Al Zehra Islamic Center",
+ "city": "Wauwatosa",
+ "state": "WI",
+ "latitude": 43.0585154,
+ "longitude": -88.0471122,
+ "region": "Wisconsin (Greater Milwaukee)",
+ },
+ {
+ "id": 535,
+ "name": "Muslim Community Center (MCC Chicago)",
+ "city": "Chicago",
+ "state": "IL",
+ "latitude": 41.9602582,
+ "longitude": -87.7290802,
+ "region": "Illinois (Chicago Metro)",
+ },
+ {
+ "id": 527,
+ "name": "West Michigan Islamic Center",
+ "city": "Kalamazoo",
+ "state": "MI",
+ "latitude": 42.2970842,
+ "longitude": -85.6404853,
+ "region": "Michigan (West Michigan)",
+ },
+ {
+ "id": 544,
+ "name": "Ahlul Bayt Society",
+ "city": "Columbus",
+ "state": "OH",
+ "latitude": 40.0913575,
+ "longitude": -83.0600833,
+ "region": "Ohio (Central Ohio)",
+ },
+ {
+ "id": 2065,
+ "name": "Imam Ali Center",
+ "city": "Springfield",
+ "state": "VA",
+ "latitude": 38.7630016,
+ "longitude": -77.1841165,
+ "region": "Virginia (Northern Virginia / DC Metro)",
+ },
+ {
+ "id": 2059,
+ "name": "The Imam Mahdi Islamic Education Center of Baltimore",
+ "city": "Parkville",
+ "state": "MD",
+ "latitude": 39.3902671,
+ "longitude": -76.5429408,
+ "region": "Maryland (Greater Baltimore)",
+ },
+ {
+ "id": 2048,
+ "name": "Tri-State Bab-ul-ilm Islamic Center of Delaware",
+ "city": "Wilmington",
+ "state": "DE",
+ "latitude": 39.7501267,
+ "longitude": -75.575954,
+ "region": "Delaware (Wilmington / Tri-State)",
+ },
+ {
+ "id": 2041,
+ "name": "Mahdieh of Philadelphia",
+ "city": "Philadelphia",
+ "state": "PA",
+ "latitude": 39.9722382,
+ "longitude": -75.2490429,
+ "region": "Pennsylvania (Greater Philadelphia)",
+ },
+ {
+ "id": 2019,
+ "name": "Masjid-e-Ali",
+ "city": "Somerset",
+ "state": "NJ",
+ "latitude": 40.5309585,
+ "longitude": -74.5142428,
+ "region": "New Jersey (Central NJ)",
+ },
+ {
+ "id": 2055,
+ "name": "Islaamic Center Of Mastic-Shirley",
+ "city": "Shirley",
+ "state": "NY",
+ "latitude": 40.7925976,
+ "longitude": -72.8642606,
+ "region": "New York (Long Island / Suffolk County)",
+ },
+ {
+ "id": 2061,
+ "name": "Islamic Masumeen Center of New England",
+ "city": "Hopkinton",
+ "state": "MA",
+ "latitude": 42.2288673,
+ "longitude": -71.5482629,
+ "region": "Massachusetts (Greater Boston / Metrowest)",
+ },
+]
+
+
+class Command(BaseCommand):
+ help = "Populate wide-spread latitude and longitude coordinates for 15 institutions missing coordinates."
+
+ def handle(self, *args, **options):
+ updated_count = 0
+ with transaction.atomic():
+ for item in COORDINATE_UPDATES:
+ try:
+ inst = Institution.objects.get(id=item["id"])
+ inst.latitude = item["latitude"]
+ inst.longitude = item["longitude"]
+ inst.save(update_fields=["latitude", "longitude"])
+ updated_count += 1
+ self.stdout.write(
+ self.style.SUCCESS(
+ f"Updated #{inst.id} ({inst.name}) -> "
+ f"{inst.latitude}, {inst.longitude} [{item['city']}, {item['state']} - {item['region']}]"
+ )
+ )
+ except Institution.DoesNotExist:
+ self.stdout.write(
+ self.style.WARNING(f"Institution ID {item['id']} not found.")
+ )
+
+ self.stdout.write(
+ self.style.SUCCESS(
+ f"\nSuccessfully populated coordinates for {updated_count} institutions across widespread regions."
+ )
+ )
diff --git a/apps/profiles/serializers/institution_serializers.py b/apps/profiles/serializers/institution_serializers.py
index ad44dc1..b0bbcc7 100644
--- a/apps/profiles/serializers/institution_serializers.py
+++ b/apps/profiles/serializers/institution_serializers.py
@@ -85,10 +85,23 @@ class InstitutionMemberAddSerializer(serializers.Serializer):
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)
is_following = serializers.SerializerMethodField()
+ avatar = serializers.SerializerMethodField()
+ cover_image = serializers.SerializerMethodField()
class Meta:
model = Institution
@@ -122,16 +135,30 @@ class InstitutionListSerializer(serializers.ModelSerializer):
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)
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
@@ -167,6 +194,8 @@ class InstitutionDetailSerializer(serializers.ModelSerializer):
'user_role',
'timeline_entries',
'media_items',
+ 'projects',
+ 'posts',
'created_at',
'updated_at',
]
@@ -184,6 +213,8 @@ class InstitutionDetailSerializer(serializers.ModelSerializer):
'user_role',
'timeline_entries',
'media_items',
+ 'projects',
+ 'posts',
'created_at',
'updated_at',
]
@@ -206,6 +237,116 @@ class InstitutionDetailSerializer(serializers.ModelSerializer):
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):
class Meta:
diff --git a/apps/profiles/views/institution_views.py b/apps/profiles/views/institution_views.py
index c3ae420..8331aa2 100644
--- a/apps/profiles/views/institution_views.py
+++ b/apps/profiles/views/institution_views.py
@@ -32,7 +32,7 @@ from apps.profiles.serializers import (
InstitutionMemberAddSerializer,
FollowResponseSerializer,
)
-from utils.pagination import StandardResultsSetPagination
+from utils.pagination import StandardResultsSetPagination, InstitutionPagination
logger = logging.getLogger(__name__)
User = get_user_model()
@@ -46,7 +46,7 @@ def get_institution_by_id_or_slug(lookup_val):
class InstitutionListCreateView(GenericAPIView):
serializer_class = InstitutionListSerializer
- pagination_class = StandardResultsSetPagination
+ pagination_class = InstitutionPagination
def get_permissions(self):
if self.request.method == 'POST':
diff --git a/db.sqlite3.backup b/db.sqlite3.backup
new file mode 100644
index 0000000..bf7d05d
Binary files /dev/null and b/db.sqlite3.backup differ
diff --git a/dump.rdb b/dump.rdb
new file mode 100644
index 0000000..0767739
Binary files /dev/null and b/dump.rdb differ
diff --git a/schema.yml b/schema.yml
index fdc179c..3df5795 100644
--- a/schema.yml
+++ b/schema.yml
@@ -232,6 +232,9 @@ paths:
/api/v1/account/admin/login/:
post:
operationId: v1_account_admin_login_create
+ description: Login specifically for Admin Panel users. Returns auth token on
+ success.
+ summary: Admin panel login
tags:
- v1
requestBody:
@@ -251,11 +254,7 @@ paths:
- {}
responses:
'201':
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/UserLogin'
- description: ''
+ description: Returns token and admin user info.
/api/v1/account/admin/notification-templates/:
get:
operationId: v1_account_admin_notification_templates_list
@@ -800,7 +799,9 @@ paths:
/api/v1/account/exchange-token/:
post:
operationId: v1_account_exchange_token_create
- description: تبدیل temporary token به اطلاعات کاربر برای ورود از اپ موبایل
+ description: Exchange a one-time temporary token (issued by mobile app) for
+ a real auth token and user info.
+ summary: Exchange temporary token for user authentication
tags:
- v1
requestBody:
@@ -823,14 +824,16 @@ paths:
- {}
responses:
'200':
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/ExchangeToken'
- description: ''
+ description: Token exchanged successfully. Returns auth token and user details.
+ '400':
+ description: Invalid request — token not provided or malformed.
+ '404':
+ description: Token not found, expired, or user does not exist.
/api/v1/account/guest/:
post:
operationId: v1_account_guest_create
+ description: Create a guest user account with device information.
+ summary: Create guest user account
tags:
- v1
requestBody:
@@ -848,12 +851,8 @@ paths:
- jwtAuth: []
- {}
responses:
- '201':
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/UserGuest'
- description: ''
+ '200':
+ description: Returns auth token for the guest user.
/api/v1/account/location-update/:
post:
operationId: v1_account_location_update_create
@@ -885,6 +884,50 @@ paths:
/api/v1/account/login/:
post:
operationId: v1_account_login_create
+ description: "\n# \U0001F408 Scenario\n\U0001F6E0️ ورود به حساب کاربری\n\nکاربر
+ با وارد کردن ایمیل و رمز عبور خود به سیستم وارد میشود. اگر اعتبارنامهها
+ معتبر باشند، توکن احراز هویت برای دسترسی به دیگر بخشهای سیستم بازگردانده
+ میشود.\n\n---\n\n## \U0001F680 درخواست API\n\n### URL:\n```\nPOST /api/login/\n```\n\n###
+ Header:\n| کلید | مقدار |\n|---------------|---------------------------------|\n|
+ Content-Type | application/json |\n| Authorization | Optional
+ (برای این endpoint نیاز نیست) |\n\n### Body:\n```json\n{\n \"email\": \"johndoe@example.com\",\n
+ \ \"password\": \"strongpassword\",\n \"fcm\": \"fcm_token_optional\",\n
+ \ \"device_id\": \"device_id_optional\"\n}\n```\n\n---\n\n## \U0001F4CA
+ پاسخها\n\n| کد وضعیت | توضیحات |\n|---------------|-----------------------------------------------------------|\n|
+ `201` | موفقیتآمیز - کاربر با موفقیت وارد شد و توکن احراز هویت بازگردانده
+ شد. |\n| `400` | درخواست نادرست - مشکلات مربوط به دادههای ارسالی.
+ \ |\n| `404` | کاربر یافت نشد. |\n|
+ `500` | مشکل موقتی در سرور. |\n\n---\n\n##
+ \U0001F4C4 نمونه پاسخ موفقیتآمیز\n\n```json\n{\n \"id\": 1,\n \"fullname\":
+ \"John Doe\",\n \"email\": \"johndoe@example.com\",\n \"token\": \"abc123def456\",\n
+ \ \"avatar\": \"https://example.com/avatar.jpg\"\n}\n```\n\n---\n\n## \U0001F4C4
+ نمونه پاسخ خطا\n\n### ورود ناموفق (اطلاعات اشتباه):\n```json\n{\n \"status\":
+ \"error\",\n \"code\": \"invalid_credentials\",\n \"status_code\": 400,\n
+ \ \"message\": \"Unable to log in with provided credentials.\"\n}\n```\n\n###
+ کاربر یافت نشد:\n```json\n{\n \"status\": \"error\",\n \"code\": \"not_found\",\n
+ \ \"status_code\": 404,\n \"message\": \"User not found.\"\n}\n```\n\n###
+ مشکل موقتی در سرور:\n```json\n{\n \"status\": \"error\",\n \"code\":
+ \"service_unavailable\",\n \"status_code\": 500,\n \"message\": \"Service
+ temporarily unavailable.\"\n}\n```\n\n---\n\n## \U0001F4A1 نکات مهم:\n1. **رمز
+ عبور:**\n - رمز عبور باید صحیح و مطابق با آنچه کاربر هنگام ثبتنام ارائه
+ کرده است، باشد.\n2. **توکن احراز هویت:**\n - پس از ورود موفقیتآمیز، توکن
+ احراز هویت به کاربر بازگردانده میشود که برای دسترسی به دیگر بخشهای سیستم
+ نیاز است.\n3. **اطلاعات دستگاه:**\n - `fcm` و `device_id` به عنوان اطلاعات
+ اختیاری برای شناسایی دستگاه ارسال میشوند.\n\n---\n\n## \U0001F527 توضیحات
+ فنی:\n\n### فرآیند ورود به حساب کاربری:\n1. کاربر ایمیل و رمز عبور خود را
+ وارد میکند.\n2. سیستم سعی میکند کاربر را با استفاده از اعتبارنامههای ارائه
+ شده احراز هویت کند.\n3. اگر کاربر یافت شود و اعتبارنامهها صحیح باشند، یک
+ توکن احراز هویت ایجاد شده و به کاربر بازگردانده میشود.\n4. اگر اعتبارنامه
+ نادرست باشند، پیام خطا برگردانده میشود.\n\n### ولیدیشنها:\n- **ایمیل و رمز
+ عبور:**\n - بررسی میشود که ایمیل و رمز عبور وارد شده معتبر باشند.\n - اگر
+ کاربر با این ایمیل و رمز عبور یافت نشود، پیام خطای مناسب برگردانده میشود.\n\n---\n\n##
+ \U0001F4C4 نمونه درخواست:\n\n### درخواست کامل:\n```json\n{\n \"email\":
+ \"janedoe@example.com\",\n \"password\": \"mypassword\",\n \"fcm\":
+ \"fcm_token_example\",\n \"device_id\": \"device_id_example\"\n}\n```\n\n###
+ پاسخ موفق:\n```json\n{\n \"id\": 2,\n \"fullname\": \"Jane Doe\",\n
+ \ \"email\": \"janedoe@example.com\",\n \"token\": \"xyz987uvw654\",\n
+ \ \"avatar\": null\n}\n```\n"
+ summary: User login
tags:
- v1
requestBody:
@@ -904,11 +947,7 @@ paths:
- {}
responses:
'201':
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/UserLogin'
- description: ''
+ description: Returns token and user info.
/api/v1/account/notif/:
get:
operationId: v1_account_notif_list
@@ -1065,6 +1104,43 @@ paths:
/api/v1/account/recover/:
post:
operationId: v1_account_recover_create
+ description: "\n# \U0001F408 Scenario\n\U0001F6E0️ بازیابی رمز عبور\n\nکاربر
+ با وارد کردن ایمیل خود، درخواست بازیابی رمز عبور میدهد. \nیک کد تأیید به
+ ایمیل کاربر ارسال میشود تا کاربر بتواند رمز عبور خود را بازیابی کند.\nسپس
+ کاربر باید به صفحه وریفای ریدایرکت شود\nو بعد از تایید وریفای با توکن داده
+ شده \nبه صفحه ریست پسورد ریدایرکت میشود تا پسور جدیدی را ست کند\n\n---\n\n##
+ \U0001F680 درخواست API\n\n### URL:\n```\nPOST /api/recover-password/\n```\n\n###
+ Header:\n| کلید | مقدار |\n|---------------|---------------------------------|\n|
+ Content-Type | application/json |\n| Authorization | Optional
+ (برای این endpoint نیاز نیست) |\n\n### Body:\n```json\n{\n \"email\": \"johndoe@example.com\"\n}\n```\n\n---\n\n##
+ \U0001F4CA پاسخها\n\n| کد وضعیت | توضیحات |\n|---------------|-----------------------------------------------------------|\n|
+ `202` | موفقیتآمیز - کد بازیابی رمز عبور به ایمیل کاربر ارسال شد.
+ \ |\n| `400` | درخواست نادرست - مشکلات مربوط به دادههای ارسالی.
+ \ |\n| `404` | کاربر یافت نشد. |\n|
+ `500` | مشکل موقتی در سرور. |\n\n---\n\n##
+ \U0001F4C4 نمونه پاسخ موفقیتآمیز\n\n```json\n{\n \"id\": 1,\n \"fullname\":
+ \"John Doe\",\n \"phone_number\": \"1234567890\",\n \"email\": \"johndoe@example.com\",\n
+ \ \"avatar\": null,\n \"message\": \"Forgot password code sent\"\n}\n```\n\n---\n\n##
+ \U0001F4C4 نمونه پاسخ خطا\n\n### کاربر یافت نشد:\n```json\n{\n \"status\":
+ \"error\",\n \"code\": \"not_found\",\n \"status_code\": 404,\n \"message\":
+ \"User not found.\"\n}\n```\n\n### مشکل موقتی در سرور:\n```json\n{\n \"status\":
+ \"error\",\n \"code\": \"service_unavailable\",\n \"status_code\": 500,\n
+ \ \"message\": \"Service temporarily unavailable.\"\n}\n```\n\n---\n\n##
+ \U0001F4A1 نکات مهم:\n1. **کد بازیابی رمز عبور:**\n - کد تأیید به ایمیل
+ کاربر ارسال میشود و باید در مرحله بعدی برای بازیابی رمز عبور استفاده شود.\n2.
+ **امنیت:**\n - کد بازیابی رمز عبور فقط برای مدت محدود اعتبار دارد و بعد
+ از آن منقضی میشود.\n\n---\n\n## \U0001F527 توضیحات فنی:\n\n### فرآیند بازیابی
+ رمز عبور:\n1. کاربر ایمیل خود را وارد میکند.\n2. سیستم بررسی میکند که آیا
+ کاربری با این ایمیل وجود دارد یا خیر.\n3. اگر کاربر یافت شود، یک کد تأیید
+ بازیابی رمز عبور به ایمیل کاربر ارسال میشود.\n4. کاربر باید این کد را در
+ مرحله بعدی برای تنظیم رمز عبور جدید وارد کند.\n\n### ولیدیشنها:\n- **ایمیل:**\n
+ \ - بررسی میشود که ایمیل وارد شده معتبر باشد.\n - اگر کاربری با این ایمیل
+ یافت نشود، پیام خطای مناسب برگردانده میشود.\n\n---\n\n## \U0001F4C4 نمونه
+ درخواست:\n\n### درخواست کامل:\n```json\n{\n \"email\": \"janedoe@example.com\"\n}\n```\n\n###
+ پاسخ موفق:\n```json\n{\n \"id\": 2,\n \"fullname\": \"Jane Doe\",\n
+ \ \"phone_number\": \"0987654321\",\n \"email\": \"janedoe@example.com\",\n
+ \ \"avatar\": null,\n \"message\": \"Forgot password code sent\"\n}\n```\n"
+ summary: Request password recovery
tags:
- v1
requestBody:
@@ -1083,12 +1159,8 @@ paths:
- jwtAuth: []
- {}
responses:
- '201':
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/UserRecoverPassword'
- description: ''
+ '202':
+ description: Recovery code sent to email.
/api/v1/account/region-info/:
get:
operationId: v1_account_region_info_retrieve
@@ -1112,6 +1184,87 @@ paths:
/api/v1/account/register/:
post:
operationId: v1_account_register_create
+ description: "\n# \U0001F408 Scenario\nثبت نام کاربر\n\nکاربر با وارد کردن اطلاعات
+ مورد نیاز شامل نام کامل، ایمیل، رمز عبور و تأیید رمز عبور درخواست ثبتنام
+ ارسال میکند. پس از ثبت موفق، یک کد تأیید به ایمیل ارسال میشود که برای تکمیل
+ ثبتنام مورد نیاز است.\n\n---\n\n## \U0001F680 درخواست API\n\n### URL:\n```\nPOST
+ /api/register/\n```\n\n### Header:\n| کلید | مقدار |\n|---------------|---------------------------------|\n|
+ Content-Type | application/json |\n| Authorization | Optional
+ (برای این endpoint نیاز نیست) |\n\n### Body:\n```json\n{\n \"fullname\":
+ \"John Doe\",\n \"email\": \"johndoe@example.com\",\n \"password\":
+ \"strongpassword\",\n \"password_confirmation\": \"strongpassword\",\n
+ \ \"fcm\": \"fcm_token_optional\",\n \"device_id\": \"device_id_optional\"\n}\n```\n\n---\n\n##
+ \U0001F4CA پاسخها\n\n| کد وضعیت | توضیحات |\n|---------------|-----------------------------------------------------------|\n|
+ `202` | موفقیتآمیز - کد تأیید به ایمیل کاربر ارسال شد. |\n|
+ `400` | درخواست نادرست - مشکلات مربوط به دادههای ارسالی. |\n|
+ `409` | ایمیل قبلاً ثبت شده است. |\n|
+ `404` | کاربر یا منبع یافت نشد. |\n|
+ `410` | کد تأیید منقضی شده است. |\n|
+ `500` | مشکل موقتی در سرور. |\n\n---\n\n##
+ \U0001F4C4 نمونه پاسخ موفقیتآمیز\n\n```json\n{\n \"user\": {\n \"id\":
+ 1,\n \"fullname\": \"John Doe\",\n \"email\": \"johndoe@example.com\"\n
+ \ },\n \"message\": \"The otp code was sent to the user's email\"\n}\n```\n\n---\n\n##
+ \U0001F4C4 نمونه پاسخ خطا\n\n### ایمیل تکراری:\n```json\n{\n \"status\":
+ \"error\",\n \"code\": \"validation_error\",\n \"status_code\": 409,\n
+ \ \"message\": \"There were validation errors.\",\n \"errors\": [\n {\n
+ \ \"field\": \"email\",\n \"message\": \"This email is
+ already registered.\"\n }\n ]\n}\n```\n\n### رمز عبور و تأیید رمز
+ عبور برابر نیستند:\n```json\n{\n \"status\": \"error\",\n \"code\":
+ \"validation_error\",\n \"status_code\": 400,\n \"message\": \"There
+ were validation errors.\",\n \"errors\": [\n {\n \"field\":
+ \"password_confirmation\",\n \"message\": \"Passwords do not match.\"\n
+ \ }\n ]\n}\n```\n\n### رمز عبور کوتاهتر از 8 کاراکتر است:\n```json\n{\n
+ \ \"status\": \"error\",\n \"code\": \"validation_error\",\n \"status_code\":
+ 400,\n \"message\": \"There were validation errors.\",\n \"errors\":
+ [\n {\n \"field\": \"password\",\n \"message\":
+ \"Password must be at least 8 characters long.\"\n }\n ]\n}\n```\n\n###
+ درخواست نامعتبر (فیلدهای اجباری):\n```json\n{\n \"status\": \"error\",\n
+ \ \"code\": \"validation_error\",\n \"status_code\": 400,\n \"message\":
+ \"There were validation errors.\",\n \"errors\": [\n {\n \"field\":
+ \"fullname\",\n \"message\": \"This field is required.\"\n },\n
+ \ {\n \"field\": \"email\",\n \"message\": \"This
+ field is required.\"\n },\n {\n \"field\": \"password\",\n
+ \ \"message\": \"This field is required.\"\n },\n {\n
+ \ \"field\": \"password_confirmation\",\n \"message\":
+ \"This field is required.\"\n }\n ]\n}\n```\n\n### کاربر یافت نشد:\n```json\n{\n
+ \ \"status\": \"error\",\n \"code\": \"not_found\",\n \"status_code\":
+ 404,\n \"message\": \"The requested resource was not found.\"\n}\n```\n\n###
+ کد تأیید منقضی شده است:\n```json\n{\n \"status\": \"error\",\n \"code\":
+ \"expired_code\",\n \"status_code\": 410,\n \"message\": \"The verification
+ code has expired.\"\n}\n```\n\n### مشکل موقتی در سرور:\n```json\n{\n \"status\":
+ \"error\",\n \"code\": \"service_unavailable\",\n \"status_code\": 500,\n
+ \ \"message\": \"Service temporarily unavailable.\"\n}\n```\n\n---\n\n##
+ \U0001F4A1 نکات مهم:\n1. **رمز عبور:**\n - باید حداقل 8 کاراکتر باشد.\n
+ \ - رمز عبور و تأیید رمز عبور (`password_confirmation`) باید یکسان باشند.\n2.
+ **ایمیل:**\n - باید یک آدرس ایمیل معتبر باشد.\n - ایمیلهای تکراری مجاز
+ نیستند.\n3. **کد OTP:**\n - کد تأیید به ایمیل ارسال میشود و برای وریفای
+ کاربر استفاده میشود.\n4. **فیلدهای اختیاری:**\n - `fcm` و `device_id` در
+ صورت نیاز میتوانند ارسال شوند اما اجباری نیستند.\n\n---\n\n### ولیدیشنها:\n-
+ **ایمیل:**\n - بررسی میشود که در سیستم موجود نباشد.\n - اگر موجود باشد،
+ پیام خطای زیر برگردانده میشود:\n ```json\n {\n \"status\": \"error\",\n
+ \ \"code\": \"validation_error\",\n \"status_code\": 409,\n \"message\":
+ \"There were validation errors.\",\n \"errors\": [\n {\n
+ \ \"field\": \"email\",\n \"message\": \"This
+ email is already registered.\"\n }\n ]\n }\n ```\n-
+ **رمز عبور:**\n - بررسی میشود که حداقل 8 کاراکتر باشد:\n ```json\n {\n
+ \ \"status\": \"error\",\n \"code\": \"validation_error\",\n
+ \ \"status_code\": 400,\n \"message\": \"There were validation
+ errors.\",\n \"errors\": [\n {\n \"field\":
+ \"password\",\n \"message\": \"Password must be at least 8
+ characters long.\"\n }\n ]\n }\n ```\n - بررسی میشود
+ که با `password_confirmation` یکسان باشد:\n ```json\n {\n \"status\":
+ \"error\",\n \"code\": \"validation_error\",\n \"status_code\":
+ 400,\n \"message\": \"There were validation errors.\",\n \"errors\":
+ [\n {\n \"field\": \"password_confirmation\",\n
+ \ \"message\": \"Passwords do not match.\"\n }\n
+ \ ]\n }\n ```\n\n---\n\n## \U0001F4C4 نمونه درخواست:\n\n### درخواست
+ کامل:\n```json\n{\n \"fullname\": \"Jane Doe\",\n \"email\": \"janedoe@example.com\",\n
+ \ \"password\": \"securepassword\",\n \"password_confirmation\": \"securepassword\",\n
+ \ \"fcm\": \"fcm_token_example\",\n \"device_id\": \"device_id_example\"\n}\n```\n\n###
+ پاسخ موفق:\n```json\n{\n \"user\": {\n \"id\": 2,\n \"fullname\":
+ \"Jane Doe\",\n \"email\": \"janedoe@example.com\"\n },\n \"message\":
+ \"The otp code was sent to the user's email\"\n}\n```\n"
+ summary: Register new user
tags:
- v1
requestBody:
@@ -1130,15 +1283,55 @@ paths:
- jwtAuth: []
- {}
responses:
- '201':
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/UserRegister'
- description: ''
+ '202':
+ description: OTP code sent to user email.
/api/v1/account/reset/:
post:
operationId: v1_account_reset_create
+ description: "\n# \U0001F408 Scenario\n\U0001F6E0️ تنظیم مجدد رمز عبور\n\nکاربر
+ پس از تأیید کد بازیابی رمز عبور، میتواند رمز عبور جدید خود را تنظیم کند.
+ برای این کار، کاربر باید رمز عبور جدید و تأیید آن را وارد کند.\n\nبعد از ریکاور
+ و وریفای\nبه این صفحه برای ریست میآید\nکه باید با همان توکنی که در وریفای
+ دریافت کرده است را درخواست کند\n\n(نکته بعد از ریست پسورد توکن ذخیره شده حذف
+ شود و کاربر باید با رمز عبور جدیدی که ست کرده است مجددا لاگین را انجام دهد)\n---\n\n##
+ \U0001F680 درخواست API\n\n### URL:\n```\nPOST /api/reset-password/\n```\n\n###
+ Header:\n| کلید | مقدار |\n|---------------|---------------------------------|\n|
+ Content-Type | application/json |\n| Authorization | Bearer
+ <توکن احراز هویت> |\n\n### Body:\n```json\n{\n \"password\":
+ \"newstrongpassword\",\n \"password_confirmation\": \"newstrongpassword\"\n}\n```\n\n---\n\n##
+ \U0001F4CA پاسخها\n\n| کد وضعیت | توضیحات |\n|---------------|-----------------------------------------------------------|\n|
+ `200` | موفقیتآمیز - رمز عبور با موفقیت تغییر یافت. |\n|
+ `400` | درخواست نادرست - مشکلات مربوط به دادههای ارسالی. |\n|
+ `401` | عدم احراز هویت - کاربر وارد نشده است یا توکن نامعتبر است.
+ \ |\n| `500` | مشکل موقتی در سرور. |\n\n---\n\n##
+ \U0001F4C4 نمونه پاسخ موفقیتآمیز\n\n```json\n{\n \"message\": \"Your password
+ has been changed successfully.\"\n}\n```\n\n---\n\n## \U0001F4C4 نمونه پاسخ
+ خطا\n\n### رمز عبور و تأیید رمز عبور برابر نیستند:\n```json\n{\n \"status\":
+ \"error\",\n \"code\": \"validation_error\",\n \"status_code\": 400,\n
+ \ \"message\": \"Passwords do not match.\"\n}\n```\n\n### رمز عبور کوتاهتر
+ از 8 کاراکتر است:\n```json\n{\n \"status\": \"error\",\n \"code\": \"validation_error\",\n
+ \ \"status_code\": 400,\n \"message\": \"Password must be at least 8
+ characters long.\"\n}\n```\n\n### عدم احراز هویت:\n```json\n{\n \"status\":
+ \"error\",\n \"code\": \"unauthorized\",\n \"status_code\": 401,\n \"message\":
+ \"Authentication credentials were not provided or are invalid.\"\n}\n```\n\n###
+ مشکل موقتی در سرور:\n```json\n{\n \"status\": \"error\",\n \"code\":
+ \"service_unavailable\",\n \"status_code\": 500,\n \"message\": \"Service
+ temporarily unavailable.\"\n}\n```\n\n---\n\n## \U0001F4A1 نکات مهم:\n1. **رمز
+ عبور جدید:**\n - باید حداقل 8 کاراکتر باشد و تأیید رمز عبور (`password_confirmation`)
+ باید با رمز عبور اصلی یکسان باشد.\n2. **امنیت:**\n - کاربر باید توکن احراز
+ هویت معتبر برای تنظیم مجدد رمز عبور ارائه دهد.\n3. **توکن احراز هویت:**\n
+ \ - فقط کاربران احراز هویت شده میتوانند رمز عبور خود را تغییر دهند.\n\n---\n\n##
+ \U0001F527 توضیحات فنی:\n\n### فرآیند تنظیم مجدد رمز عبور:\n1. کاربر باید
+ ابتدا کد بازیابی رمز عبور را تأیید کند.\n2. پس از تأیید موفقیتآمیز، کاربر
+ با استفاده از توکن احراز هویت، رمز عبور جدید و تأیید آن را وارد میکند.\n3.
+ اگر دادهها معتبر باشند، رمز عبور جدید برای کاربر تنظیم میشود.\n4. اگر دادهها
+ نادرست باشند، پیام خطای مناسب به کاربر بازگردانده میشود.\n\n### ولیدیشنها:\n-
+ **رمز عبور:**\n - بررسی میشود که رمز عبور حداقل 8 کاراکتر باشد.\n - بررسی
+ میشود که رمز عبور و تأیید آن یکسان باشند.\n\n---\n\n## \U0001F4C4 نمونه درخواست:\n\n###
+ درخواست کامل:\n```json\n{\n \"password\": \"mynewpassword\",\n \"password_confirmation\":
+ \"mynewpassword\"\n}\n```\n\n### پاسخ موفق:\n```json\n{\n \"message\":
+ \"Your password has been changed successfully.\"\n}\n```\n"
+ summary: Reset user password
tags:
- v1
requestBody:
@@ -1157,8 +1350,8 @@ paths:
- tokenAuth: []
- jwtAuth: []
responses:
- '201':
- description: No response body
+ '200':
+ description: Password changed successfully.
/api/v1/account/update-fcm/:
post:
operationId: v1_account_update_fcm_create
@@ -1188,6 +1381,48 @@ paths:
/api/v1/account/verify/:
post:
operationId: v1_account_verify_create
+ description: "\n# \U0001F408 Scenario\n\U0001F4C5️ تأیید حساب کاربری با کد تأیید\n\nکاربر
+ پس از ثبتنام، باید با استفاده از کد تأییدی که به ایمیل او ارسال شده است،\nحساب
+ کاربری خود را تأیید کند. در این مرحله، کاربر ایمیل و کد تأیید خود را ارسال
+ میکند.\n\n---\n\n## \U0001F680 درخواست API\n\n### URL:\n```\nPOST /api/verify/\n```\n\n###
+ Header:\n| کلید | مقدار |\n|---------------|---------------------------------|\n|
+ Content-Type | application/json |\n| Authorization | Optional
+ (برای این endpoint نیاز نیست) |\n\n### Body:\n```json\n{\n \"email\": \"johndoe@example.com\",\n
+ \ \"code\": \"12345\"\n}\n```\n\n---\n\n## \U0001F4CA پاسخها\n\n| کد وضعیت
+ \ | توضیحات |\n|---------------|-----------------------------------------------------------|\n|
+ `201` | موفقیتآمیز - کاربر تأیید شد و توکن احراز هویت بازگردانده
+ شد. |\n| `400` | درخواست نادرست - مشکلات مربوط به دادههای ارسالی.
+ \ |\n| `404` | کاربر یا کد تأیید یافت نشد. |\n|
+ `410` | کد تأیید منقضی شده است. |\n|
+ `500` | مشکل موقتی در سرور. |\n\n---\n\n##
+ \U0001F4C4 نمونه پاسخ موفقیتآمیز\n\n```json\n{\n \"token\": \"abc123def456\",\n
+ \ \"user_id\": 1,\n \"phone_number\": \"1234567890\",\n \"email\":
+ \"johndoe@example.com\",\n \"fullname\": \"John Doe\",\n \"avatar\":
+ null\n}\n```\n\n---\n\n## \U0001F4C4 نمونه پاسخ خطا\n\n### کد تأیید نادرست:\n```json\n{\n
+ \ \"status\": \"error\",\n \"code\": \"invalid_verification_code\",\n
+ \ \"status_code\": 400,\n \"message\": \"The verification code is invalid.\"\n}\n```\n\n###
+ کد تأیید منقضی شده است:\n```json\n{\n \"status\": \"error\",\n \"code\":
+ \"expired_code\",\n \"status_code\": 410,\n \"message\": \"The verification
+ code has expired.\"\n}\n```\n\n### کاربر یا کد تأیید یافت نشد:\n```json\n{\n
+ \ \"status\": \"error\",\n \"code\": \"not_found\",\n \"status_code\":
+ 404,\n \"message\": \"Verification data not found or expired.\"\n}\n```\n\n###
+ مشکل موقتی در سرور:\n```json\n{\n \"status\": \"error\",\n \"code\":
+ \"service_unavailable\",\n \"status_code\": 500,\n \"message\": \"Service
+ temporarily unavailable.\"\n}\n```\n\n---\n\n## \U0001F4A1 نکات مهم:\n1. **کد
+ تأیید:**\n - کد تأیید باید دقیقاً با کدی که به ایمیل کاربر ارسال شده مطابقت
+ داشته باشد.\n - کد تأیید فقط برای یک مدت محدود اعتبار دارد.\n2. **خطاها:**\n
+ \ - اگر کد تأیید نادرست باشد، پیام مناسب بازگردانده میشود.\n - اگر کد
+ تأیید منقضی شده باشد، کاربر باید درخواست کد جدید کند.\n3. **توکن احراز هویت:**\n
+ \ - پس از تأیید موفقیتآمیز، توکن احراز هویت به کاربر بازگردانده میشود که
+ برای دسترسی به دیگر بخشهای سیستم نیاز است.\n\n---\n\n### ولیدیشنها:\n- **کد
+ تأیید:**\n - باید حداکثر 5 کاراکتر باشد.\n - اگر کد معتبر نباشد یا منقضی
+ شده باشد، پیام خطای مناسب برگردانده میشود.\n\n---\n\n## \U0001F4C4 نمونه
+ درخواست:\n\n### درخواست کامل:\n```json\n{\n \"email\": \"janedoe@example.com\",\n
+ \ \"code\": \"67890\"\n}\n```\n\n### پاسخ موفق:\n```json\n{\n \"token\":
+ \"xyz987uvw654\",\n \"user_id\": 2,\n \"phone_number\": \"0987654321\",\n
+ \ \"email\": \"janedoe@example.com\",\n \"fullname\": \"Jane Doe\",\n
+ \ \"avatar\": null\n}\n```\n"
+ summary: Verify OTP and activate user
tags:
- v1
requestBody:
@@ -1207,14 +1442,12 @@ paths:
- {}
responses:
'201':
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/UserVerify'
- description: ''
+ description: Returns token and user info.
/api/v1/account/web/register/:
post:
operationId: v1_account_web_register_create
+ description: Web registration with password and confirmation.
+ summary: Register web user with password
tags:
- v1
requestBody:
@@ -1233,12 +1466,8 @@ paths:
- jwtAuth: []
- {}
responses:
- '201':
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/WebUserRegister'
- description: ''
+ '202':
+ description: OTP code sent to user email.
/api/v1/analytics/dashboard/:
get:
operationId: v1_analytics_dashboard_retrieve
@@ -1356,13 +1585,13 @@ paths:
- Authentication & Profile
requestBody:
content:
- application/json:
+ multipart/form-data:
schema:
$ref: '#/components/schemas/PatchedUserMeUpdateRequest'
application/x-www-form-urlencoded:
schema:
$ref: '#/components/schemas/PatchedUserMeUpdateRequest'
- multipart/form-data:
+ application/json:
schema:
$ref: '#/components/schemas/PatchedUserMeUpdateRequest'
security:
@@ -1474,32 +1703,27 @@ paths:
/api/v1/auth/token/:
post:
operationId: v1_auth_token_create
- description: |-
- Takes a set of user credentials and returns an access and refresh JSON web
- token pair to prove the authentication of those credentials.
+ description: Takes a set of user credentials (either 'username' or 'email' +
+ password) and returns access & refresh token pair.
tags:
- v1
requestBody:
content:
application/json:
schema:
- $ref: '#/components/schemas/TokenObtainPairRequest'
+ $ref: '#/components/schemas/CustomTokenObtainPairRequest'
application/x-www-form-urlencoded:
schema:
- $ref: '#/components/schemas/TokenObtainPairRequest'
+ $ref: '#/components/schemas/CustomTokenObtainPairRequest'
multipart/form-data:
schema:
- $ref: '#/components/schemas/TokenObtainPairRequest'
+ $ref: '#/components/schemas/CustomTokenObtainPairRequest'
required: true
security:
- jwtAuth: []
responses:
'200':
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/TokenObtainPair'
- description: ''
+ description: No response body
/api/v1/auth/token/refresh/:
post:
operationId: v1_auth_token_refresh_create
@@ -6597,6 +6821,25 @@ components:
- is_completed
- progress_percentage
- total_lessons_count
+ CustomTokenObtainPairRequest:
+ type: object
+ description: JWT token obtain serializer that accepts either 'username' or 'email'
+ as the credential.
+ properties:
+ email:
+ type: string
+ writeOnly: true
+ minLength: 1
+ password:
+ type: string
+ writeOnly: true
+ minLength: 1
+ username:
+ type: string
+ writeOnly: true
+ minLength: 1
+ required:
+ - password
DeviceOsEnum:
enum:
- android
@@ -7480,14 +7723,6 @@ components:
* `registered` - Registered
* `attended` - Attended
* `cancelled` - Cancelled
- ExchangeToken:
- type: object
- properties:
- temp_token:
- type: string
- maxLength: 128
- required:
- - temp_token
ExchangeTokenRequest:
type: object
properties:
@@ -10707,11 +10942,21 @@ components:
title: Full Name
description: Full name of the user.
maxLength: 255
+ first_name:
+ type: string
+ last_name:
+ type: string
+ name:
+ type: string
+ writeOnly: true
phone_number:
type: string
nullable: true
description: e.g., +1 555 1234567
maxLength: 128
+ phone:
+ type: string
+ writeOnly: true
avatar:
type: string
format: binary
@@ -10720,7 +10965,29 @@ components:
type: string
nullable: true
languages: {}
+ preferred_language:
+ type: string
+ writeOnly: true
skills: {}
+ expertise:
+ type: array
+ items:
+ type: string
+ minLength: 1
+ writeOnly: true
+ title:
+ type: string
+ rank:
+ type: string
+ institution:
+ type: string
+ writeOnly: true
+ institution_name:
+ type: string
+ website:
+ type: string
+ address:
+ type: string
country:
type: string
nullable: true
@@ -10746,9 +11013,12 @@ components:
type: string
minLength: 1
description: Firebase Cloud Messaging token.
+ first_name:
+ type: string
+ last_name:
+ type: string
fullname:
type: string
- minLength: 1
avatar:
type: string
nullable: true
@@ -11938,9 +12208,14 @@ components:
type: string
writeOnly: true
minLength: 6
+ first_name:
+ type: string
+ maxLength: 150
+ last_name:
+ type: string
+ maxLength: 150
fullname:
type: string
- minLength: 1
maxLength: 255
phone_number:
type: string
@@ -11969,7 +12244,6 @@ components:
maxLength: 255
required:
- email
- - fullname
- password
RegisterResponse:
type: object
@@ -12723,32 +12997,6 @@ components:
- average_satisfaction_rating
- open_tickets
- total_tickets
- TokenObtainPair:
- type: object
- properties:
- access:
- type: string
- readOnly: true
- refresh:
- type: string
- readOnly: true
- required:
- - access
- - refresh
- TokenObtainPairRequest:
- type: object
- properties:
- email:
- type: string
- writeOnly: true
- minLength: 1
- password:
- type: string
- writeOnly: true
- minLength: 1
- required:
- - email
- - password
TokenRefresh:
type: object
properties:
@@ -12816,26 +13064,6 @@ components:
nullable: true
title: FCM Token
maxLength: 512
- UserGuest:
- type: object
- properties:
- device_id:
- type: string
- fcm:
- type: string
- device_os:
- $ref: '#/components/schemas/DeviceOsEnum'
- lat:
- type: string
- nullable: true
- maxLength: 255
- lon:
- type: string
- nullable: true
- maxLength: 255
- timezone:
- type: string
- nullable: true
UserGuestRequest:
type: object
properties:
@@ -12858,35 +13086,6 @@ components:
timezone:
type: string
nullable: true
- UserLogin:
- type: object
- properties:
- token:
- type: string
- readOnly: true
- nullable: true
- fullname:
- type: string
- readOnly: true
- nullable: true
- avatar:
- type: string
- readOnly: true
- nullable: true
- password:
- type: string
- fcm:
- type: string
- device_id:
- type: string
- timezone:
- type: string
- nullable: true
- required:
- - avatar
- - fullname
- - password
- - token
UserLoginRequest:
type: object
properties:
@@ -12933,11 +13132,23 @@ components:
title: Full Name
description: Full name of the user.
maxLength: 255
+ first_name:
+ type: string
+ readOnly: true
+ last_name:
+ type: string
+ readOnly: true
+ name:
+ type: string
+ readOnly: true
phone_number:
type: string
nullable: true
description: e.g., +1 555 1234567
maxLength: 128
+ phone:
+ type: string
+ readOnly: true
user_type:
allOf:
- $ref: '#/components/schemas/UserTypeEnum'
@@ -12950,7 +13161,32 @@ components:
type: string
nullable: true
languages: {}
+ preferred_language:
+ type: string
+ readOnly: true
skills: {}
+ title:
+ type: string
+ readOnly: true
+ rank:
+ type: string
+ readOnly: true
+ position:
+ type: string
+ readOnly: true
+ institution:
+ type: string
+ readOnly: true
+ institution_id:
+ type: integer
+ nullable: true
+ readOnly: true
+ website:
+ type: string
+ readOnly: true
+ address:
+ type: string
+ readOnly: true
country:
type: string
nullable: true
@@ -12996,17 +13232,29 @@ components:
type: boolean
readOnly: true
required:
+ - address
- date_joined
- email
+ - first_name
- id
+ - institution
+ - institution_id
- is_active
- is_staff
- is_verified
+ - last_name
+ - name
- permissions
+ - phone
+ - position
+ - preferred_language
+ - rank
- roles
+ - title
- user_type
- username
- verification_status
+ - website
UserProfile:
type: object
properties:
@@ -13020,6 +13268,10 @@ components:
fcm:
type: string
description: Firebase Cloud Messaging token.
+ first_name:
+ type: string
+ last_name:
+ type: string
fullname:
type: string
slug:
@@ -13092,9 +13344,12 @@ components:
type: string
minLength: 1
description: Firebase Cloud Messaging token.
+ first_name:
+ type: string
+ last_name:
+ type: string
fullname:
type: string
- minLength: 1
avatar:
type: string
nullable: true
@@ -13129,17 +13384,6 @@ components:
* `male` - Male
* `female` - Female
* `other` - Other
- UserRecoverPassword:
- type: object
- description: |-
- Validates that an email is provided and is in a valid format
- without checking for database uniqueness.
- properties:
- email:
- type: string
- format: email
- required:
- - email
UserRecoverPasswordRequest:
type: object
description: |-
@@ -13152,31 +13396,13 @@ components:
minLength: 1
required:
- email
- UserRegister:
+ UserRegisterRequest:
type: object
properties:
- id:
- type: integer
- readOnly: true
- fullname:
+ first_name:
type: string
- nullable: true
- title: Full Name
- description: Full name of the user.
- maxLength: 255
- email:
+ last_name:
type: string
- format: email
- fcm:
- type: string
- nullable: true
- required:
- - email
- - fullname
- - id
- UserRegisterRequest:
- type: object
- properties:
fullname:
type: string
nullable: true
@@ -13200,7 +13426,6 @@ components:
nullable: true
required:
- email
- - fullname
UserResetPasswordRequest:
type: object
properties:
@@ -13234,21 +13459,6 @@ components:
* `institution_admin` - Institution Admin
* `editor` - Editor
* `viewer` - Viewer
- UserVerify:
- type: object
- properties:
- code:
- type: string
- maxLength: 6
- email:
- type: string
- format: email
- device_id:
- type: string
- maxLength: 255
- required:
- - code
- - email
UserVerifyRequest:
type: object
properties:
@@ -13302,11 +13512,6 @@ components:
readOnly: true
nullable: true
title: Admin Notes / Feedback
- reviewed_by_email:
- type: string
- format: email
- readOnly: true
- nullable: true
uploaded_at:
type: string
format: date-time
@@ -13322,7 +13527,6 @@ components:
- document_type_display
- id
- reviewed_at
- - reviewed_by_email
- status
- status_display
- uploaded_at
@@ -13362,28 +13566,6 @@ components:
* `pending` - Pending Review
* `approved` - Approved & Verified
* `rejected` - Rejected
- WebUserRegister:
- type: object
- properties:
- id:
- type: integer
- readOnly: true
- fullname:
- type: string
- nullable: true
- title: Full Name
- description: Full name of the user.
- maxLength: 255
- email:
- type: string
- format: email
- fcm:
- type: string
- nullable: true
- required:
- - email
- - fullname
- - id
WebUserRegisterRequest:
type: object
properties:
diff --git a/utils/pagination.py b/utils/pagination.py
index 24665a1..6551f81 100644
--- a/utils/pagination.py
+++ b/utils/pagination.py
@@ -5,7 +5,21 @@ from rest_framework.response import Response
class StandardResultsSetPagination(PageNumberPagination):
page_size = 16
page_size_query_param = 'page_size'
- max_page_size = 100
+ max_page_size = 3000
+
+ def get_paginated_response(self, data):
+ return Response({
+ 'count': self.page.paginator.count,
+ 'next': self.get_next_link(),
+ 'previous': self.get_previous_link(),
+ 'results': data,
+ })
+
+
+class InstitutionPagination(PageNumberPagination):
+ page_size = 500
+ page_size_query_param = 'page_size'
+ max_page_size = 3000
def get_paginated_response(self, data):
return Response({