From 7c81b19d969edca7bd040c5292f791d43fc035bf Mon Sep 17 00:00:00 2001 From: sina_sajjadi Date: Tue, 15 Sep 2026 17:27:12 +0330 Subject: [PATCH] feat: add new backend applications including tickets, lms, diplomacy, donations, dynamic forms, and analytics --- apps/analytics/__init__.py | 1 + apps/analytics/apps.py | 8 + apps/analytics/serializers.py | 48 + apps/analytics/tests/__init__.py | 1 + apps/analytics/tests/test_phase9_analytics.py | 108 + apps/analytics/urls.py | 6 + apps/analytics/views.py | 103 + apps/diplomacy/__init__.py | 1 + apps/diplomacy/admin.py | 11 + apps/diplomacy/apps.py | 8 + apps/diplomacy/migrations/0001_initial.py | 42 + apps/diplomacy/migrations/__init__.py | 0 apps/diplomacy/models/__init__.py | 9 + apps/diplomacy/models/report.py | 80 + apps/diplomacy/serializers/__init__.py | 13 + .../serializers/report_serializers.py | 47 + apps/diplomacy/tests/__init__.py | 1 + apps/diplomacy/tests/test_phase9_diplomacy.py | 69 + apps/diplomacy/urls.py | 12 + apps/diplomacy/views/__init__.py | 11 + apps/diplomacy/views/report_views.py | 140 + apps/donations/__init__.py | 1 + apps/donations/admin.py | 27 + apps/donations/apps.py | 8 + apps/donations/migrations/0001_initial.py | 65 + apps/donations/migrations/__init__.py | 0 apps/donations/models/__init__.py | 15 + apps/donations/models/donation.py | 194 + apps/donations/serializers/__init__.py | 13 + .../serializers/donation_serializers.py | 69 + apps/donations/tests/__init__.py | 1 + apps/donations/tests/test_phase9_donations.py | 86 + apps/donations/urls.py | 12 + apps/donations/views/__init__.py | 11 + apps/donations/views/donation_views.py | 141 + apps/dynamic_forms/__init__.py | 1 + apps/dynamic_forms/admin.py | 27 + apps/dynamic_forms/apps.py | 8 + apps/dynamic_forms/migrations/0001_initial.py | 58 + apps/dynamic_forms/migrations/__init__.py | 0 apps/dynamic_forms/models/__init__.py | 11 + apps/dynamic_forms/models/form.py | 127 + apps/dynamic_forms/serializers/__init__.py | 15 + .../serializers/form_serializers.py | 50 + apps/dynamic_forms/tests/__init__.py | 1 + apps/dynamic_forms/tests/test_phase9_forms.py | 94 + apps/dynamic_forms/urls.py | 19 + apps/dynamic_forms/views/__init__.py | 15 + apps/dynamic_forms/views/form_views.py | 177 + apps/lms/__init__.py | 1 + apps/lms/admin.py | 83 + apps/lms/apps.py | 8 + apps/lms/migrations/0001_initial.py | 160 + apps/lms/migrations/__init__.py | 0 apps/lms/models/__init__.py | 25 + apps/lms/models/course.py | 418 ++ apps/lms/permissions.py | 55 + apps/lms/serializers/__init__.py | 35 + apps/lms/serializers/course_serializers.py | 257 + apps/lms/tests/__init__.py | 1 + apps/lms/tests/test_phase8_lms.py | 296 + apps/lms/urls.py | 31 + apps/lms/views/__init__.py | 23 + apps/lms/views/course_views.py | 463 ++ apps/tickets/__init__.py | 1 + apps/tickets/admin.py | 27 + apps/tickets/apps.py | 8 + apps/tickets/migrations/0001_initial.py | 60 + apps/tickets/migrations/__init__.py | 0 apps/tickets/models/__init__.py | 15 + apps/tickets/models/ticket.py | 149 + apps/tickets/permissions.py | 24 + apps/tickets/serializers/__init__.py | 17 + .../tickets/serializers/ticket_serializers.py | 94 + apps/tickets/tests/__init__.py | 1 + apps/tickets/tests/test_phase9_tickets.py | 146 + apps/tickets/urls.py | 17 + apps/tickets/views/__init__.py | 15 + apps/tickets/views/ticket_views.py | 260 + config/settings/base.py | 20 +- config/urls.py | 18 + schema.yml | 6223 +++++++++++++---- .../2026/09/agenda_schedule_SERzRKL.pdf | 1 + .../2026/09/agenda_schedule_elprMLK.pdf | 1 + .../2026/09/agenda_schedule_kMmLXkB.pdf | 1 + .../gallery/2026/09/center_hall_PjFbSf2.jpg | 1 + .../gallery/2026/09/center_hall_ZcZFmJ9.jpg | 1 + .../gallery/2026/09/center_hall_fTt2o2G.jpg | 1 + .../2026/09/bilateral_mou_2026_UvsbCfE.pdf | 1 + .../2026/09/bilateral_mou_2026_pDBspqJ.pdf | 1 + .../2026/09/bilateral_mou_2026_xSM8Gd0.pdf | 1 + .../2026/09/institution_license_8fLXyTj.pdf | 1 + .../2026/09/institution_license_FHSml8p.pdf | 1 + .../2026/09/institution_license_nE7mZTJ.pdf | 1 + 94 files changed, 9489 insertions(+), 1439 deletions(-) create mode 100644 apps/analytics/__init__.py create mode 100644 apps/analytics/apps.py create mode 100644 apps/analytics/serializers.py create mode 100644 apps/analytics/tests/__init__.py create mode 100644 apps/analytics/tests/test_phase9_analytics.py create mode 100644 apps/analytics/urls.py create mode 100644 apps/analytics/views.py create mode 100644 apps/diplomacy/__init__.py create mode 100644 apps/diplomacy/admin.py create mode 100644 apps/diplomacy/apps.py create mode 100644 apps/diplomacy/migrations/0001_initial.py create mode 100644 apps/diplomacy/migrations/__init__.py create mode 100644 apps/diplomacy/models/__init__.py create mode 100644 apps/diplomacy/models/report.py create mode 100644 apps/diplomacy/serializers/__init__.py create mode 100644 apps/diplomacy/serializers/report_serializers.py create mode 100644 apps/diplomacy/tests/__init__.py create mode 100644 apps/diplomacy/tests/test_phase9_diplomacy.py create mode 100644 apps/diplomacy/urls.py create mode 100644 apps/diplomacy/views/__init__.py create mode 100644 apps/diplomacy/views/report_views.py create mode 100644 apps/donations/__init__.py create mode 100644 apps/donations/admin.py create mode 100644 apps/donations/apps.py create mode 100644 apps/donations/migrations/0001_initial.py create mode 100644 apps/donations/migrations/__init__.py create mode 100644 apps/donations/models/__init__.py create mode 100644 apps/donations/models/donation.py create mode 100644 apps/donations/serializers/__init__.py create mode 100644 apps/donations/serializers/donation_serializers.py create mode 100644 apps/donations/tests/__init__.py create mode 100644 apps/donations/tests/test_phase9_donations.py create mode 100644 apps/donations/urls.py create mode 100644 apps/donations/views/__init__.py create mode 100644 apps/donations/views/donation_views.py create mode 100644 apps/dynamic_forms/__init__.py create mode 100644 apps/dynamic_forms/admin.py create mode 100644 apps/dynamic_forms/apps.py create mode 100644 apps/dynamic_forms/migrations/0001_initial.py create mode 100644 apps/dynamic_forms/migrations/__init__.py create mode 100644 apps/dynamic_forms/models/__init__.py create mode 100644 apps/dynamic_forms/models/form.py create mode 100644 apps/dynamic_forms/serializers/__init__.py create mode 100644 apps/dynamic_forms/serializers/form_serializers.py create mode 100644 apps/dynamic_forms/tests/__init__.py create mode 100644 apps/dynamic_forms/tests/test_phase9_forms.py create mode 100644 apps/dynamic_forms/urls.py create mode 100644 apps/dynamic_forms/views/__init__.py create mode 100644 apps/dynamic_forms/views/form_views.py create mode 100644 apps/lms/__init__.py create mode 100644 apps/lms/admin.py create mode 100644 apps/lms/apps.py create mode 100644 apps/lms/migrations/0001_initial.py create mode 100644 apps/lms/migrations/__init__.py create mode 100644 apps/lms/models/__init__.py create mode 100644 apps/lms/models/course.py create mode 100644 apps/lms/permissions.py create mode 100644 apps/lms/serializers/__init__.py create mode 100644 apps/lms/serializers/course_serializers.py create mode 100644 apps/lms/tests/__init__.py create mode 100644 apps/lms/tests/test_phase8_lms.py create mode 100644 apps/lms/urls.py create mode 100644 apps/lms/views/__init__.py create mode 100644 apps/lms/views/course_views.py create mode 100644 apps/tickets/__init__.py create mode 100644 apps/tickets/admin.py create mode 100644 apps/tickets/apps.py create mode 100644 apps/tickets/migrations/0001_initial.py create mode 100644 apps/tickets/migrations/__init__.py create mode 100644 apps/tickets/models/__init__.py create mode 100644 apps/tickets/models/ticket.py create mode 100644 apps/tickets/permissions.py create mode 100644 apps/tickets/serializers/__init__.py create mode 100644 apps/tickets/serializers/ticket_serializers.py create mode 100644 apps/tickets/tests/__init__.py create mode 100644 apps/tickets/tests/test_phase9_tickets.py create mode 100644 apps/tickets/urls.py create mode 100644 apps/tickets/views/__init__.py create mode 100644 apps/tickets/views/ticket_views.py create mode 100644 test_media/chat/attachments/2026/09/agenda_schedule_SERzRKL.pdf create mode 100644 test_media/chat/attachments/2026/09/agenda_schedule_elprMLK.pdf create mode 100644 test_media/chat/attachments/2026/09/agenda_schedule_kMmLXkB.pdf create mode 100644 test_media/institutions/gallery/2026/09/center_hall_PjFbSf2.jpg create mode 100644 test_media/institutions/gallery/2026/09/center_hall_ZcZFmJ9.jpg create mode 100644 test_media/institutions/gallery/2026/09/center_hall_fTt2o2G.jpg create mode 100644 test_media/projects/docs/2026/09/bilateral_mou_2026_UvsbCfE.pdf create mode 100644 test_media/projects/docs/2026/09/bilateral_mou_2026_pDBspqJ.pdf create mode 100644 test_media/projects/docs/2026/09/bilateral_mou_2026_xSM8Gd0.pdf create mode 100644 test_media/users/documents/2026/09/institution_license_8fLXyTj.pdf create mode 100644 test_media/users/documents/2026/09/institution_license_FHSml8p.pdf create mode 100644 test_media/users/documents/2026/09/institution_license_nE7mZTJ.pdf diff --git a/apps/analytics/__init__.py b/apps/analytics/__init__.py new file mode 100644 index 0000000..8dfbe37 --- /dev/null +++ b/apps/analytics/__init__.py @@ -0,0 +1 @@ +# Analytics app diff --git a/apps/analytics/apps.py b/apps/analytics/apps.py new file mode 100644 index 0000000..1f45e09 --- /dev/null +++ b/apps/analytics/apps.py @@ -0,0 +1,8 @@ +from django.apps import AppConfig +from django.utils.translation import gettext_lazy as _ + + +class AnalyticsConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'apps.analytics' + verbose_name = _('Executive Analytics & KPIs') diff --git a/apps/analytics/serializers.py b/apps/analytics/serializers.py new file mode 100644 index 0000000..4d38bd2 --- /dev/null +++ b/apps/analytics/serializers.py @@ -0,0 +1,48 @@ +from rest_framework import serializers + + +class InstitutionsAnalyticsSerializer(serializers.Serializer): + total_institutions = serializers.IntegerField() + verified_institutions = serializers.IntegerField() + countries_count = serializers.IntegerField() + by_type = serializers.DictField(child=serializers.IntegerField()) + top_countries = serializers.ListField(child=serializers.DictField()) + + +class ProjectsAnalyticsSerializer(serializers.Serializer): + total_projects = serializers.IntegerField() + active_projects = serializers.IntegerField() + completed_projects = serializers.IntegerField() + + +class LMSAnalyticsSerializer(serializers.Serializer): + total_courses = serializers.IntegerField() + total_enrollments = serializers.IntegerField() + total_certificates_issued = serializers.IntegerField() + + +class TicketingAnalyticsSerializer(serializers.Serializer): + total_tickets = serializers.IntegerField() + open_tickets = serializers.IntegerField() + answered_or_closed = serializers.IntegerField() + average_satisfaction_rating = serializers.FloatField(allow_null=True) + + +class DonationsAnalyticsSerializer(serializers.Serializer): + active_campaigns_count = serializers.IntegerField() + total_funds_raised_usd = serializers.FloatField() + + +class DiplomacyAnalyticsSerializer(serializers.Serializer): + total_reports_submitted = serializers.IntegerField() + approved_reports_count = serializers.IntegerField() + total_centers_visited = serializers.IntegerField() + + +class ExecutiveDashboardSerializer(serializers.Serializer): + institutions = InstitutionsAnalyticsSerializer() + projects = ProjectsAnalyticsSerializer() + lms = LMSAnalyticsSerializer() + ticketing = TicketingAnalyticsSerializer() + donations = DonationsAnalyticsSerializer() + diplomacy = DiplomacyAnalyticsSerializer() diff --git a/apps/analytics/tests/__init__.py b/apps/analytics/tests/__init__.py new file mode 100644 index 0000000..6a33fcc --- /dev/null +++ b/apps/analytics/tests/__init__.py @@ -0,0 +1 @@ +# Analytics tests diff --git a/apps/analytics/tests/test_phase9_analytics.py b/apps/analytics/tests/test_phase9_analytics.py new file mode 100644 index 0000000..559cc23 --- /dev/null +++ b/apps/analytics/tests/test_phase9_analytics.py @@ -0,0 +1,108 @@ +from decimal import Decimal +from django.test import TestCase +from django.contrib.auth import get_user_model +from rest_framework.test import APIClient +from rest_framework import status + +from apps.profiles.models import Institution +from apps.projects.models import Project +from apps.lms.models import Course, CourseCategory, CourseLevel, CourseLanguage +from apps.tickets.models import Ticket, TicketCategory, TicketPriority, TicketStatus +from apps.donations.models import DonationCampaign, CampaignCategory, CampaignPriority +from apps.diplomacy.models import RepresentativeReport, ReportStatus + +User = get_user_model() + + +class Phase9AnalyticsAPITests(TestCase): + """ + Automated test suite for Phase 9: Executive Analytics Dashboard + - Multi-Module Real-Time Aggregated Metrics + - Breakdown by Institution Type, Project Status, LMS Stats, Ticketing & Donations + """ + + def setUp(self): + self.client = APIClient() + + self.user = User.objects.create_user( + email="analytics.viewer@astan.org", + password="Password123!", + fullname="Executive Observer", + ) + + # Seed data + self.inst = Institution.objects.create( + name="Mashhad Razavi Center", + country="Iran", + city="Mashhad", + type=Institution.InstitutionType.CULTURAL_CENTER, + verification_status=Institution.VerificationStatus.APPROVED, + ) + self.proj = Project.objects.create( + title="Global Interfaith Forum", + owner_institution=self.inst, + status=Project.Status.ACTIVE, + ) + self.course = Course.objects.create( + title="Diplomacy 101", + category=CourseCategory.CULTURAL_DIPLOMACY, + language=CourseLanguage.EN, + level=CourseLevel.INTRODUCTORY, + instructor_name="Prof. Seyed", + description="Introduction to diplomacy.", + is_published=True, + ) + self.ticket = Ticket.objects.create( + user=self.user, + subject="Assistance with registration", + category=TicketCategory.GENERAL, + status=TicketStatus.CLOSED, + satisfaction_rating=5, + ) + self.campaign = DonationCampaign.objects.create( + title="Winter Relief", + description="Relief campaign", + target_amount=Decimal('10000.00'), + raised_amount=Decimal('4500.00'), + category=CampaignCategory.HUMANITARIAN_RELIEF, + priority=CampaignPriority.HIGH, + is_active=True, + ) + self.report = RepresentativeReport.objects.create( + representative=self.user, + title="Monthly Report", + region_hub="Europe", + period="Feb 2026", + activities_summary="Activities summary", + centers_visited_count=6, + status=ReportStatus.APPROVED, + ) + + def test_executive_dashboard_kpis(self): + response = self.client.get("/api/v1/analytics/dashboard/") + self.assertEqual(response.status_code, status.HTTP_200_OK) + + # 1. Institutions + self.assertEqual(response.data["institutions"]["total_institutions"], 1) + self.assertEqual(response.data["institutions"]["verified_institutions"], 1) + self.assertEqual(response.data["institutions"]["countries_count"], 1) + + # 2. Projects + self.assertEqual(response.data["projects"]["total_projects"], 1) + self.assertEqual(response.data["projects"]["active_projects"], 1) + + # 3. LMS + self.assertEqual(response.data["lms"]["total_courses"], 1) + + # 4. Ticketing + self.assertEqual(response.data["ticketing"]["total_tickets"], 1) + self.assertEqual(response.data["ticketing"]["average_satisfaction_rating"], 5.0) + + # 5. Donations + self.assertEqual(response.data["donations"]["active_campaigns_count"], 1) + self.assertEqual(response.data["donations"]["total_funds_raised_usd"], 4500.0) + + # 6. Diplomacy + self.assertEqual(response.data["diplomacy"]["total_reports_submitted"], 1) + self.assertEqual(response.data["diplomacy"]["approved_reports_count"], 1) + self.assertEqual(response.data["diplomacy"]["total_centers_visited"], 6) diff --git a/apps/analytics/urls.py b/apps/analytics/urls.py new file mode 100644 index 0000000..587986a --- /dev/null +++ b/apps/analytics/urls.py @@ -0,0 +1,6 @@ +from django.urls import path +from apps.analytics.views import ExecutiveDashboardView + +urlpatterns = [ + path('dashboard/', ExecutiveDashboardView.as_view(), name='analytics_dashboard'), +] diff --git a/apps/analytics/views.py b/apps/analytics/views.py new file mode 100644 index 0000000..b0de142 --- /dev/null +++ b/apps/analytics/views.py @@ -0,0 +1,103 @@ +import logging +from django.db.models import Count, Avg, Sum +from rest_framework import status +from rest_framework.views import APIView +from rest_framework.generics import GenericAPIView +from rest_framework.permissions import AllowAny, IsAuthenticated +from rest_framework.response import Response +from drf_spectacular.utils import extend_schema + +from apps.profiles.models import Institution +from apps.projects.models import Project +from apps.lms.models import Course, CourseEnrollment, DigitalCertificate +from apps.tickets.models import Ticket, TicketStatus +from apps.donations.models import DonationCampaign +from apps.diplomacy.models import RepresentativeReport, ReportStatus +from apps.analytics.serializers import ExecutiveDashboardSerializer + +logger = logging.getLogger(__name__) + + +class ExecutiveDashboardView(GenericAPIView): + permission_classes = [AllowAny] + serializer_class = ExecutiveDashboardSerializer + + @extend_schema( + summary="Executive KPIs & Global Analytics Dashboard", + description="Aggregates platform KPIs across international institutions, collaborative projects, LMS academy, official ticketing, campaigns, and field diplomacy reports.", + responses={200: ExecutiveDashboardSerializer}, + tags=["Executive Analytics & KPIs"], + ) + def get(self, request, *args, **kwargs): + # 1. Institutions + inst_qs = Institution.objects.all() + total_inst = inst_qs.count() + verified_inst = inst_qs.filter(verification_status=Institution.VerificationStatus.APPROVED).count() + countries_count = inst_qs.values('country').distinct().count() + + type_counts_raw = inst_qs.values('type').annotate(count=Count('id')) + by_type = {item['type']: item['count'] for item in type_counts_raw} + + top_countries_raw = inst_qs.values('country').annotate(count=Count('id')).order_by('-count')[:8] + top_countries = [{'country': item['country'], 'count': item['count']} for item in top_countries_raw] + + institutions_data = { + 'total_institutions': total_inst, + 'verified_institutions': verified_inst, + 'countries_count': countries_count, + 'by_type': by_type, + 'top_countries': top_countries, + } + + # 2. Projects + proj_qs = Project.objects.all() + projects_data = { + 'total_projects': proj_qs.count(), + 'active_projects': proj_qs.filter(status=Project.Status.ACTIVE).count(), + 'completed_projects': proj_qs.filter(status=Project.Status.COMPLETED).count(), + } + + # 3. LMS + lms_data = { + 'total_courses': Course.objects.filter(is_published=True).count(), + 'total_enrollments': CourseEnrollment.objects.count(), + 'total_certificates_issued': DigitalCertificate.objects.filter(is_revoked=False).count(), + } + + # 4. Ticketing + tickets_qs = Ticket.objects.all() + avg_rating = tickets_qs.filter(satisfaction_rating__isnull=False).aggregate(avg=Avg('satisfaction_rating'))['avg'] + ticketing_data = { + 'total_tickets': tickets_qs.count(), + 'open_tickets': tickets_qs.filter(status__in=[TicketStatus.OPEN, TicketStatus.IN_PROGRESS]).count(), + 'answered_or_closed': tickets_qs.filter(status__in=[TicketStatus.ANSWERED, TicketStatus.CLOSED]).count(), + 'average_satisfaction_rating': round(avg_rating, 2) if avg_rating else None, + } + + # 5. Donations + campaigns_qs = DonationCampaign.objects.filter(is_active=True) + total_funds = DonationCampaign.objects.aggregate(total=Sum('raised_amount'))['total'] or 0.0 + donations_data = { + 'active_campaigns_count': campaigns_qs.count(), + 'total_funds_raised_usd': float(total_funds), + } + + # 6. Diplomacy + reports_qs = RepresentativeReport.objects.all() + total_visited = reports_qs.aggregate(total=Sum('centers_visited_count'))['total'] or 0 + diplomacy_data = { + 'total_reports_submitted': reports_qs.count(), + 'approved_reports_count': reports_qs.filter(status=ReportStatus.APPROVED).count(), + 'total_centers_visited': total_visited, + } + + dashboard_data = { + 'institutions': institutions_data, + 'projects': projects_data, + 'lms': lms_data, + 'ticketing': ticketing_data, + 'donations': donations_data, + 'diplomacy': diplomacy_data, + } + + return Response(dashboard_data, status=status.HTTP_200_OK) diff --git a/apps/diplomacy/__init__.py b/apps/diplomacy/__init__.py new file mode 100644 index 0000000..3955baa --- /dev/null +++ b/apps/diplomacy/__init__.py @@ -0,0 +1 @@ +# Diplomacy app diff --git a/apps/diplomacy/admin.py b/apps/diplomacy/admin.py new file mode 100644 index 0000000..08c454f --- /dev/null +++ b/apps/diplomacy/admin.py @@ -0,0 +1,11 @@ +from django.contrib import admin +from unfold.admin import ModelAdmin +from apps.diplomacy.models import RepresentativeReport + + +@admin.register(RepresentativeReport) +class RepresentativeReportAdmin(ModelAdmin): + list_display = ('title', 'representative', 'region_hub', 'period', 'centers_visited_count', 'status', 'reviewed_by', 'created_at') + list_filter = ('status', 'region_hub', 'created_at') + search_fields = ('title', 'activities_summary', 'representative__email', 'representative__fullname', 'region_hub') + readonly_fields = ('created_at', 'updated_at') diff --git a/apps/diplomacy/apps.py b/apps/diplomacy/apps.py new file mode 100644 index 0000000..984468f --- /dev/null +++ b/apps/diplomacy/apps.py @@ -0,0 +1,8 @@ +from django.apps import AppConfig +from django.utils.translation import gettext_lazy as _ + + +class DiplomacyConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'apps.diplomacy' + verbose_name = _('International Diplomacy & Field Representative Reporting') diff --git a/apps/diplomacy/migrations/0001_initial.py b/apps/diplomacy/migrations/0001_initial.py new file mode 100644 index 0000000..3b315f9 --- /dev/null +++ b/apps/diplomacy/migrations/0001_initial.py @@ -0,0 +1,42 @@ +# Generated by Django 4.2.30 on 2026-09-15 13:16 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='RepresentativeReport', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(max_length=255, verbose_name='Report Title')), + ('region_hub', models.CharField(help_text='e.g. East Africa Regional Hub, South Asia Bureau, European Cultural Mission', max_length=150, verbose_name='Regional Hub / Mission Zone')), + ('period', models.CharField(help_text='e.g. Q1 2026, Muharram 1448 AH, March 2026', max_length=100, verbose_name='Reporting Period')), + ('activities_summary', models.TextField(verbose_name='Summary of Activities & Negotiations')), + ('centers_visited_count', models.PositiveIntegerField(default=0, verbose_name='Number of Mosques / Centers Visited')), + ('key_achievements', models.TextField(blank=True, null=True, verbose_name='Key Achievements & Milestones')), + ('challenges_and_needs', models.TextField(blank=True, null=True, verbose_name='Challenges, Risks & Logistical Needs')), + ('attachment', models.FileField(blank=True, null=True, upload_to='diplomacy/reports/%Y/%m/', verbose_name='Official Report Document (PDF / Word)')), + ('status', models.CharField(choices=[('submitted', 'Submitted'), ('under_review', 'Under Review'), ('approved', 'Approved by Headquarters'), ('returned', 'Returned for Revision')], default='submitted', max_length=20, verbose_name='Review Status')), + ('feedback_notes', models.TextField(blank=True, null=True, verbose_name='Headquarters Feedback & Directives')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Submitted At')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated At')), + ('representative', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='diplomacy_reports', to=settings.AUTH_USER_MODEL, verbose_name='Field Representative')), + ('reviewed_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='reviewed_diplomacy_reports', to=settings.AUTH_USER_MODEL, verbose_name='Reviewed By Officer')), + ], + options={ + 'verbose_name': 'Representative Report', + 'verbose_name_plural': 'Representative Reports', + 'ordering': ('-created_at',), + }, + ), + ] diff --git a/apps/diplomacy/migrations/__init__.py b/apps/diplomacy/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/diplomacy/models/__init__.py b/apps/diplomacy/models/__init__.py new file mode 100644 index 0000000..bc9b199 --- /dev/null +++ b/apps/diplomacy/models/__init__.py @@ -0,0 +1,9 @@ +from apps.diplomacy.models.report import ( + RepresentativeReport, + ReportStatus, +) + +__all__ = [ + 'RepresentativeReport', + 'ReportStatus', +] diff --git a/apps/diplomacy/models/report.py b/apps/diplomacy/models/report.py new file mode 100644 index 0000000..53381f1 --- /dev/null +++ b/apps/diplomacy/models/report.py @@ -0,0 +1,80 @@ +from django.conf import settings +from django.db import models +from django.utils.translation import gettext_lazy as _ + + +class ReportStatus(models.TextChoices): + SUBMITTED = 'submitted', _('Submitted') + UNDER_REVIEW = 'under_review', _('Under Review') + APPROVED = 'approved', _('Approved by Headquarters') + RETURNED = 'returned', _('Returned for Revision') + + +class RepresentativeReport(models.Model): + representative = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name='diplomacy_reports', + verbose_name=_('Field Representative') + ) + title = models.CharField(max_length=255, verbose_name=_('Report Title')) + region_hub = models.CharField( + max_length=150, + verbose_name=_('Regional Hub / Mission Zone'), + help_text=_('e.g. East Africa Regional Hub, South Asia Bureau, European Cultural Mission') + ) + period = models.CharField( + max_length=100, + verbose_name=_('Reporting Period'), + help_text=_('e.g. Q1 2026, Muharram 1448 AH, March 2026') + ) + activities_summary = models.TextField(verbose_name=_('Summary of Activities & Negotiations')) + centers_visited_count = models.PositiveIntegerField( + default=0, + verbose_name=_('Number of Mosques / Centers Visited') + ) + key_achievements = models.TextField( + blank=True, + null=True, + verbose_name=_('Key Achievements & Milestones') + ) + challenges_and_needs = models.TextField( + blank=True, + null=True, + verbose_name=_('Challenges, Risks & Logistical Needs') + ) + attachment = models.FileField( + upload_to='diplomacy/reports/%Y/%m/', + blank=True, + null=True, + verbose_name=_('Official Report Document (PDF / Word)') + ) + status = models.CharField( + max_length=20, + choices=ReportStatus.choices, + default=ReportStatus.SUBMITTED, + verbose_name=_('Review Status') + ) + feedback_notes = models.TextField( + blank=True, + null=True, + verbose_name=_('Headquarters Feedback & Directives') + ) + reviewed_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name='reviewed_diplomacy_reports', + verbose_name=_('Reviewed By Officer') + ) + created_at = models.DateTimeField(auto_now_add=True, verbose_name=_('Submitted At')) + updated_at = models.DateTimeField(auto_now=True, verbose_name=_('Updated At')) + + class Meta: + ordering = ('-created_at',) + verbose_name = _('Representative Report') + verbose_name_plural = _('Representative Reports') + + def __str__(self): + return f"{self.title} - {self.representative} ({self.region_hub}, {self.period})" diff --git a/apps/diplomacy/serializers/__init__.py b/apps/diplomacy/serializers/__init__.py new file mode 100644 index 0000000..b17fedc --- /dev/null +++ b/apps/diplomacy/serializers/__init__.py @@ -0,0 +1,13 @@ +from apps.diplomacy.serializers.report_serializers import ( + RepresentativeReportListSerializer, + RepresentativeReportDetailSerializer, + RepresentativeReportCreateSerializer, + RepresentativeReportReviewSerializer, +) + +__all__ = [ + 'RepresentativeReportListSerializer', + 'RepresentativeReportDetailSerializer', + 'RepresentativeReportCreateSerializer', + 'RepresentativeReportReviewSerializer', +] diff --git a/apps/diplomacy/serializers/report_serializers.py b/apps/diplomacy/serializers/report_serializers.py new file mode 100644 index 0000000..ec7e981 --- /dev/null +++ b/apps/diplomacy/serializers/report_serializers.py @@ -0,0 +1,47 @@ +from rest_framework import serializers +from apps.diplomacy.models import RepresentativeReport, ReportStatus + + +class RepresentativeReportListSerializer(serializers.ModelSerializer): + representative_name = serializers.CharField(source='representative.get_full_name', read_only=True) + representative_email = serializers.EmailField(source='representative.email', read_only=True) + + class Meta: + model = RepresentativeReport + fields = [ + 'id', 'title', 'representative_name', + 'representative_email', 'region_hub', 'period', + 'centers_visited_count', 'status', 'created_at', + ] + + +class RepresentativeReportDetailSerializer(serializers.ModelSerializer): + representative_name = serializers.CharField(source='representative.get_full_name', read_only=True) + reviewer_name = serializers.CharField(source='reviewed_by.get_full_name', read_only=True) + + class Meta: + model = RepresentativeReport + fields = [ + 'id', 'title', 'representative', 'representative_name', + 'region_hub', 'period', 'activities_summary', + 'centers_visited_count', 'key_achievements', + 'challenges_and_needs', 'attachment', 'status', + 'feedback_notes', 'reviewed_by', 'reviewer_name', + 'created_at', 'updated_at', + ] + read_only_fields = ['id', 'representative', 'status', 'feedback_notes', 'reviewed_by', 'created_at', 'updated_at'] + + +class RepresentativeReportCreateSerializer(serializers.ModelSerializer): + class Meta: + model = RepresentativeReport + fields = [ + 'id', 'title', 'region_hub', 'period', + 'activities_summary', 'centers_visited_count', + 'key_achievements', 'challenges_and_needs', 'attachment', + ] + + +class RepresentativeReportReviewSerializer(serializers.Serializer): + status = serializers.ChoiceField(choices=ReportStatus.choices) + feedback_notes = serializers.CharField(required=False, allow_blank=True) diff --git a/apps/diplomacy/tests/__init__.py b/apps/diplomacy/tests/__init__.py new file mode 100644 index 0000000..c38f76d --- /dev/null +++ b/apps/diplomacy/tests/__init__.py @@ -0,0 +1 @@ +# Diplomacy tests diff --git a/apps/diplomacy/tests/test_phase9_diplomacy.py b/apps/diplomacy/tests/test_phase9_diplomacy.py new file mode 100644 index 0000000..66fc8cc --- /dev/null +++ b/apps/diplomacy/tests/test_phase9_diplomacy.py @@ -0,0 +1,69 @@ +from django.test import TestCase +from django.contrib.auth import get_user_model +from rest_framework.test import APIClient +from rest_framework import status + +from apps.diplomacy.models import RepresentativeReport, ReportStatus + +User = get_user_model() + + +class Phase9DiplomacyAPITests(TestCase): + """ + Automated test suite for Phase 9: International Diplomacy & Field Reports (Module 6.5) + - Periodic Report Submission by Field Representative + - Report Listing & Region Hub Filtering + - Report Detail Retrieval + - Headquarters Review, Approval, and Feedback Directives + """ + + def setUp(self): + self.client = APIClient() + + self.representative = User.objects.create_user( + email="rep.africa@astan.org", + password="Password123!", + fullname="Hujjat al-Islam Seyed Reza", + user_type=User.UserType.REGIONAL_ADMIN, + ) + self.hq_admin = User.objects.create_user( + email="headquarters@astan.ir", + password="AdminPassword123!", + fullname="Director of International Relations", + user_type=User.UserType.SUPER_ADMIN, + is_staff=True, + ) + + def test_submit_field_activity_report(self): + self.client.force_authenticate(user=self.representative) + + payload = { + "title": "East Africa Q1 2026 Cultural Mission Report", + "region_hub": "East Africa Regional Hub (Kenya & Tanzania)", + "period": "Q1 2026", + "activities_summary": "Conducted bilateral meetings with 14 community leaders and inspected educational library centers in Nairobi and Dar es Salaam.", + "centers_visited_count": 14, + "key_achievements": "Signed preliminary MOU for translation of 5 Razavi books into Swahili.", + "challenges_and_needs": "Logistical support required for audio recording studio equipment.", + } + response = self.client.post("/api/v1/diplomacy/reports/", payload, format="json") + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(response.data["status"], ReportStatus.SUBMITTED) + self.assertEqual(response.data["centers_visited_count"], 14) + report_id = response.data["id"] + + # List reports + list_res = self.client.get("/api/v1/diplomacy/reports/") + self.assertEqual(list_res.status_code, status.HTTP_200_OK) + self.assertEqual(len(list_res.data), 1) + + # Headquarters reviews and approves + self.client.force_authenticate(user=self.hq_admin) + review_payload = { + "status": ReportStatus.APPROVED, + "feedback_notes": "Commendable mission outcomes. Audio studio budget has been allocated for Q2.", + } + review_res = self.client.patch(f"/api/v1/diplomacy/reports/{report_id}/review/", review_payload, format="json") + self.assertEqual(review_res.status_code, status.HTTP_200_OK) + self.assertEqual(review_res.data["status"], ReportStatus.APPROVED) + self.assertEqual(review_res.data["feedback_notes"], review_payload["feedback_notes"]) diff --git a/apps/diplomacy/urls.py b/apps/diplomacy/urls.py new file mode 100644 index 0000000..6db33ec --- /dev/null +++ b/apps/diplomacy/urls.py @@ -0,0 +1,12 @@ +from django.urls import path +from apps.diplomacy.views import ( + RepresentativeReportListCreateView, + RepresentativeReportDetailView, + RepresentativeReportReviewView, +) + +urlpatterns = [ + path('reports/', RepresentativeReportListCreateView.as_view(), name='diplomacy_report_list_create'), + path('reports//', RepresentativeReportDetailView.as_view(), name='diplomacy_report_detail'), + path('reports//review/', RepresentativeReportReviewView.as_view(), name='diplomacy_report_review'), +] diff --git a/apps/diplomacy/views/__init__.py b/apps/diplomacy/views/__init__.py new file mode 100644 index 0000000..301a678 --- /dev/null +++ b/apps/diplomacy/views/__init__.py @@ -0,0 +1,11 @@ +from apps.diplomacy.views.report_views import ( + RepresentativeReportListCreateView, + RepresentativeReportDetailView, + RepresentativeReportReviewView, +) + +__all__ = [ + 'RepresentativeReportListCreateView', + 'RepresentativeReportDetailView', + 'RepresentativeReportReviewView', +] diff --git a/apps/diplomacy/views/report_views.py b/apps/diplomacy/views/report_views.py new file mode 100644 index 0000000..0a5120d --- /dev/null +++ b/apps/diplomacy/views/report_views.py @@ -0,0 +1,140 @@ +import logging +from django.db.models import Q +from django.shortcuts import get_object_or_404 +from django.utils.translation import gettext_lazy as _ +from rest_framework import status +from rest_framework.views import APIView +from rest_framework.generics import GenericAPIView +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework.parsers import MultiPartParser, FormParser, JSONParser +from drf_spectacular.utils import extend_schema, OpenApiResponse, OpenApiParameter + +from apps.diplomacy.models import RepresentativeReport, ReportStatus +from apps.diplomacy.serializers import ( + RepresentativeReportListSerializer, + RepresentativeReportDetailSerializer, + RepresentativeReportCreateSerializer, + RepresentativeReportReviewSerializer, +) + +logger = logging.getLogger(__name__) + + +class RepresentativeReportListCreateView(GenericAPIView): + permission_classes = [IsAuthenticated] + parser_classes = [MultiPartParser, FormParser, JSONParser] + serializer_class = RepresentativeReportListSerializer + queryset = RepresentativeReport.objects.all() + + def get_serializer_class(self): + if self.request.method == 'POST': + return RepresentativeReportCreateSerializer + return RepresentativeReportListSerializer + + def get_queryset(self): + if getattr(self, 'swagger_fake_view', False) or not self.request.user.is_authenticated: + return RepresentativeReport.objects.none() + + user = self.request.user + if user.is_staff or getattr(user, 'is_super_admin', False) or getattr(user, 'is_regional_admin', False): + return RepresentativeReport.objects.all() + return RepresentativeReport.objects.filter(representative=user) + + @extend_schema( + summary="List field representative activity reports", + description="Retrieves periodic reports submitted by regional mission representatives (Module 6.5).", + parameters=[ + OpenApiParameter('status', str, description="Filter by status (submitted, under_review, approved, returned)"), + OpenApiParameter('region_hub', str, description="Filter by regional hub name"), + ], + responses={200: RepresentativeReportListSerializer(many=True)}, + tags=["Diplomacy & Field Reports"], + ) + def get(self, request, *args, **kwargs): + qs = self.get_queryset() + + status_param = request.query_params.get('status') + if status_param: + qs = qs.filter(status=status_param) + + hub = request.query_params.get('region_hub') + if hub: + qs = qs.filter(region_hub__icontains=hub) + + serializer = RepresentativeReportListSerializer(qs, many=True) + return Response(serializer.data, status=status.HTTP_200_OK) + + @extend_schema( + summary="Submit periodic field activity report", + description="Submits a field audit, visits summary, and achievements report for headquarters review.", + request=RepresentativeReportCreateSerializer, + responses={ + 201: RepresentativeReportDetailSerializer, + 400: OpenApiResponse(description="Validation error"), + }, + tags=["Diplomacy & Field Reports"], + ) + def post(self, request, *args, **kwargs): + serializer = RepresentativeReportCreateSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + report = serializer.save(representative=request.user, status=ReportStatus.SUBMITTED) + + return Response(RepresentativeReportDetailSerializer(report).data, status=status.HTTP_201_CREATED) + + +class RepresentativeReportDetailView(GenericAPIView): + permission_classes = [IsAuthenticated] + serializer_class = RepresentativeReportDetailSerializer + queryset = RepresentativeReport.objects.all() + + @extend_schema( + summary="Retrieve representative report details", + description="Returns full report contents, achievements, challenges, attachment, and headquarters feedback.", + responses={ + 200: RepresentativeReportDetailSerializer, + 403: OpenApiResponse(description="Permission denied"), + 404: OpenApiResponse(description="Report not found"), + }, + tags=["Diplomacy & Field Reports"], + ) + def get(self, request, pk, *args, **kwargs): + report = get_object_or_404(RepresentativeReport, pk=pk) + # Check permissions: owner or staff + if not (request.user.is_staff or getattr(request.user, 'is_super_admin', False) or report.representative == request.user): + return Response({'detail': _("Permission denied.")}, status=status.HTTP_403_FORBIDDEN) + + serializer = self.get_serializer(report) + return Response(serializer.data, status=status.HTTP_200_OK) + + +class RepresentativeReportReviewView(GenericAPIView): + permission_classes = [IsAuthenticated] + serializer_class = RepresentativeReportReviewSerializer + queryset = RepresentativeReport.objects.all() + + @extend_schema( + summary="Review and give directives on field report", + description="Approves or returns report with headquarters feedback directives (Staff/Admin only).", + request=RepresentativeReportReviewSerializer, + responses={ + 200: RepresentativeReportDetailSerializer, + 403: OpenApiResponse(description="Permission denied"), + }, + tags=["Diplomacy & Field Reports"], + ) + def patch(self, request, pk, *args, **kwargs): + if not request.user.is_staff and not getattr(request.user, 'is_super_admin', False) and not getattr(request.user, 'is_regional_admin', False): + return Response({'detail': _("Permission denied.")}, status=status.HTTP_403_FORBIDDEN) + + report = get_object_or_404(RepresentativeReport, pk=pk) + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + + report.status = serializer.validated_data['status'] + if 'feedback_notes' in serializer.validated_data: + report.feedback_notes = serializer.validated_data['feedback_notes'] + report.reviewed_by = request.user + report.save() + + return Response(RepresentativeReportDetailSerializer(report).data, status=status.HTTP_200_OK) diff --git a/apps/donations/__init__.py b/apps/donations/__init__.py new file mode 100644 index 0000000..067d476 --- /dev/null +++ b/apps/donations/__init__.py @@ -0,0 +1 @@ +# Donations app diff --git a/apps/donations/admin.py b/apps/donations/admin.py new file mode 100644 index 0000000..80ba094 --- /dev/null +++ b/apps/donations/admin.py @@ -0,0 +1,27 @@ +from django.contrib import admin +from unfold.admin import ModelAdmin, TabularInline +from apps.donations.models import DonationCampaign, DonationTransaction + + +class DonationTransactionInline(TabularInline): + model = DonationTransaction + extra = 0 + fields = ('transaction_reference', 'donor_name', 'amount', 'currency', 'payment_status', 'created_at') + readonly_fields = ('transaction_reference', 'donor_name', 'amount', 'currency', 'payment_status', 'created_at') + + +@admin.register(DonationCampaign) +class DonationCampaignAdmin(ModelAdmin): + list_display = ('title', 'category', 'priority', 'target_amount', 'raised_amount', 'currency', 'is_active', 'is_featured', 'deadline') + list_filter = ('category', 'priority', 'is_active', 'is_featured') + search_fields = ('title', 'description', 'organization_name', 'institution__name') + prepopulated_fields = {'slug': ('title',)} + inlines = [DonationTransactionInline] + + +@admin.register(DonationTransaction) +class DonationTransactionAdmin(ModelAdmin): + list_display = ('transaction_reference', 'campaign', 'donor_name', 'amount', 'currency', 'payment_status', 'created_at') + list_filter = ('payment_status', 'currency', 'created_at') + search_fields = ('transaction_reference', 'donor_name', 'donor_email', 'campaign__title') + readonly_fields = ('transaction_reference', 'created_at') diff --git a/apps/donations/apps.py b/apps/donations/apps.py new file mode 100644 index 0000000..d78c3aa --- /dev/null +++ b/apps/donations/apps.py @@ -0,0 +1,8 @@ +from django.apps import AppConfig +from django.utils.translation import gettext_lazy as _ + + +class DonationsConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'apps.donations' + verbose_name = _('Donations & Charitable Campaigns') diff --git a/apps/donations/migrations/0001_initial.py b/apps/donations/migrations/0001_initial.py new file mode 100644 index 0000000..078ddb1 --- /dev/null +++ b/apps/donations/migrations/0001_initial.py @@ -0,0 +1,65 @@ +# Generated by Django 4.2.30 on 2026-09-15 13:16 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('profiles', '0001_initial'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='DonationCampaign', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(max_length=255, verbose_name='Campaign Title')), + ('slug', models.SlugField(allow_unicode=True, blank=True, max_length=280, null=True, unique=True, verbose_name='Slug / URL Identifier')), + ('description', models.TextField(verbose_name='Campaign Description & Objectives')), + ('target_amount', models.DecimalField(decimal_places=2, max_digits=14, verbose_name='Target Amount')), + ('raised_amount', models.DecimalField(decimal_places=2, default=0.0, max_digits=14, verbose_name='Accumulated Raised Amount')), + ('currency', models.CharField(default='USD', max_length=10, verbose_name='Currency (e.g. USD, EUR, IRR, AED)')), + ('category', models.CharField(choices=[('mosque_construction', 'Mosque & Center Construction / Renovation'), ('humanitarian_relief', 'Humanitarian & Disaster Relief'), ('pilgrim_support', 'Imam Reza (AS) Pilgrim Services'), ('cultural_publications', 'Quran & Islamic Publications Translation'), ('educational_scholarships', 'Seminary & Academic Scholarships'), ('general', 'General Waqf & Endowment')], default='humanitarian_relief', max_length=50, verbose_name='Category')), + ('priority', models.CharField(choices=[('urgent', 'Urgent Appeal'), ('high', 'High Priority'), ('medium', 'Medium Priority')], default='medium', max_length=20, verbose_name='Priority')), + ('cover_image', models.ImageField(blank=True, null=True, upload_to='donations/covers/%Y/%m/', verbose_name='Campaign Image')), + ('organization_name', models.CharField(blank=True, max_length=255, null=True, verbose_name='Managing Foundation Name')), + ('deadline', models.DateField(blank=True, null=True, verbose_name='Campaign Deadline')), + ('is_active', models.BooleanField(default=True, verbose_name='Is Active')), + ('is_featured', models.BooleanField(default=False, verbose_name='Is Featured')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated At')), + ('institution', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='donation_campaigns', to='profiles.institution', verbose_name='Beneficiary Institution / Mosque')), + ], + options={ + 'verbose_name': 'Donation Campaign', + 'verbose_name_plural': 'Donation Campaigns', + 'ordering': ('-is_featured', '-priority', '-created_at'), + }, + ), + migrations.CreateModel( + name='DonationTransaction', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('transaction_reference', models.CharField(blank=True, help_text='e.g. DON-RAZAVI-2026-XYZ123', max_length=100, unique=True, verbose_name='Transaction Reference')), + ('donor_name', models.CharField(default='Anonymous Donor', max_length=255, verbose_name='Donor Display Name')), + ('donor_email', models.EmailField(blank=True, max_length=254, null=True, verbose_name='Donor Email')), + ('amount', models.DecimalField(decimal_places=2, max_digits=12, verbose_name='Donation Amount')), + ('currency', models.CharField(default='USD', max_length=10, verbose_name='Currency')), + ('payment_status', models.CharField(choices=[('completed', 'Completed'), ('pending', 'Pending'), ('failed', 'Failed')], default='completed', max_length=20, verbose_name='Payment Status')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Donated At')), + ('campaign', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='transactions', to='donations.donationcampaign', verbose_name='Campaign')), + ('donor_user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='donations', to=settings.AUTH_USER_MODEL, verbose_name='Donor Account')), + ], + options={ + 'verbose_name': 'Donation Transaction', + 'verbose_name_plural': 'Donation Transactions', + 'ordering': ('-created_at',), + }, + ), + ] diff --git a/apps/donations/migrations/__init__.py b/apps/donations/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/donations/models/__init__.py b/apps/donations/models/__init__.py new file mode 100644 index 0000000..65faf60 --- /dev/null +++ b/apps/donations/models/__init__.py @@ -0,0 +1,15 @@ +from apps.donations.models.donation import ( + DonationCampaign, + DonationTransaction, + CampaignCategory, + CampaignPriority, + PaymentStatus, +) + +__all__ = [ + 'DonationCampaign', + 'DonationTransaction', + 'CampaignCategory', + 'CampaignPriority', + 'PaymentStatus', +] diff --git a/apps/donations/models/donation.py b/apps/donations/models/donation.py new file mode 100644 index 0000000..20c886d --- /dev/null +++ b/apps/donations/models/donation.py @@ -0,0 +1,194 @@ +import random +from django.conf import settings +from django.db import models +from django.utils import timezone +from django.utils.text import slugify +from django.utils.translation import gettext_lazy as _ +from apps.profiles.models import Institution + + +class CampaignCategory(models.TextChoices): + MOSQUE_CONSTRUCTION = 'mosque_construction', _('Mosque & Center Construction / Renovation') + HUMANITARIAN_RELIEF = 'humanitarian_relief', _('Humanitarian & Disaster Relief') + PILGRIM_SUPPORT = 'pilgrim_support', _('Imam Reza (AS) Pilgrim Services') + CULTURAL_PUBLICATIONS = 'cultural_publications', _('Quran & Islamic Publications Translation') + EDUCATIONAL_SCHOLARSHIPS = 'educational_scholarships', _('Seminary & Academic Scholarships') + GENERAL = 'general', _('General Waqf & Endowment') + + +class CampaignPriority(models.TextChoices): + URGENT = 'urgent', _('Urgent Appeal') + HIGH = 'high', _('High Priority') + MEDIUM = 'medium', _('Medium Priority') + + +class PaymentStatus(models.TextChoices): + COMPLETED = 'completed', _('Completed') + PENDING = 'pending', _('Pending') + FAILED = 'failed', _('Failed') + + +class DonationCampaign(models.Model): + title = models.CharField(max_length=255, verbose_name=_('Campaign Title')) + slug = models.SlugField( + max_length=280, + unique=True, + blank=True, + null=True, + allow_unicode=True, + verbose_name=_('Slug / URL Identifier') + ) + description = models.TextField(verbose_name=_('Campaign Description & Objectives')) + target_amount = models.DecimalField( + max_digits=14, + decimal_places=2, + verbose_name=_('Target Amount') + ) + raised_amount = models.DecimalField( + max_digits=14, + decimal_places=2, + default=0.00, + verbose_name=_('Accumulated Raised Amount') + ) + currency = models.CharField( + max_length=10, + default='USD', + verbose_name=_('Currency (e.g. USD, EUR, IRR, AED)') + ) + category = models.CharField( + max_length=50, + choices=CampaignCategory.choices, + default=CampaignCategory.HUMANITARIAN_RELIEF, + verbose_name=_('Category') + ) + priority = models.CharField( + max_length=20, + choices=CampaignPriority.choices, + default=CampaignPriority.MEDIUM, + verbose_name=_('Priority') + ) + cover_image = models.ImageField( + upload_to='donations/covers/%Y/%m/', + blank=True, + null=True, + verbose_name=_('Campaign Image') + ) + institution = models.ForeignKey( + Institution, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name='donation_campaigns', + verbose_name=_('Beneficiary Institution / Mosque') + ) + organization_name = models.CharField( + max_length=255, + blank=True, + null=True, + verbose_name=_('Managing Foundation Name') + ) + deadline = models.DateField( + null=True, + blank=True, + verbose_name=_('Campaign Deadline') + ) + is_active = models.BooleanField(default=True, verbose_name=_('Is Active')) + is_featured = models.BooleanField(default=False, verbose_name=_('Is Featured')) + created_at = models.DateTimeField(auto_now_add=True, verbose_name=_('Created At')) + updated_at = models.DateTimeField(auto_now=True, verbose_name=_('Updated At')) + + class Meta: + ordering = ('-is_featured', '-priority', '-created_at') + verbose_name = _('Donation Campaign') + verbose_name_plural = _('Donation Campaigns') + + def __str__(self): + return f"{self.title} ({self.raised_amount}/{self.target_amount} {self.currency})" + + def save(self, *args, **kwargs): + if not self.slug: + base_slug = slugify(self.title, allow_unicode=True) or f"campaign-{random.randint(1000, 9999)}" + slug = base_slug + counter = 1 + while DonationCampaign.objects.filter(slug=slug).exclude(pk=self.pk).exists(): + slug = f"{base_slug}-{counter}" + counter += 1 + self.slug = slug + super().save(*args, **kwargs) + + @property + def progress_percentage(self) -> int: + if self.target_amount and self.target_amount > 0: + pct = int((self.raised_amount / self.target_amount) * 100) + return min(100, pct) + return 0 + + @property + def donors_count(self) -> int: + return self.transactions.filter(payment_status=PaymentStatus.COMPLETED).count() + + +class DonationTransaction(models.Model): + transaction_reference = models.CharField( + max_length=100, + unique=True, + blank=True, + verbose_name=_('Transaction Reference'), + help_text=_('e.g. DON-RAZAVI-2026-XYZ123') + ) + campaign = models.ForeignKey( + DonationCampaign, + on_delete=models.CASCADE, + related_name='transactions', + verbose_name=_('Campaign') + ) + donor_user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name='donations', + verbose_name=_('Donor Account') + ) + donor_name = models.CharField( + max_length=255, + default='Anonymous Donor', + verbose_name=_('Donor Display Name') + ) + donor_email = models.EmailField( + blank=True, + null=True, + verbose_name=_('Donor Email') + ) + amount = models.DecimalField( + max_digits=12, + decimal_places=2, + verbose_name=_('Donation Amount') + ) + currency = models.CharField( + max_length=10, + default='USD', + verbose_name=_('Currency') + ) + payment_status = models.CharField( + max_length=20, + choices=PaymentStatus.choices, + default=PaymentStatus.COMPLETED, + verbose_name=_('Payment Status') + ) + created_at = models.DateTimeField(auto_now_add=True, verbose_name=_('Donated At')) + + class Meta: + ordering = ('-created_at',) + verbose_name = _('Donation Transaction') + verbose_name_plural = _('Donation Transactions') + + def __str__(self): + return f"{self.transaction_reference} - {self.amount} {self.currency} by {self.donor_name}" + + def save(self, *args, **kwargs): + if not self.transaction_reference: + year = timezone.now().year + rand_code = ''.join(random.choices('ABCDEFGHJKLMNPQRSTUVWXYZ23456789', k=6)) + self.transaction_reference = f"DON-RAZAVI-{year}-{rand_code}" + super().save(*args, **kwargs) diff --git a/apps/donations/serializers/__init__.py b/apps/donations/serializers/__init__.py new file mode 100644 index 0000000..c9f7b28 --- /dev/null +++ b/apps/donations/serializers/__init__.py @@ -0,0 +1,13 @@ +from apps.donations.serializers.donation_serializers import ( + DonationCampaignListSerializer, + DonationCampaignDetailSerializer, + DonationTransactionSerializer, + DonationContributeRequestSerializer, +) + +__all__ = [ + 'DonationCampaignListSerializer', + 'DonationCampaignDetailSerializer', + 'DonationTransactionSerializer', + 'DonationContributeRequestSerializer', +] diff --git a/apps/donations/serializers/donation_serializers.py b/apps/donations/serializers/donation_serializers.py new file mode 100644 index 0000000..559abc6 --- /dev/null +++ b/apps/donations/serializers/donation_serializers.py @@ -0,0 +1,69 @@ +from decimal import Decimal +from rest_framework import serializers +from drf_spectacular.utils import extend_schema_field +from apps.donations.models import ( + DonationCampaign, + DonationTransaction, + CampaignCategory, + CampaignPriority, + PaymentStatus, +) + + +class DonationTransactionSerializer(serializers.ModelSerializer): + campaign_title = serializers.CharField(source='campaign.title', read_only=True) + + class Meta: + model = DonationTransaction + fields = [ + 'id', 'transaction_reference', 'campaign', + 'campaign_title', 'donor_name', 'amount', + 'currency', 'payment_status', 'created_at', + ] + read_only_fields = ['id', 'transaction_reference', 'payment_status', 'created_at'] + + +class DonationCampaignListSerializer(serializers.ModelSerializer): + institution_name = serializers.CharField(source='institution.name', read_only=True) + progress_percentage = serializers.IntegerField(read_only=True) + donors_count = serializers.IntegerField(read_only=True) + + class Meta: + model = DonationCampaign + fields = [ + 'id', 'title', 'slug', 'description', + 'target_amount', 'raised_amount', 'currency', + 'category', 'priority', 'cover_image', + 'institution', 'institution_name', 'organization_name', + 'deadline', 'progress_percentage', 'donors_count', + 'is_featured', 'created_at', + ] + + +class DonationCampaignDetailSerializer(serializers.ModelSerializer): + institution_name = serializers.CharField(source='institution.name', read_only=True) + progress_percentage = serializers.IntegerField(read_only=True) + donors_count = serializers.IntegerField(read_only=True) + recent_donations = serializers.SerializerMethodField() + + class Meta: + model = DonationCampaign + fields = [ + 'id', 'title', 'slug', 'description', + 'target_amount', 'raised_amount', 'currency', + 'category', 'priority', 'cover_image', + 'institution', 'institution_name', 'organization_name', + 'deadline', 'progress_percentage', 'donors_count', + 'is_featured', 'recent_donations', 'created_at', 'updated_at', + ] + + @extend_schema_field(DonationTransactionSerializer(many=True)) + def get_recent_donations(self, obj): + recent = obj.transactions.filter(payment_status=PaymentStatus.COMPLETED)[:5] + return DonationTransactionSerializer(recent, many=True).data + + +class DonationContributeRequestSerializer(serializers.Serializer): + amount = serializers.DecimalField(max_digits=12, decimal_places=2, min_value=Decimal('1.00')) + donor_name = serializers.CharField(required=False, default='Anonymous Donor', allow_blank=True) + donor_email = serializers.EmailField(required=False, allow_blank=True, allow_null=True) diff --git a/apps/donations/tests/__init__.py b/apps/donations/tests/__init__.py new file mode 100644 index 0000000..1d62bc8 --- /dev/null +++ b/apps/donations/tests/__init__.py @@ -0,0 +1 @@ +# Donations tests diff --git a/apps/donations/tests/test_phase9_donations.py b/apps/donations/tests/test_phase9_donations.py new file mode 100644 index 0000000..3f13054 --- /dev/null +++ b/apps/donations/tests/test_phase9_donations.py @@ -0,0 +1,86 @@ +from decimal import Decimal +from django.test import TestCase +from django.contrib.auth import get_user_model +from rest_framework.test import APIClient +from rest_framework import status + +from apps.profiles.models import Institution +from apps.donations.models import DonationCampaign, DonationTransaction, CampaignCategory, CampaignPriority, PaymentStatus + +User = get_user_model() + + +class Phase9DonationsAPITests(TestCase): + """ + Automated test suite for Phase 9: Donations & Financial Campaigns + - Campaign Listing & Filtering + - Campaign Detail & Progress Calculation + - Charitable Contribution Flow & Automatic Balance Increment + """ + + def setUp(self): + self.client = APIClient() + + self.donor = User.objects.create_user( + email="donor@charitynet.org", + password="Password123!", + fullname="Haj Ahmad Karimi", + ) + self.institution = Institution.objects.create( + name="Imam Reza Humanitarian Center", + country="Afghanistan", + city="Herat", + type=Institution.InstitutionType.CHARITY, + ) + self.campaign = DonationCampaign.objects.create( + title="Clean Water Wells & Winter Food Baskets for Displaced Families", + description="Emergency humanitarian appeal to construct 15 solar-powered clean water wells.", + target_amount=Decimal('50000.00'), + raised_amount=Decimal('12500.00'), + currency='USD', + category=CampaignCategory.HUMANITARIAN_RELIEF, + priority=CampaignPriority.URGENT, + institution=self.institution, + organization_name="Astan Relief Foundation", + is_active=True, + is_featured=True, + ) + + def test_campaign_listing_and_filtering(self): + # 1. List + response = self.client.get("/api/v1/donations/campaigns/") + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(len(response.data), 1) + self.assertEqual(response.data[0]["title"], self.campaign.title) + self.assertEqual(response.data[0]["progress_percentage"], 25) + + # 2. Filter by category + res_cat = self.client.get(f"/api/v1/donations/campaigns/?category={CampaignCategory.HUMANITARIAN_RELIEF}") + self.assertEqual(res_cat.status_code, status.HTTP_200_OK) + self.assertEqual(len(res_cat.data), 1) + + def test_campaign_detail_view(self): + response = self.client.get(f"/api/v1/donations/campaigns/{self.campaign.slug}/") + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["id"], self.campaign.id) + self.assertEqual(response.data["target_amount"], "50000.00") + self.assertEqual(response.data["progress_percentage"], 25) + + def test_donation_contribution_flow(self): + self.client.force_authenticate(user=self.donor) + + payload = { + "amount": 2500.00, + "donor_name": "Haj Ahmad Karimi", + } + response = self.client.post(f"/api/v1/donations/campaigns/{self.campaign.slug}/contribute/", payload, format="json") + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertTrue(response.data["transaction_reference"].startswith("DON-RAZAVI-")) + self.assertEqual(response.data["amount"], "2500.00") + self.assertEqual(response.data["payment_status"], PaymentStatus.COMPLETED) + + # Check campaign raised amount incremented: 12500 + 2500 = 15000 + self.campaign.refresh_from_db() + self.assertEqual(self.campaign.raised_amount, Decimal('15000.00')) + self.assertEqual(self.campaign.progress_percentage, 30) + self.assertEqual(self.campaign.donors_count, 1) diff --git a/apps/donations/urls.py b/apps/donations/urls.py new file mode 100644 index 0000000..645719e --- /dev/null +++ b/apps/donations/urls.py @@ -0,0 +1,12 @@ +from django.urls import path +from apps.donations.views import ( + DonationCampaignListView, + DonationCampaignDetailView, + DonationContributeView, +) + +urlpatterns = [ + path('campaigns/', DonationCampaignListView.as_view(), name='donation_campaign_list'), + path('campaigns//', DonationCampaignDetailView.as_view(), name='donation_campaign_detail'), + path('campaigns//contribute/', DonationContributeView.as_view(), name='donation_contribute'), +] diff --git a/apps/donations/views/__init__.py b/apps/donations/views/__init__.py new file mode 100644 index 0000000..23c646f --- /dev/null +++ b/apps/donations/views/__init__.py @@ -0,0 +1,11 @@ +from apps.donations.views.donation_views import ( + DonationCampaignListView, + DonationCampaignDetailView, + DonationContributeView, +) + +__all__ = [ + 'DonationCampaignListView', + 'DonationCampaignDetailView', + 'DonationContributeView', +] diff --git a/apps/donations/views/donation_views.py b/apps/donations/views/donation_views.py new file mode 100644 index 0000000..687bfea --- /dev/null +++ b/apps/donations/views/donation_views.py @@ -0,0 +1,141 @@ +import logging +from decimal import Decimal +from django.db.models import Q, F +from django.shortcuts import get_object_or_404 +from django.utils.translation import gettext_lazy as _ +from rest_framework import status +from rest_framework.views import APIView +from rest_framework.generics import GenericAPIView +from rest_framework.permissions import AllowAny, IsAuthenticated +from rest_framework.response import Response +from drf_spectacular.utils import extend_schema, OpenApiResponse, OpenApiParameter + +from apps.donations.models import DonationCampaign, DonationTransaction, PaymentStatus +from apps.donations.serializers import ( + DonationCampaignListSerializer, + DonationCampaignDetailSerializer, + DonationTransactionSerializer, + DonationContributeRequestSerializer, +) + +logger = logging.getLogger(__name__) + + +def get_campaign_by_pk_or_slug(pk_or_slug, queryset=None): + if queryset is None: + queryset = DonationCampaign.objects.all() + if str(pk_or_slug).isdigit(): + return get_object_or_404(queryset, pk=int(pk_or_slug)) + return get_object_or_404(queryset, slug=pk_or_slug) + + +class DonationCampaignListView(GenericAPIView): + permission_classes = [AllowAny] + serializer_class = DonationCampaignListSerializer + queryset = DonationCampaign.objects.filter(is_active=True) + + @extend_schema( + summary="List active donation appeals & campaigns", + description="Retrieves charitable campaigns with target, raised amounts, progress percentages, and filters.", + parameters=[ + OpenApiParameter('category', str, description="Filter by campaign category"), + OpenApiParameter('priority', str, description="Filter by priority (urgent, high, medium)"), + OpenApiParameter('search', str, description="Search in campaign title or description"), + OpenApiParameter('featured', bool, description="Filter featured campaigns"), + ], + responses={200: DonationCampaignListSerializer(many=True)}, + tags=["Donations & Financial Campaigns"], + ) + def get(self, request, *args, **kwargs): + qs = DonationCampaign.objects.all() + if not (request.user and request.user.is_authenticated and request.user.is_staff): + qs = qs.filter(is_active=True) + + category = request.query_params.get('category') + if category: + qs = qs.filter(category=category) + + priority = request.query_params.get('priority') + if priority: + qs = qs.filter(priority=priority) + + featured = request.query_params.get('featured') + if featured is not None: + qs = qs.filter(is_featured=featured.lower() in ('true', '1')) + + search = request.query_params.get('search') + if search: + qs = qs.filter(Q(title__icontains=search) | Q(description__icontains=search)) + + serializer = self.get_serializer(qs, many=True) + return Response(serializer.data, status=status.HTTP_200_OK) + + +class DonationCampaignDetailView(GenericAPIView): + permission_classes = [AllowAny] + serializer_class = DonationCampaignDetailSerializer + queryset = DonationCampaign.objects.all() + + @extend_schema( + summary="Retrieve campaign details & recent contributions", + description="Returns detailed campaign goal, beneficiary center, progress rate, and recent donors.", + responses={ + 200: DonationCampaignDetailSerializer, + 404: OpenApiResponse(description="Campaign not found"), + }, + tags=["Donations & Financial Campaigns"], + ) + def get(self, request, pk_or_slug, *args, **kwargs): + campaign = get_campaign_by_pk_or_slug(pk_or_slug, self.get_queryset()) + serializer = self.get_serializer(campaign) + return Response(serializer.data, status=status.HTTP_200_OK) + + +class DonationContributeView(GenericAPIView): + permission_classes = [AllowAny] + serializer_class = DonationContributeRequestSerializer + queryset = DonationCampaign.objects.all() + + @extend_schema( + summary="Make a charitable contribution to campaign", + description="Processes a donation contribution, persists transaction, and increments campaign accumulated funds.", + request=DonationContributeRequestSerializer, + responses={ + 201: DonationTransactionSerializer, + 400: OpenApiResponse(description="Validation error"), + 404: OpenApiResponse(description="Campaign not found"), + }, + tags=["Donations & Financial Campaigns"], + ) + def post(self, request, pk_or_slug, *args, **kwargs): + campaign = get_campaign_by_pk_or_slug(pk_or_slug, self.get_queryset()) + + if not campaign.is_active: + return Response({'error': _("This campaign is currently inactive or closed.")}, status=status.HTTP_400_BAD_REQUEST) + + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + + amount = serializer.validated_data['amount'] + donor_name = serializer.validated_data.get('donor_name') or 'Anonymous Donor' + donor_email = serializer.validated_data.get('donor_email') + + user = request.user if (request.user and request.user.is_authenticated) else None + if user and not serializer.validated_data.get('donor_name'): + donor_name = user.get_full_name() or user.email + + transaction = DonationTransaction.objects.create( + campaign=campaign, + donor_user=user, + donor_name=donor_name, + donor_email=donor_email or (user.email if user else None), + amount=amount, + currency=campaign.currency, + payment_status=PaymentStatus.COMPLETED, + ) + + # Increment campaign raised amount + campaign.raised_amount = (campaign.raised_amount or Decimal('0.00')) + amount + campaign.save() + + return Response(DonationTransactionSerializer(transaction).data, status=status.HTTP_201_CREATED) diff --git a/apps/dynamic_forms/__init__.py b/apps/dynamic_forms/__init__.py new file mode 100644 index 0000000..7eca811 --- /dev/null +++ b/apps/dynamic_forms/__init__.py @@ -0,0 +1 @@ +# Dynamic forms app diff --git a/apps/dynamic_forms/admin.py b/apps/dynamic_forms/admin.py new file mode 100644 index 0000000..739c0e9 --- /dev/null +++ b/apps/dynamic_forms/admin.py @@ -0,0 +1,27 @@ +from django.contrib import admin +from unfold.admin import ModelAdmin, TabularInline +from apps.dynamic_forms.models import FormDefinition, FormResponse + + +class FormResponseInline(TabularInline): + model = FormResponse + extra = 0 + fields = ('user', 'workflow_status', 'created_at') + readonly_fields = ('created_at',) + + +@admin.register(FormDefinition) +class FormDefinitionAdmin(ModelAdmin): + list_display = ('title', 'slug', 'is_active', 'is_public', 'target_role', 'created_at') + list_filter = ('is_active', 'is_public', 'created_at') + search_fields = ('title', 'description', 'slug') + prepopulated_fields = {'slug': ('title',)} + inlines = [FormResponseInline] + + +@admin.register(FormResponse) +class FormResponseAdmin(ModelAdmin): + list_display = ('form', 'user', 'workflow_status', 'reviewed_by', 'created_at') + list_filter = ('workflow_status', 'form', 'created_at') + search_fields = ('form__title', 'user__email', 'user__fullname', 'admin_notes') + readonly_fields = ('created_at', 'updated_at') diff --git a/apps/dynamic_forms/apps.py b/apps/dynamic_forms/apps.py new file mode 100644 index 0000000..93065da --- /dev/null +++ b/apps/dynamic_forms/apps.py @@ -0,0 +1,8 @@ +from django.apps import AppConfig +from django.utils.translation import gettext_lazy as _ + + +class DynamicFormsConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'apps.dynamic_forms' + verbose_name = _('Dynamic Custom Forms & Workflow Engine') diff --git a/apps/dynamic_forms/migrations/0001_initial.py b/apps/dynamic_forms/migrations/0001_initial.py new file mode 100644 index 0000000..c4f369b --- /dev/null +++ b/apps/dynamic_forms/migrations/0001_initial.py @@ -0,0 +1,58 @@ +# Generated by Django 4.2.30 on 2026-09-15 13:16 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='FormDefinition', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(max_length=255, verbose_name='Form Title')), + ('slug', models.SlugField(allow_unicode=True, blank=True, max_length=280, null=True, unique=True, verbose_name='Slug / URL Identifier')), + ('description', models.TextField(blank=True, null=True, verbose_name='Instructions & Description')), + ('schema', models.JSONField(default=list, help_text='Array of field definitions: [{"id": "applicant_name", "label": "Full Name", "type": "text", "required": true}]', verbose_name='Form Fields Schema (JSON)')), + ('is_active', models.BooleanField(default=True, verbose_name='Is Active')), + ('is_public', models.BooleanField(default=False, verbose_name='Is Publicly Accessible (No Login Required)')), + ('target_role', models.CharField(blank=True, max_length=50, null=True, verbose_name='Target User Role Restriction')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated At')), + ('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_forms', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), + ], + options={ + 'verbose_name': 'Form Definition', + 'verbose_name_plural': 'Form Definitions', + 'ordering': ('-created_at',), + }, + ), + migrations.CreateModel( + name='FormResponse', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('answers', models.JSONField(default=dict, verbose_name='Submitted Form Answers')), + ('attachment', models.FileField(blank=True, null=True, upload_to='forms/attachments/%Y/%m/', verbose_name='Uploaded Attachment')), + ('workflow_status', models.CharField(choices=[('submitted', 'Submitted'), ('under_review', 'Under Review'), ('approved', 'Approved'), ('rejected', 'Rejected')], default='submitted', max_length=20, verbose_name='Workflow Status')), + ('admin_notes', models.TextField(blank=True, null=True, verbose_name='Reviewer / Admin Notes')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Submitted At')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated At')), + ('form', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='responses', to='dynamic_forms.formdefinition', verbose_name='Form')), + ('reviewed_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='reviewed_form_responses', to=settings.AUTH_USER_MODEL, verbose_name='Reviewed By')), + ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='form_responses', to=settings.AUTH_USER_MODEL, verbose_name='Submitter')), + ], + options={ + 'verbose_name': 'Form Response', + 'verbose_name_plural': 'Form Responses', + 'ordering': ('-created_at',), + }, + ), + ] diff --git a/apps/dynamic_forms/migrations/__init__.py b/apps/dynamic_forms/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/dynamic_forms/models/__init__.py b/apps/dynamic_forms/models/__init__.py new file mode 100644 index 0000000..c7fbabd --- /dev/null +++ b/apps/dynamic_forms/models/__init__.py @@ -0,0 +1,11 @@ +from apps.dynamic_forms.models.form import ( + FormDefinition, + FormResponse, + WorkflowStatus, +) + +__all__ = [ + 'FormDefinition', + 'FormResponse', + 'WorkflowStatus', +] diff --git a/apps/dynamic_forms/models/form.py b/apps/dynamic_forms/models/form.py new file mode 100644 index 0000000..065c34c --- /dev/null +++ b/apps/dynamic_forms/models/form.py @@ -0,0 +1,127 @@ +import random +from django.conf import settings +from django.db import models +from django.utils.text import slugify +from django.utils.translation import gettext_lazy as _ + + +class WorkflowStatus(models.TextChoices): + SUBMITTED = 'submitted', _('Submitted') + UNDER_REVIEW = 'under_review', _('Under Review') + APPROVED = 'approved', _('Approved') + REJECTED = 'rejected', _('Rejected') + + +class FormDefinition(models.Model): + title = models.CharField(max_length=255, verbose_name=_('Form Title')) + slug = models.SlugField( + max_length=280, + unique=True, + blank=True, + null=True, + allow_unicode=True, + verbose_name=_('Slug / URL Identifier') + ) + description = models.TextField(blank=True, null=True, verbose_name=_('Instructions & Description')) + schema = models.JSONField( + default=list, + verbose_name=_('Form Fields Schema (JSON)'), + help_text=_('Array of field definitions: [{"id": "applicant_name", "label": "Full Name", "type": "text", "required": true}]') + ) + is_active = models.BooleanField(default=True, verbose_name=_('Is Active')) + is_public = models.BooleanField( + default=False, + verbose_name=_('Is Publicly Accessible (No Login Required)') + ) + target_role = models.CharField( + max_length=50, + blank=True, + null=True, + verbose_name=_('Target User Role Restriction') + ) + created_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name='created_forms', + verbose_name=_('Created By') + ) + created_at = models.DateTimeField(auto_now_add=True, verbose_name=_('Created At')) + updated_at = models.DateTimeField(auto_now=True, verbose_name=_('Updated At')) + + class Meta: + ordering = ('-created_at',) + verbose_name = _('Form Definition') + verbose_name_plural = _('Form Definitions') + + def __str__(self): + return f"{self.title} ({'Active' if self.is_active else 'Inactive'})" + + def save(self, *args, **kwargs): + if not self.slug: + base_slug = slugify(self.title, allow_unicode=True) or f"form-{random.randint(1000, 9999)}" + slug = base_slug + counter = 1 + while FormDefinition.objects.filter(slug=slug).exclude(pk=self.pk).exists(): + slug = f"{base_slug}-{counter}" + counter += 1 + self.slug = slug + super().save(*args, **kwargs) + + +class FormResponse(models.Model): + form = models.ForeignKey( + FormDefinition, + on_delete=models.CASCADE, + related_name='responses', + verbose_name=_('Form') + ) + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name='form_responses', + verbose_name=_('Submitter') + ) + answers = models.JSONField( + default=dict, + verbose_name=_('Submitted Form Answers') + ) + attachment = models.FileField( + upload_to='forms/attachments/%Y/%m/', + blank=True, + null=True, + verbose_name=_('Uploaded Attachment') + ) + workflow_status = models.CharField( + max_length=20, + choices=WorkflowStatus.choices, + default=WorkflowStatus.SUBMITTED, + verbose_name=_('Workflow Status') + ) + admin_notes = models.TextField( + blank=True, + null=True, + verbose_name=_('Reviewer / Admin Notes') + ) + reviewed_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name='reviewed_form_responses', + verbose_name=_('Reviewed By') + ) + created_at = models.DateTimeField(auto_now_add=True, verbose_name=_('Submitted At')) + updated_at = models.DateTimeField(auto_now=True, verbose_name=_('Updated At')) + + class Meta: + ordering = ('-created_at',) + verbose_name = _('Form Response') + verbose_name_plural = _('Form Responses') + + def __str__(self): + submitter_str = self.user.get_full_name() if self.user else "Anonymous / Guest" + return f"{self.form.title} Submission by {submitter_str} ({self.get_workflow_status_display()})" diff --git a/apps/dynamic_forms/serializers/__init__.py b/apps/dynamic_forms/serializers/__init__.py new file mode 100644 index 0000000..066619e --- /dev/null +++ b/apps/dynamic_forms/serializers/__init__.py @@ -0,0 +1,15 @@ +from apps.dynamic_forms.serializers.form_serializers import ( + FormDefinitionListSerializer, + FormDefinitionDetailSerializer, + FormResponseSerializer, + FormResponseSubmitSerializer, + FormResponseReviewSerializer, +) + +__all__ = [ + 'FormDefinitionListSerializer', + 'FormDefinitionDetailSerializer', + 'FormResponseSerializer', + 'FormResponseSubmitSerializer', + 'FormResponseReviewSerializer', +] diff --git a/apps/dynamic_forms/serializers/form_serializers.py b/apps/dynamic_forms/serializers/form_serializers.py new file mode 100644 index 0000000..dcdfa67 --- /dev/null +++ b/apps/dynamic_forms/serializers/form_serializers.py @@ -0,0 +1,50 @@ +from rest_framework import serializers +from apps.dynamic_forms.models import FormDefinition, FormResponse, WorkflowStatus + + +class FormDefinitionListSerializer(serializers.ModelSerializer): + responses_count = serializers.IntegerField(source='responses.count', read_only=True) + + class Meta: + model = FormDefinition + fields = [ + 'id', 'title', 'slug', 'description', + 'is_active', 'is_public', 'target_role', + 'responses_count', 'created_at', + ] + + +class FormDefinitionDetailSerializer(serializers.ModelSerializer): + class Meta: + model = FormDefinition + fields = [ + 'id', 'title', 'slug', 'description', + 'schema', 'is_active', 'is_public', + 'target_role', 'created_at', 'updated_at', + ] + + +class FormResponseSerializer(serializers.ModelSerializer): + form_title = serializers.CharField(source='form.title', read_only=True) + submitter_name = serializers.CharField(source='user.get_full_name', read_only=True) + reviewer_name = serializers.CharField(source='reviewed_by.get_full_name', read_only=True) + + class Meta: + model = FormResponse + fields = [ + 'id', 'form', 'form_title', 'user', + 'submitter_name', 'answers', 'attachment', + 'workflow_status', 'admin_notes', 'reviewed_by', + 'reviewer_name', 'created_at', 'updated_at', + ] + read_only_fields = ['id', 'user', 'workflow_status', 'admin_notes', 'reviewed_by', 'created_at'] + + +class FormResponseSubmitSerializer(serializers.Serializer): + answers = serializers.JSONField(help_text="Dictionary of question IDs and answers") + attachment = serializers.FileField(required=False, allow_null=True) + + +class FormResponseReviewSerializer(serializers.Serializer): + workflow_status = serializers.ChoiceField(choices=WorkflowStatus.choices) + admin_notes = serializers.CharField(required=False, allow_blank=True) diff --git a/apps/dynamic_forms/tests/__init__.py b/apps/dynamic_forms/tests/__init__.py new file mode 100644 index 0000000..5cf98bd --- /dev/null +++ b/apps/dynamic_forms/tests/__init__.py @@ -0,0 +1 @@ +# Dynamic forms tests diff --git a/apps/dynamic_forms/tests/test_phase9_forms.py b/apps/dynamic_forms/tests/test_phase9_forms.py new file mode 100644 index 0000000..7c67bd9 --- /dev/null +++ b/apps/dynamic_forms/tests/test_phase9_forms.py @@ -0,0 +1,94 @@ +from django.test import TestCase +from django.contrib.auth import get_user_model +from rest_framework.test import APIClient +from rest_framework import status + +from apps.dynamic_forms.models import FormDefinition, FormResponse, WorkflowStatus + +User = get_user_model() + + +class Phase9DynamicFormsAPITests(TestCase): + """ + Automated test suite for Phase 9: Dynamic Forms & Surveys Engine + - Form Creation with JSON Schema Definition + - Form Listing and Detail + - Form Response Submission (Authenticated and Guest) + - Reviewing and Updating Form Response Workflow Status + """ + + def setUp(self): + self.client = APIClient() + + self.admin_user = User.objects.create_user( + email="admin.forms@astan.org", + password="AdminPassword123!", + fullname="System Administrator", + user_type=User.UserType.SUPER_ADMIN, + is_staff=True, + ) + self.applicant = User.objects.create_user( + email="applicant@shiahub.org", + password="UserPassword123!", + fullname="Fatima Zahra", + user_type=User.UserType.CLIENT, + ) + + self.form_def = FormDefinition.objects.create( + title="Annual Islamic Center Activity Audit", + description="Please provide complete metrics regarding your center's cultural and educational programs.", + schema=[ + {"id": "annual_attendees", "label": "Annual Estimated Attendees", "type": "number", "required": True}, + {"id": "programs_held", "label": "Number of Programs Held", "type": "number", "required": True}, + {"id": "primary_language", "label": "Primary Language of Congregation", "type": "text", "required": True}, + {"id": "has_library", "label": "Does center maintain a library?", "type": "boolean", "required": False}, + ], + is_active=True, + is_public=False, + created_by=self.admin_user, + ) + + def test_form_list_and_detail(self): + # 1. List + response = self.client.get("/api/v1/forms/") + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(len(response.data), 1) + self.assertEqual(response.data[0]["title"], self.form_def.title) + + # 2. Detail + detail_res = self.client.get(f"/api/v1/forms/{self.form_def.slug}/") + self.assertEqual(detail_res.status_code, status.HTTP_200_OK) + self.assertEqual(len(detail_res.data["schema"]), 4) + + def test_form_response_submission_and_workflow(self): + self.client.force_authenticate(user=self.applicant) + + # 1. Submit response + payload = { + "answers": { + "annual_attendees": 12500, + "programs_held": 48, + "primary_language": "Arabic", + "has_library": True, + } + } + submit_res = self.client.post(f"/api/v1/forms/{self.form_def.slug}/responses/", payload, format="json") + self.assertEqual(submit_res.status_code, status.HTTP_201_CREATED) + self.assertEqual(submit_res.data["workflow_status"], WorkflowStatus.SUBMITTED) + response_id = submit_res.data["id"] + + # 2. Check submission list + list_res = self.client.get(f"/api/v1/forms/{self.form_def.slug}/submissions/") + self.assertEqual(list_res.status_code, status.HTTP_200_OK) + self.assertEqual(len(list_res.data), 1) + + # 3. Admin reviews response + self.client.force_authenticate(user=self.admin_user) + review_payload = { + "workflow_status": WorkflowStatus.APPROVED, + "admin_notes": "Audit approved and entered into global database.", + } + review_res = self.client.patch(f"/api/v1/forms/responses/{response_id}/review/", review_payload, format="json") + self.assertEqual(review_res.status_code, status.HTTP_200_OK) + self.assertEqual(review_res.data["workflow_status"], WorkflowStatus.APPROVED) + self.assertEqual(review_res.data["admin_notes"], review_payload["admin_notes"]) diff --git a/apps/dynamic_forms/urls.py b/apps/dynamic_forms/urls.py new file mode 100644 index 0000000..1413b2b --- /dev/null +++ b/apps/dynamic_forms/urls.py @@ -0,0 +1,19 @@ +from django.urls import path +from apps.dynamic_forms.views import ( + FormDefinitionListCreateView, + FormDefinitionDetailView, + FormResponseSubmitView, + FormResponseListView, + FormResponseReviewView, +) + +urlpatterns = [ + # Form Definitions + path('', FormDefinitionListCreateView.as_view(), name='form_list_create'), + path('/', FormDefinitionDetailView.as_view(), name='form_detail'), + + # Submissions & Workflow + path('/responses/', FormResponseSubmitView.as_view(), name='form_response_submit'), + path('/submissions/', FormResponseListView.as_view(), name='form_response_list'), + path('responses//review/', FormResponseReviewView.as_view(), name='form_response_review'), +] diff --git a/apps/dynamic_forms/views/__init__.py b/apps/dynamic_forms/views/__init__.py new file mode 100644 index 0000000..74e7da9 --- /dev/null +++ b/apps/dynamic_forms/views/__init__.py @@ -0,0 +1,15 @@ +from apps.dynamic_forms.views.form_views import ( + FormDefinitionListCreateView, + FormDefinitionDetailView, + FormResponseSubmitView, + FormResponseListView, + FormResponseReviewView, +) + +__all__ = [ + 'FormDefinitionListCreateView', + 'FormDefinitionDetailView', + 'FormResponseSubmitView', + 'FormResponseListView', + 'FormResponseReviewView', +] diff --git a/apps/dynamic_forms/views/form_views.py b/apps/dynamic_forms/views/form_views.py new file mode 100644 index 0000000..dd673ff --- /dev/null +++ b/apps/dynamic_forms/views/form_views.py @@ -0,0 +1,177 @@ +import logging +from django.db.models import Q +from django.shortcuts import get_object_or_404 +from django.utils.translation import gettext_lazy as _ +from rest_framework import status +from rest_framework.views import APIView +from rest_framework.generics import GenericAPIView +from rest_framework.permissions import AllowAny, IsAuthenticated +from rest_framework.response import Response +from rest_framework.parsers import MultiPartParser, FormParser, JSONParser +from drf_spectacular.utils import extend_schema, OpenApiResponse + +from apps.dynamic_forms.models import FormDefinition, FormResponse, WorkflowStatus +from apps.dynamic_forms.serializers import ( + FormDefinitionListSerializer, + FormDefinitionDetailSerializer, + FormResponseSerializer, + FormResponseSubmitSerializer, + FormResponseReviewSerializer, +) + +logger = logging.getLogger(__name__) + + +def get_form_by_pk_or_slug(pk_or_slug, queryset=None): + if queryset is None: + queryset = FormDefinition.objects.all() + if str(pk_or_slug).isdigit(): + return get_object_or_404(queryset, pk=int(pk_or_slug)) + return get_object_or_404(queryset, slug=pk_or_slug) + + +class FormDefinitionListCreateView(GenericAPIView): + permission_classes = [AllowAny] + serializer_class = FormDefinitionListSerializer + queryset = FormDefinition.objects.filter(is_active=True) + + @extend_schema( + summary="List active dynamic forms", + description="Retrieves available custom dynamic forms and survey workflows.", + responses={200: FormDefinitionListSerializer(many=True)}, + tags=["Dynamic Forms & Surveys"], + ) + def get(self, request, *args, **kwargs): + qs = FormDefinition.objects.all() + if not (request.user and request.user.is_authenticated and request.user.is_staff): + qs = qs.filter(is_active=True) + serializer = self.get_serializer(qs, many=True) + return Response(serializer.data, status=status.HTTP_200_OK) + + @extend_schema( + summary="Create new dynamic form schema", + description="Defines a new custom form, questions, validation rules, and schema (Staff/Admin only).", + request=FormDefinitionDetailSerializer, + responses={ + 201: FormDefinitionDetailSerializer, + 403: OpenApiResponse(description="Permission denied"), + }, + tags=["Dynamic Forms & Surveys"], + ) + def post(self, request, *args, **kwargs): + if not request.user or not request.user.is_authenticated or not request.user.is_staff: + return Response({'detail': _("Only platform administrators can create form schemas.")}, status=status.HTTP_403_FORBIDDEN) + + serializer = FormDefinitionDetailSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + form = serializer.save(created_by=request.user) + return Response(FormDefinitionDetailSerializer(form).data, status=status.HTTP_201_CREATED) + + +class FormDefinitionDetailView(GenericAPIView): + permission_classes = [AllowAny] + serializer_class = FormDefinitionDetailSerializer + queryset = FormDefinition.objects.all() + + @extend_schema( + summary="Get form schema definition", + description="Retrieves the detailed form definition, instructions, and fields JSON schema.", + responses={ + 200: FormDefinitionDetailSerializer, + 404: OpenApiResponse(description="Form not found"), + }, + tags=["Dynamic Forms & Surveys"], + ) + def get(self, request, pk_or_slug, *args, **kwargs): + form = get_form_by_pk_or_slug(pk_or_slug, self.get_queryset()) + serializer = self.get_serializer(form) + return Response(serializer.data, status=status.HTTP_200_OK) + + +class FormResponseSubmitView(GenericAPIView): + permission_classes = [AllowAny] + parser_classes = [MultiPartParser, FormParser, JSONParser] + serializer_class = FormResponseSubmitSerializer + queryset = FormDefinition.objects.all() + + @extend_schema( + summary="Submit dynamic form responses", + description="Records user answers to custom dynamic form questions with optional file attachments.", + request=FormResponseSubmitSerializer, + responses={ + 201: FormResponseSerializer, + 400: OpenApiResponse(description="Invalid response data"), + }, + tags=["Dynamic Forms & Surveys"], + ) + def post(self, request, pk_or_slug, *args, **kwargs): + form = get_form_by_pk_or_slug(pk_or_slug, self.get_queryset()) + + if not form.is_active: + return Response({'error': _("This form is no longer accepting responses.")}, status=status.HTTP_400_BAD_REQUEST) + + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + + user = request.user if (request.user and request.user.is_authenticated) else None + + response_obj = FormResponse.objects.create( + form=form, + user=user, + answers=serializer.validated_data['answers'], + attachment=serializer.validated_data.get('attachment'), + workflow_status=WorkflowStatus.SUBMITTED, + ) + + return Response(FormResponseSerializer(response_obj).data, status=status.HTTP_201_CREATED) + + +class FormResponseListView(GenericAPIView): + permission_classes = [IsAuthenticated] + serializer_class = FormResponseSerializer + queryset = FormResponse.objects.all() + + @extend_schema( + summary="List submitted form responses", + description="Retrieves submissions for a form. Admins see all submissions; users see their own.", + responses={200: FormResponseSerializer(many=True)}, + tags=["Dynamic Forms & Surveys"], + ) + def get(self, request, pk_or_slug, *args, **kwargs): + form = get_form_by_pk_or_slug(pk_or_slug) + if request.user.is_staff or getattr(request.user, 'is_super_admin', False): + qs = FormResponse.objects.filter(form=form) + else: + qs = FormResponse.objects.filter(form=form, user=request.user) + + serializer = self.get_serializer(qs, many=True) + return Response(serializer.data, status=status.HTTP_200_OK) + + +class FormResponseReviewView(GenericAPIView): + permission_classes = [IsAuthenticated] + serializer_class = FormResponseReviewSerializer + queryset = FormResponse.objects.all() + + @extend_schema( + summary="Review and update form response workflow state", + description="Updates workflow status (under_review, approved, rejected) and adds admin notes (Staff only).", + request=FormResponseReviewSerializer, + responses={200: FormResponseSerializer}, + tags=["Dynamic Forms & Surveys"], + ) + def patch(self, request, response_id, *args, **kwargs): + if not request.user.is_staff and not getattr(request.user, 'is_super_admin', False): + return Response({'detail': _("Permission denied.")}, status=status.HTTP_403_FORBIDDEN) + + response_obj = get_object_or_404(FormResponse, pk=response_id) + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + + response_obj.workflow_status = serializer.validated_data['workflow_status'] + if 'admin_notes' in serializer.validated_data: + response_obj.admin_notes = serializer.validated_data['admin_notes'] + response_obj.reviewed_by = request.user + response_obj.save() + + return Response(FormResponseSerializer(response_obj).data, status=status.HTTP_200_OK) diff --git a/apps/lms/__init__.py b/apps/lms/__init__.py new file mode 100644 index 0000000..50895da --- /dev/null +++ b/apps/lms/__init__.py @@ -0,0 +1 @@ +# LMS app diff --git a/apps/lms/admin.py b/apps/lms/admin.py new file mode 100644 index 0000000..6913243 --- /dev/null +++ b/apps/lms/admin.py @@ -0,0 +1,83 @@ +from django.contrib import admin +from unfold.admin import ModelAdmin, TabularInline, StackedInline +from apps.lms.models import ( + Course, + CourseModule, + Lesson, + QuizQuestion, + CourseEnrollment, + LessonProgress, + DigitalCertificate, +) + + +class LessonInline(TabularInline): + model = Lesson + extra = 1 + fields = ('order', 'title', 'lesson_type', 'duration_minutes', 'is_preview') + + +class CourseModuleInline(StackedInline): + model = CourseModule + extra = 1 + fields = ('order', 'title', 'description') + + +class QuizQuestionInline(StackedInline): + model = QuizQuestion + extra = 1 + fields = ('order', 'question_text', 'options', 'correct_option_index', 'explanation') + + +@admin.register(Course) +class CourseAdmin(ModelAdmin): + list_display = ('title', 'category', 'language', 'level', 'instructor_name', 'is_published', 'is_featured', 'created_at') + list_filter = ('category', 'language', 'level', 'is_published', 'is_featured') + search_fields = ('title', 'description', 'instructor_name', 'organization_name') + prepopulated_fields = {'slug': ('title',)} + inlines = [CourseModuleInline] + + +@admin.register(CourseModule) +class CourseModuleAdmin(ModelAdmin): + list_display = ('title', 'course', 'order', 'created_at') + list_filter = ('course',) + search_fields = ('title', 'description', 'course__title') + inlines = [LessonInline] + + +@admin.register(Lesson) +class LessonAdmin(ModelAdmin): + list_display = ('title', 'module', 'lesson_type', 'duration_minutes', 'order', 'is_preview') + list_filter = ('lesson_type', 'is_preview', 'module__course') + search_fields = ('title', 'content', 'module__title', 'module__course__title') + inlines = [QuizQuestionInline] + + +@admin.register(QuizQuestion) +class QuizQuestionAdmin(ModelAdmin): + list_display = ('question_text', 'lesson', 'correct_option_index', 'order') + list_filter = ('lesson__module__course',) + search_fields = ('question_text', 'explanation', 'lesson__title') + + +@admin.register(CourseEnrollment) +class CourseEnrollmentAdmin(ModelAdmin): + list_display = ('user', 'course', 'progress_percentage', 'enrolled_at', 'completed_at') + list_filter = ('course', 'progress_percentage') + search_fields = ('user__email', 'user__fullname', 'course__title') + + +@admin.register(LessonProgress) +class LessonProgressAdmin(ModelAdmin): + list_display = ('user', 'lesson', 'is_completed', 'quiz_score', 'completed_at') + list_filter = ('is_completed', 'lesson__module__course') + search_fields = ('user__email', 'user__fullname', 'lesson__title') + + +@admin.register(DigitalCertificate) +class DigitalCertificateAdmin(ModelAdmin): + list_display = ('certificate_code', 'user', 'course', 'issue_date', 'final_score', 'is_revoked') + list_filter = ('is_revoked', 'issue_date', 'course') + search_fields = ('certificate_code', 'user__email', 'user__fullname', 'course__title') + readonly_fields = ('uuid', 'created_at') diff --git a/apps/lms/apps.py b/apps/lms/apps.py new file mode 100644 index 0000000..7cbf284 --- /dev/null +++ b/apps/lms/apps.py @@ -0,0 +1,8 @@ +from django.apps import AppConfig +from django.utils.translation import gettext_lazy as _ + + +class LMSConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'apps.lms' + verbose_name = _('Learning Management System (LMS)') diff --git a/apps/lms/migrations/0001_initial.py b/apps/lms/migrations/0001_initial.py new file mode 100644 index 0000000..4e7f3e3 --- /dev/null +++ b/apps/lms/migrations/0001_initial.py @@ -0,0 +1,160 @@ +# Generated by Django 4.2.30 on 2026-09-15 12:58 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone +import uuid + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('profiles', '0001_initial'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Course', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(max_length=255, verbose_name='Course Title')), + ('slug', models.SlugField(allow_unicode=True, blank=True, max_length=280, null=True, unique=True, verbose_name='Slug / URL Identifier')), + ('category', models.CharField(choices=[('razavi_teachings', 'Razavi Teachings & Lifestyle'), ('cultural_diplomacy', 'Cultural Diplomacy & Interfaith'), ('center_management', 'Center & Mosque Management'), ('international_media', 'International Media & Content'), ('specialized_languages', 'Specialized Languages & Translation'), ('general', 'General Religious Studies')], default='razavi_teachings', max_length=50, verbose_name='Category')), + ('language', models.CharField(choices=[('fa', 'Persian'), ('ar', 'Arabic'), ('en', 'English'), ('ur', 'Urdu'), ('fr', 'French')], default='en', max_length=10, verbose_name='Language')), + ('level', models.CharField(choices=[('introductory', 'Introductory'), ('intermediate', 'Intermediate'), ('advanced', 'Advanced')], default='introductory', max_length=20, verbose_name='Difficulty Level')), + ('cover_image', models.ImageField(blank=True, null=True, upload_to='lms/covers/%Y/%m/', verbose_name='Cover Image')), + ('instructor_name', models.CharField(max_length=255, verbose_name='Instructor Name')), + ('instructor_title', models.CharField(blank=True, max_length=255, null=True, verbose_name='Instructor Title / Specialty')), + ('instructor_avatar', models.ImageField(blank=True, null=True, upload_to='lms/instructors/%Y/%m/', verbose_name='Instructor Avatar')), + ('organization_name', models.CharField(blank=True, max_length=255, null=True, verbose_name='Organization / Academy Name')), + ('description', models.TextField(verbose_name='Course Description & Syllabus Summary')), + ('duration_hours', models.FloatField(default=1.0, verbose_name='Duration (Hours)')), + ('rating', models.FloatField(default=5.0, verbose_name='Average Rating')), + ('reviews_count', models.PositiveIntegerField(default=0, verbose_name='Reviews Count')), + ('is_published', models.BooleanField(default=True, verbose_name='Is Published')), + ('is_featured', models.BooleanField(default=False, verbose_name='Is Featured')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated At')), + ('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_courses', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), + ('institution', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='courses', to='profiles.institution', verbose_name='Partner Institution / Center')), + ], + options={ + 'verbose_name': 'Course', + 'verbose_name_plural': 'Courses', + 'ordering': ('-is_featured', '-created_at'), + }, + ), + migrations.CreateModel( + name='CourseModule', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(max_length=255, verbose_name='Module Title')), + ('description', models.TextField(blank=True, null=True, verbose_name='Module Description')), + ('order', models.IntegerField(default=0, verbose_name='Display Order')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), + ('course', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='modules', to='lms.course', verbose_name='Course')), + ], + options={ + 'verbose_name': 'Course Module', + 'verbose_name_plural': 'Course Modules', + 'ordering': ('order', 'id'), + }, + ), + migrations.CreateModel( + name='Lesson', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(max_length=255, verbose_name='Lesson Title')), + ('slug', models.SlugField(allow_unicode=True, blank=True, max_length=280, null=True, verbose_name='Slug')), + ('lesson_type', models.CharField(choices=[('video', 'Video Lesson'), ('reading', 'Reading & Text'), ('quiz', 'Interactive Quiz'), ('assignment', 'Practical Assignment')], default='video', max_length=20, verbose_name='Lesson Type')), + ('duration_minutes', models.PositiveIntegerField(default=15, verbose_name='Duration (Minutes)')), + ('video_url', models.URLField(blank=True, max_length=500, null=True, verbose_name='Video URL (YouTube, Vimeo, HLS, MP4)')), + ('content', models.TextField(blank=True, null=True, verbose_name='Lesson Content / Notes (Markdown)')), + ('slides_attachment', models.FileField(blank=True, null=True, upload_to='lms/slides/%Y/%m/', verbose_name='Slides / PDF Handout')), + ('order', models.IntegerField(default=0, verbose_name='Display Order')), + ('is_preview', models.BooleanField(default=False, verbose_name='Is Free Preview (Public)')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), + ('module', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='lessons', to='lms.coursemodule', verbose_name='Module')), + ], + options={ + 'verbose_name': 'Lesson', + 'verbose_name_plural': 'Lessons', + 'ordering': ('order', 'id'), + }, + ), + migrations.CreateModel( + name='QuizQuestion', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('question_text', models.TextField(verbose_name='Question Text')), + ('options', models.JSONField(default=list, help_text='List of multiple-choice strings: ["Option A", "Option B", "Option C", "Option D"]', verbose_name='Options Array')), + ('correct_option_index', models.PositiveSmallIntegerField(default=0, verbose_name='Correct Option Index (0-indexed)')), + ('explanation', models.TextField(blank=True, null=True, verbose_name='Explanation / Reference for Correct Answer')), + ('order', models.IntegerField(default=0, verbose_name='Display Order')), + ('lesson', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='quiz_questions', to='lms.lesson', verbose_name='Lesson')), + ], + options={ + 'verbose_name': 'Quiz Question', + 'verbose_name_plural': 'Quiz Questions', + 'ordering': ('order', 'id'), + }, + ), + migrations.CreateModel( + name='LessonProgress', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('is_completed', models.BooleanField(default=False, verbose_name='Is Completed')), + ('completed_at', models.DateTimeField(blank=True, null=True, verbose_name='Completed At')), + ('quiz_score', models.FloatField(blank=True, null=True, verbose_name='Quiz Score (Percentage)')), + ('lesson', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='user_progresses', to='lms.lesson', verbose_name='Lesson')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='lesson_progresses', to=settings.AUTH_USER_MODEL, verbose_name='User')), + ], + options={ + 'verbose_name': 'Lesson Progress', + 'verbose_name_plural': 'Lesson Progresses', + 'unique_together': {('user', 'lesson')}, + }, + ), + migrations.CreateModel( + name='DigitalCertificate', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('certificate_code', models.CharField(help_text='e.g. CERT-RAZAVI-2026-AB12CD', max_length=100, unique=True, verbose_name='Verification Code')), + ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, unique=True, verbose_name='Unique UUID')), + ('issue_date', models.DateField(default=django.utils.timezone.now, verbose_name='Issue Date')), + ('final_score', models.FloatField(default=100.0, verbose_name='Final Score (%)')), + ('certificate_file', models.FileField(blank=True, null=True, upload_to='lms/certificates/%Y/%m/', verbose_name='Certificate PDF Document')), + ('is_revoked', models.BooleanField(default=False, verbose_name='Is Revoked')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), + ('course', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='certificates', to='lms.course', verbose_name='Course')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='certificates', to=settings.AUTH_USER_MODEL, verbose_name='Student / User')), + ], + options={ + 'verbose_name': 'Digital Certificate', + 'verbose_name_plural': 'Digital Certificates', + 'ordering': ('-created_at',), + 'unique_together': {('user', 'course')}, + }, + ), + migrations.CreateModel( + name='CourseEnrollment', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('enrolled_at', models.DateTimeField(auto_now_add=True, verbose_name='Enrolled At')), + ('completed_at', models.DateTimeField(blank=True, null=True, verbose_name='Completed At')), + ('progress_percentage', models.PositiveSmallIntegerField(default=0, verbose_name='Progress Percentage (0-100)')), + ('course', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='enrollments', to='lms.course', verbose_name='Course')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='course_enrollments', to=settings.AUTH_USER_MODEL, verbose_name='User')), + ], + options={ + 'verbose_name': 'Course Enrollment', + 'verbose_name_plural': 'Course Enrollments', + 'ordering': ('-enrolled_at',), + 'unique_together': {('user', 'course')}, + }, + ), + ] diff --git a/apps/lms/migrations/__init__.py b/apps/lms/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/lms/models/__init__.py b/apps/lms/models/__init__.py new file mode 100644 index 0000000..5aa2220 --- /dev/null +++ b/apps/lms/models/__init__.py @@ -0,0 +1,25 @@ +from apps.lms.models.course import ( + Course, + CourseModule, + Lesson, + QuizQuestion, + CourseEnrollment, + LessonProgress, + DigitalCertificate, + CourseCategory, + CourseLevel, + CourseLanguage, +) + +__all__ = [ + 'Course', + 'CourseModule', + 'Lesson', + 'QuizQuestion', + 'CourseEnrollment', + 'LessonProgress', + 'DigitalCertificate', + 'CourseCategory', + 'CourseLevel', + 'CourseLanguage', +] diff --git a/apps/lms/models/course.py b/apps/lms/models/course.py new file mode 100644 index 0000000..6ee6d3a --- /dev/null +++ b/apps/lms/models/course.py @@ -0,0 +1,418 @@ +import uuid +import random +from django.conf import settings +from django.db import models +from django.utils import timezone +from django.utils.text import slugify +from django.utils.translation import gettext_lazy as _ +from apps.profiles.models import Institution + + +class CourseCategory(models.TextChoices): + RAZAVI_TEACHINGS = 'razavi_teachings', _('Razavi Teachings & Lifestyle') + CULTURAL_DIPLOMACY = 'cultural_diplomacy', _('Cultural Diplomacy & Interfaith') + CENTER_MANAGEMENT = 'center_management', _('Center & Mosque Management') + INTERNATIONAL_MEDIA = 'international_media', _('International Media & Content') + SPECIALIZED_LANGUAGES = 'specialized_languages', _('Specialized Languages & Translation') + GENERAL = 'general', _('General Religious Studies') + + +class CourseLevel(models.TextChoices): + INTRODUCTORY = 'introductory', _('Introductory') + INTERMEDIATE = 'intermediate', _('Intermediate') + ADVANCED = 'advanced', _('Advanced') + + +class CourseLanguage(models.TextChoices): + FA = 'fa', _('Persian') + AR = 'ar', _('Arabic') + EN = 'en', _('English') + UR = 'ur', _('Urdu') + FR = 'fr', _('French') + + +class Course(models.Model): + title = models.CharField(max_length=255, verbose_name=_('Course Title')) + slug = models.SlugField( + max_length=280, + unique=True, + blank=True, + null=True, + allow_unicode=True, + verbose_name=_('Slug / URL Identifier') + ) + category = models.CharField( + max_length=50, + choices=CourseCategory.choices, + default=CourseCategory.RAZAVI_TEACHINGS, + verbose_name=_('Category') + ) + language = models.CharField( + max_length=10, + choices=CourseLanguage.choices, + default=CourseLanguage.EN, + verbose_name=_('Language') + ) + level = models.CharField( + max_length=20, + choices=CourseLevel.choices, + default=CourseLevel.INTRODUCTORY, + verbose_name=_('Difficulty Level') + ) + cover_image = models.ImageField( + upload_to='lms/covers/%Y/%m/', + blank=True, + null=True, + verbose_name=_('Cover Image') + ) + instructor_name = models.CharField(max_length=255, verbose_name=_('Instructor Name')) + instructor_title = models.CharField( + max_length=255, + blank=True, + null=True, + verbose_name=_('Instructor Title / Specialty') + ) + instructor_avatar = models.ImageField( + upload_to='lms/instructors/%Y/%m/', + blank=True, + null=True, + verbose_name=_('Instructor Avatar') + ) + institution = models.ForeignKey( + Institution, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name='courses', + verbose_name=_('Partner Institution / Center') + ) + organization_name = models.CharField( + max_length=255, + blank=True, + null=True, + verbose_name=_('Organization / Academy Name') + ) + description = models.TextField(verbose_name=_('Course Description & Syllabus Summary')) + duration_hours = models.FloatField(default=1.0, verbose_name=_('Duration (Hours)')) + rating = models.FloatField(default=5.0, verbose_name=_('Average Rating')) + reviews_count = models.PositiveIntegerField(default=0, verbose_name=_('Reviews Count')) + is_published = models.BooleanField(default=True, verbose_name=_('Is Published')) + is_featured = models.BooleanField(default=False, verbose_name=_('Is Featured')) + created_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name='created_courses', + verbose_name=_('Created By') + ) + created_at = models.DateTimeField(auto_now_add=True, verbose_name=_('Created At')) + updated_at = models.DateTimeField(auto_now=True, verbose_name=_('Updated At')) + + class Meta: + ordering = ('-is_featured', '-created_at') + verbose_name = _('Course') + verbose_name_plural = _('Courses') + + def __str__(self): + return f"{self.title} ({self.get_language_display()} - {self.get_level_display()})" + + def save(self, *args, **kwargs): + if not self.slug: + base_slug = slugify(self.title, allow_unicode=True) or f"course-{random.randint(1000, 9999)}" + slug = base_slug + counter = 1 + while Course.objects.filter(slug=slug).exclude(pk=self.pk).exists(): + slug = f"{base_slug}-{counter}" + counter += 1 + self.slug = slug + super().save(*args, **kwargs) + + @property + def total_lessons_count(self): + return Lesson.objects.filter(module__course=self).count() + + @property + def total_students_count(self): + return self.enrollments.count() + + +class CourseModule(models.Model): + course = models.ForeignKey( + Course, + on_delete=models.CASCADE, + related_name='modules', + verbose_name=_('Course') + ) + title = models.CharField(max_length=255, verbose_name=_('Module Title')) + description = models.TextField(blank=True, null=True, verbose_name=_('Module Description')) + order = models.IntegerField(default=0, verbose_name=_('Display Order')) + created_at = models.DateTimeField(auto_now_add=True, verbose_name=_('Created At')) + + class Meta: + ordering = ('order', 'id') + verbose_name = _('Course Module') + verbose_name_plural = _('Course Modules') + + def __str__(self): + return f"{self.course.title} - Module {self.order + 1}: {self.title}" + + +class Lesson(models.Model): + class LessonType(models.TextChoices): + VIDEO = 'video', _('Video Lesson') + READING = 'reading', _('Reading & Text') + QUIZ = 'quiz', _('Interactive Quiz') + ASSIGNMENT = 'assignment', _('Practical Assignment') + + module = models.ForeignKey( + CourseModule, + on_delete=models.CASCADE, + related_name='lessons', + verbose_name=_('Module') + ) + title = models.CharField(max_length=255, verbose_name=_('Lesson Title')) + slug = models.SlugField( + max_length=280, + blank=True, + null=True, + allow_unicode=True, + verbose_name=_('Slug') + ) + lesson_type = models.CharField( + max_length=20, + choices=LessonType.choices, + default=LessonType.VIDEO, + verbose_name=_('Lesson Type') + ) + duration_minutes = models.PositiveIntegerField(default=15, verbose_name=_('Duration (Minutes)')) + video_url = models.URLField( + max_length=500, + blank=True, + null=True, + verbose_name=_('Video URL (YouTube, Vimeo, HLS, MP4)') + ) + content = models.TextField( + blank=True, + null=True, + verbose_name=_('Lesson Content / Notes (Markdown)') + ) + slides_attachment = models.FileField( + upload_to='lms/slides/%Y/%m/', + blank=True, + null=True, + verbose_name=_('Slides / PDF Handout') + ) + order = models.IntegerField(default=0, verbose_name=_('Display Order')) + is_preview = models.BooleanField( + default=False, + verbose_name=_('Is Free Preview (Public)') + ) + created_at = models.DateTimeField(auto_now_add=True, verbose_name=_('Created At')) + + class Meta: + ordering = ('order', 'id') + verbose_name = _('Lesson') + verbose_name_plural = _('Lessons') + + def __str__(self): + return f"{self.module.course.title} -> {self.title} ({self.get_lesson_type_display()})" + + def save(self, *args, **kwargs): + if not self.slug: + base_slug = slugify(self.title, allow_unicode=True) or f"lesson-{random.randint(1000, 9999)}" + self.slug = base_slug + super().save(*args, **kwargs) + + +class QuizQuestion(models.Model): + lesson = models.ForeignKey( + Lesson, + on_delete=models.CASCADE, + related_name='quiz_questions', + verbose_name=_('Lesson') + ) + question_text = models.TextField(verbose_name=_('Question Text')) + options = models.JSONField( + default=list, + verbose_name=_('Options Array'), + help_text=_('List of multiple-choice strings: ["Option A", "Option B", "Option C", "Option D"]') + ) + correct_option_index = models.PositiveSmallIntegerField( + default=0, + verbose_name=_('Correct Option Index (0-indexed)') + ) + explanation = models.TextField( + blank=True, + null=True, + verbose_name=_('Explanation / Reference for Correct Answer') + ) + order = models.IntegerField(default=0, verbose_name=_('Display Order')) + + class Meta: + ordering = ('order', 'id') + verbose_name = _('Quiz Question') + verbose_name_plural = _('Quiz Questions') + + def __str__(self): + return f"Q{self.order + 1}: {self.question_text[:60]}" + + +class CourseEnrollment(models.Model): + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name='course_enrollments', + verbose_name=_('User') + ) + course = models.ForeignKey( + Course, + on_delete=models.CASCADE, + related_name='enrollments', + verbose_name=_('Course') + ) + enrolled_at = models.DateTimeField(auto_now_add=True, verbose_name=_('Enrolled At')) + completed_at = models.DateTimeField(null=True, blank=True, verbose_name=_('Completed At')) + progress_percentage = models.PositiveSmallIntegerField( + default=0, + verbose_name=_('Progress Percentage (0-100)') + ) + + class Meta: + unique_together = ('user', 'course') + ordering = ('-enrolled_at',) + verbose_name = _('Course Enrollment') + verbose_name_plural = _('Course Enrollments') + + def __str__(self): + return f"{self.user} enrolled in {self.course.title} ({self.progress_percentage}%)" + + def recalculate_progress(self): + """ + Recalculates progress percentage based on completed lessons for this course. + If progress reaches 100%, marks completed_at and issues digital certificate. + """ + all_lesson_ids = Lesson.objects.filter(module__course=self.course).values_list('id', flat=True) + total_count = len(all_lesson_ids) + if total_count == 0: + self.progress_percentage = 100 + else: + completed_count = LessonProgress.objects.filter( + user=self.user, + lesson_id__in=all_lesson_ids, + is_completed=True + ).count() + self.progress_percentage = int((completed_count / total_count) * 100) + + if self.progress_percentage >= 100 and not self.completed_at: + self.completed_at = timezone.now() + # Auto-issue certificate if not already issued + DigitalCertificate.issue_for_enrollment(self) + self.save() + return self.progress_percentage + + +class LessonProgress(models.Model): + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name='lesson_progresses', + verbose_name=_('User') + ) + lesson = models.ForeignKey( + Lesson, + on_delete=models.CASCADE, + related_name='user_progresses', + verbose_name=_('Lesson') + ) + is_completed = models.BooleanField(default=False, verbose_name=_('Is Completed')) + completed_at = models.DateTimeField(null=True, blank=True, verbose_name=_('Completed At')) + quiz_score = models.FloatField( + null=True, + blank=True, + verbose_name=_('Quiz Score (Percentage)') + ) + + class Meta: + unique_together = ('user', 'lesson') + verbose_name = _('Lesson Progress') + verbose_name_plural = _('Lesson Progresses') + + def __str__(self): + status_str = "Completed" if self.is_completed else "In Progress" + return f"{self.user} - {self.lesson.title}: {status_str}" + + +class DigitalCertificate(models.Model): + certificate_code = models.CharField( + max_length=100, + unique=True, + verbose_name=_('Verification Code'), + help_text=_('e.g. CERT-RAZAVI-2026-AB12CD') + ) + uuid = models.UUIDField(default=uuid.uuid4, unique=True, editable=False, verbose_name=_('Unique UUID')) + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name='certificates', + verbose_name=_('Student / User') + ) + course = models.ForeignKey( + Course, + on_delete=models.CASCADE, + related_name='certificates', + verbose_name=_('Course') + ) + issue_date = models.DateField(default=timezone.now, verbose_name=_('Issue Date')) + final_score = models.FloatField(default=100.0, verbose_name=_('Final Score (%)')) + certificate_file = models.FileField( + upload_to='lms/certificates/%Y/%m/', + blank=True, + null=True, + verbose_name=_('Certificate PDF Document') + ) + is_revoked = models.BooleanField(default=False, verbose_name=_('Is Revoked')) + created_at = models.DateTimeField(auto_now_add=True, verbose_name=_('Created At')) + + class Meta: + unique_together = ('user', 'course') + ordering = ('-created_at',) + verbose_name = _('Digital Certificate') + verbose_name_plural = _('Digital Certificates') + + def __str__(self): + return f"Certificate {self.certificate_code} ({self.user} - {self.course.title})" + + @classmethod + def issue_for_enrollment(cls, enrollment): + """ + Creates a new certificate for a finished course enrollment if none exists. + """ + existing = cls.objects.filter(user=enrollment.user, course=enrollment.course).first() + if existing: + return existing + + year = timezone.now().year + rand_code = ''.join(random.choices('ABCDEFGHJKLMNPQRSTUVWXYZ23456789', k=6)) + code = f"CERT-RAZAVI-{year}-{rand_code}" + + # Calculate average quiz score if any quizzes were taken + lesson_ids = Lesson.objects.filter(module__course=enrollment.course).values_list('id', flat=True) + quiz_progresses = LessonProgress.objects.filter( + user=enrollment.user, + lesson_id__in=lesson_ids, + quiz_score__isnull=False + ) + if quiz_progresses.exists(): + avg_score = sum(p.quiz_score for p in quiz_progresses) / quiz_progresses.count() + else: + avg_score = 100.0 + + certificate = cls.objects.create( + certificate_code=code, + user=enrollment.user, + course=enrollment.course, + issue_date=timezone.now().date(), + final_score=round(avg_score, 1), + ) + return certificate diff --git a/apps/lms/permissions.py b/apps/lms/permissions.py new file mode 100644 index 0000000..d4fdad0 --- /dev/null +++ b/apps/lms/permissions.py @@ -0,0 +1,55 @@ +from rest_framework.permissions import BasePermission, SAFE_METHODS + + +class IsCourseInstructorOrAdmin(BasePermission): + """ + Grants permission to course creators, institution admins, regional admins, and super admins. + Allows read-only access to anyone for safe methods. + """ + def has_permission(self, request, view): + if request.method in SAFE_METHODS: + return True + return bool(request.user and request.user.is_authenticated and request.user.is_active) + + def has_object_permission(self, request, view, obj): + if request.method in SAFE_METHODS: + return True + + if not request.user or not request.user.is_authenticated: + return False + + if getattr(request.user, 'is_super_admin', False) or getattr(request.user, 'is_regional_admin', False): + return True + + # If obj is a Course + if hasattr(obj, 'created_by') and obj.created_by == request.user: + return True + + if hasattr(obj, 'institution') and obj.institution: + if obj.institution.is_editor(request.user): + return True + + # If obj is CourseModule or Lesson + if hasattr(obj, 'course'): + course = obj.course + if course.created_by == request.user: + return True + if course.institution and course.institution.is_editor(request.user): + return True + + if hasattr(obj, 'module'): + course = obj.module.course + if course.created_by == request.user: + return True + if course.institution and course.institution.is_editor(request.user): + return True + + return False + + +class IsEnrolledOrPreview(BasePermission): + """ + Allows access if the lesson is marked as preview, or if the user is enrolled, or course admin. + """ + def has_permission(self, request, view): + return bool(request.user and request.user.is_authenticated and request.user.is_active) diff --git a/apps/lms/serializers/__init__.py b/apps/lms/serializers/__init__.py new file mode 100644 index 0000000..b59371b --- /dev/null +++ b/apps/lms/serializers/__init__.py @@ -0,0 +1,35 @@ +from apps.lms.serializers.course_serializers import ( + CourseListSerializer, + CourseDetailSerializer, + CourseCreateUpdateSerializer, + CourseModuleSerializer, + LessonListSerializer, + LessonDetailSerializer, + QuizQuestionStudentSerializer, + QuizQuestionAdminSerializer, + SingleQuizAnswerSerializer, + QuizSubmitRequestSerializer, + QuizResultSerializer, + CourseProgressSerializer, + LessonCompleteResponseSerializer, + DigitalCertificateSerializer, + PublicCertificateVerifySerializer, +) + +__all__ = [ + 'CourseListSerializer', + 'CourseDetailSerializer', + 'CourseCreateUpdateSerializer', + 'CourseModuleSerializer', + 'LessonListSerializer', + 'LessonDetailSerializer', + 'QuizQuestionStudentSerializer', + 'QuizQuestionAdminSerializer', + 'SingleQuizAnswerSerializer', + 'QuizSubmitRequestSerializer', + 'QuizResultSerializer', + 'CourseProgressSerializer', + 'LessonCompleteResponseSerializer', + 'DigitalCertificateSerializer', + 'PublicCertificateVerifySerializer', +] diff --git a/apps/lms/serializers/course_serializers.py b/apps/lms/serializers/course_serializers.py new file mode 100644 index 0000000..33bbce9 --- /dev/null +++ b/apps/lms/serializers/course_serializers.py @@ -0,0 +1,257 @@ +from rest_framework import serializers +from drf_spectacular.utils import extend_schema_field, OpenApiTypes +from apps.lms.models import ( + Course, + CourseModule, + Lesson, + QuizQuestion, + CourseEnrollment, + LessonProgress, + DigitalCertificate, +) + + +class QuizQuestionStudentSerializer(serializers.ModelSerializer): + class Meta: + model = QuizQuestion + fields = ['id', 'question_text', 'options', 'order'] + + +class QuizQuestionAdminSerializer(serializers.ModelSerializer): + class Meta: + model = QuizQuestion + fields = ['id', 'question_text', 'options', 'correct_option_index', 'explanation', 'order'] + + +class LessonListSerializer(serializers.ModelSerializer): + is_completed = serializers.SerializerMethodField() + + class Meta: + model = Lesson + fields = [ + 'id', 'title', 'slug', 'lesson_type', + 'duration_minutes', 'order', 'is_preview', + 'is_completed', + ] + + @extend_schema_field(OpenApiTypes.BOOL) + def get_is_completed(self, obj) -> bool: + request = self.context.get('request') + if request and request.user and request.user.is_authenticated: + return LessonProgress.objects.filter( + user=request.user, + lesson=obj, + is_completed=True + ).exists() + return False + + +class LessonDetailSerializer(serializers.ModelSerializer): + quiz_questions = serializers.SerializerMethodField() + is_completed = serializers.SerializerMethodField() + quiz_score = serializers.SerializerMethodField() + + class Meta: + model = Lesson + fields = [ + 'id', 'title', 'slug', 'lesson_type', + 'duration_minutes', 'video_url', 'content', + 'slides_attachment', 'order', 'is_preview', + 'quiz_questions', 'is_completed', 'quiz_score', + ] + + @extend_schema_field(QuizQuestionStudentSerializer(many=True)) + def get_quiz_questions(self, obj): + questions = obj.quiz_questions.all().order_by('order', 'id') + request = self.context.get('request') + if request and request.user and request.user.is_authenticated and (request.user.is_staff or getattr(request.user, 'is_super_admin', False)): + return QuizQuestionAdminSerializer(questions, many=True).data + return QuizQuestionStudentSerializer(questions, many=True).data + + @extend_schema_field(OpenApiTypes.BOOL) + def get_is_completed(self, obj) -> bool: + request = self.context.get('request') + if request and request.user and request.user.is_authenticated: + progress = LessonProgress.objects.filter(user=request.user, lesson=obj).first() + return progress.is_completed if progress else False + return False + + @extend_schema_field(OpenApiTypes.FLOAT) + def get_quiz_score(self, obj): + request = self.context.get('request') + if request and request.user and request.user.is_authenticated: + progress = LessonProgress.objects.filter(user=request.user, lesson=obj).first() + return progress.quiz_score if progress else None + return None + + +class CourseModuleSerializer(serializers.ModelSerializer): + lessons = LessonListSerializer(many=True, read_only=True) + + class Meta: + model = CourseModule + fields = ['id', 'title', 'description', 'order', 'lessons'] + + +class CourseListSerializer(serializers.ModelSerializer): + total_lessons = serializers.IntegerField(source='total_lessons_count', read_only=True) + total_students = serializers.IntegerField(source='total_students_count', read_only=True) + is_enrolled = serializers.SerializerMethodField() + progress_percentage = serializers.SerializerMethodField() + + class Meta: + model = Course + fields = [ + 'id', 'title', 'slug', 'category', 'language', + 'level', 'cover_image', 'instructor_name', + 'instructor_title', 'instructor_avatar', + 'organization_name', 'duration_hours', 'rating', + 'reviews_count', 'is_featured', 'total_lessons', + 'total_students', 'is_enrolled', 'progress_percentage', + 'created_at', + ] + + @extend_schema_field(OpenApiTypes.BOOL) + def get_is_enrolled(self, obj) -> bool: + request = self.context.get('request') + if request and request.user and request.user.is_authenticated: + return CourseEnrollment.objects.filter(user=request.user, course=obj).exists() + return False + + @extend_schema_field(OpenApiTypes.INT) + def get_progress_percentage(self, obj) -> int: + request = self.context.get('request') + if request and request.user and request.user.is_authenticated: + enrollment = CourseEnrollment.objects.filter(user=request.user, course=obj).first() + return enrollment.progress_percentage if enrollment else 0 + return 0 + + +class CourseDetailSerializer(serializers.ModelSerializer): + modules = CourseModuleSerializer(many=True, read_only=True) + total_lessons = serializers.IntegerField(source='total_lessons_count', read_only=True) + total_students = serializers.IntegerField(source='total_students_count', read_only=True) + is_enrolled = serializers.SerializerMethodField() + progress_percentage = serializers.SerializerMethodField() + certificate_code = serializers.SerializerMethodField() + + class Meta: + model = Course + fields = [ + 'id', 'title', 'slug', 'category', 'language', + 'level', 'cover_image', 'instructor_name', + 'instructor_title', 'instructor_avatar', + 'institution', 'organization_name', 'description', + 'duration_hours', 'rating', 'reviews_count', + 'is_published', 'is_featured', 'modules', + 'total_lessons', 'total_students', + 'is_enrolled', 'progress_percentage', 'certificate_code', + 'created_at', 'updated_at', + ] + + @extend_schema_field(OpenApiTypes.BOOL) + def get_is_enrolled(self, obj) -> bool: + request = self.context.get('request') + if request and request.user and request.user.is_authenticated: + return CourseEnrollment.objects.filter(user=request.user, course=obj).exists() + return False + + @extend_schema_field(OpenApiTypes.INT) + def get_progress_percentage(self, obj) -> int: + request = self.context.get('request') + if request and request.user and request.user.is_authenticated: + enrollment = CourseEnrollment.objects.filter(user=request.user, course=obj).first() + return enrollment.progress_percentage if enrollment else 0 + return 0 + + @extend_schema_field(OpenApiTypes.STR) + def get_certificate_code(self, obj): + request = self.context.get('request') + if request and request.user and request.user.is_authenticated: + cert = DigitalCertificate.objects.filter(user=request.user, course=obj).first() + return cert.certificate_code if cert else None + return None + + +class CourseCreateUpdateSerializer(serializers.ModelSerializer): + class Meta: + model = Course + fields = [ + 'id', 'title', 'category', 'language', 'level', + 'cover_image', 'instructor_name', 'instructor_title', + 'instructor_avatar', 'institution', 'organization_name', + 'description', 'duration_hours', 'is_published', 'is_featured', + ] + + +class SingleQuizAnswerSerializer(serializers.Serializer): + question_id = serializers.IntegerField() + selected_option = serializers.IntegerField() + + +class QuizSubmitRequestSerializer(serializers.Serializer): + answers = SingleQuizAnswerSerializer(many=True) + + +class QuizQuestionResultItemSerializer(serializers.Serializer): + question_id = serializers.IntegerField() + is_correct = serializers.BooleanField() + selected_option = serializers.IntegerField() + correct_option = serializers.IntegerField() + explanation = serializers.CharField(allow_blank=True, allow_null=True) + + +class QuizResultSerializer(serializers.Serializer): + score = serializers.FloatField(help_text="Percentage score achieved (0-100)") + passed = serializers.BooleanField(help_text="True if score >= 70%") + total_questions = serializers.IntegerField() + correct_count = serializers.IntegerField() + question_results = QuizQuestionResultItemSerializer(many=True) + is_lesson_completed = serializers.BooleanField() + course_progress_percentage = serializers.IntegerField() + certificate_code = serializers.CharField(allow_null=True, required=False) + + +class LessonCompleteResponseSerializer(serializers.Serializer): + message = serializers.CharField() + lesson_id = serializers.IntegerField() + is_completed = serializers.BooleanField() + course_progress_percentage = serializers.IntegerField() + certificate_code = serializers.CharField(allow_null=True, required=False) + + +class CourseProgressSerializer(serializers.Serializer): + course_id = serializers.IntegerField() + course_title = serializers.CharField() + progress_percentage = serializers.IntegerField() + completed_lessons_count = serializers.IntegerField() + total_lessons_count = serializers.IntegerField() + is_completed = serializers.BooleanField() + certificate_code = serializers.CharField(allow_null=True) + + +class DigitalCertificateSerializer(serializers.ModelSerializer): + course_title = serializers.CharField(source='course.title', read_only=True) + course_slug = serializers.CharField(source='course.slug', read_only=True) + recipient_name = serializers.CharField(source='user.get_full_name', read_only=True) + organization = serializers.CharField(source='course.organization_name', read_only=True) + + class Meta: + model = DigitalCertificate + fields = [ + 'id', 'certificate_code', 'uuid', + 'recipient_name', 'course_title', 'course_slug', + 'organization', 'issue_date', 'final_score', + 'certificate_file', 'is_revoked', 'created_at', + ] + + +class PublicCertificateVerifySerializer(serializers.Serializer): + certificate_code = serializers.CharField() + uuid = serializers.UUIDField() + recipient_name = serializers.CharField() + course_title = serializers.CharField() + organization = serializers.CharField(allow_null=True) + issue_date = serializers.DateField() + final_score = serializers.FloatField() + is_valid = serializers.BooleanField() diff --git a/apps/lms/tests/__init__.py b/apps/lms/tests/__init__.py new file mode 100644 index 0000000..506d29c --- /dev/null +++ b/apps/lms/tests/__init__.py @@ -0,0 +1 @@ +# LMS tests diff --git a/apps/lms/tests/test_phase8_lms.py b/apps/lms/tests/test_phase8_lms.py new file mode 100644 index 0000000..fa01e5e --- /dev/null +++ b/apps/lms/tests/test_phase8_lms.py @@ -0,0 +1,296 @@ +from django.test import TestCase +from django.contrib.auth import get_user_model +from rest_framework.test import APIClient +from rest_framework import status + +from apps.profiles.models import Institution +from apps.lms.models import ( + Course, + CourseModule, + Lesson, + QuizQuestion, + CourseEnrollment, + LessonProgress, + DigitalCertificate, + CourseCategory, + CourseLevel, + CourseLanguage, +) + +User = get_user_model() + + +class Phase8LMSAPITests(TestCase): + """ + Automated test suite for Phase 8: + - Course Catalog & Multi-Facet Filtering + - Course Creation & Detail Retrieval (with syllabus) + - Course Enrollment Flow + - Lesson Details & Content + - Lesson Completion & Progress Recalculation + - Interactive Quiz Submission & Grading + - Automatic Certificate Generation on 100% Course Completion + - Public QR-Code Certificate Verification + - User Certificates Listing + """ + + def setUp(self): + self.client = APIClient() + + # Users + self.admin_user = User.objects.create_user( + email="lms.admin@razaviacademy.org", + password="AdminPassword123!", + fullname="Dr. Mohammad Razavi", + user_type=User.UserType.SUPER_ADMIN, + is_staff=True, + ) + self.student_user = User.objects.create_user( + email="student@shiahub.org", + password="StudentPassword123!", + fullname="Ali Rezaei", + user_type=User.UserType.STUDENT, + ) + + # Partner Institution + self.institution = Institution.objects.create( + name="Razavi International Islamic Academy", + country="Iran", + city="Mashhad", + type=Institution.InstitutionType.INSTITUTE, + ) + + # Create Course + self.course = Course.objects.create( + title="Strategic Cultural Diplomacy in the Muslim World", + category=CourseCategory.CULTURAL_DIPLOMACY, + language=CourseLanguage.EN, + level=CourseLevel.INTERMEDIATE, + instructor_name="Prof. Seyed Hassan", + instructor_title="Senior Scholar in International Relations", + institution=self.institution, + organization_name="Razavi Academy", + description="Comprehensive curriculum on international communication, interfaith diplomacy, and center leadership.", + duration_hours=12.5, + rating=4.9, + reviews_count=24, + is_published=True, + is_featured=True, + created_by=self.admin_user, + ) + + # Module 1 + self.module1 = CourseModule.objects.create( + course=self.course, + title="Foundations of Cultural Dialogue", + description="Historical and theological principles of dialogue.", + order=0, + ) + # Lesson 1 (Reading) + self.lesson1 = Lesson.objects.create( + module=self.module1, + title="Historical Overview of Razavi Discourse", + lesson_type=Lesson.LessonType.READING, + duration_minutes=25, + content="# Razavi Discourse\n\nPrinciples of rational dialogue with scholars of other traditions.", + order=0, + is_preview=True, + ) + # Lesson 2 (Quiz) + self.lesson2 = Lesson.objects.create( + module=self.module1, + title="Module 1 Knowledge Assessment", + lesson_type=Lesson.LessonType.QUIZ, + duration_minutes=15, + order=1, + is_preview=False, + ) + # Quiz Questions for Lesson 2 + self.q1 = QuizQuestion.objects.create( + lesson=self.lesson2, + question_text="What was a core characteristic of Imam Reza's (AS) debates in Merv?", + options=[ + "Use of shared textual references and rational evidence", + "Dismissal of opposing perspectives without debate", + "Limiting discussions exclusively to one language", + "Avoiding dialogue with other faith traditions" + ], + correct_option_index=0, + explanation="The Imam engaged scholars using their own scriptures and universal rational arguments.", + order=0, + ) + self.q2 = QuizQuestion.objects.create( + lesson=self.lesson2, + question_text="In modern cultural diplomacy, what is primary objective of inter-center networking?", + options=[ + "Encouraging bilateral isolationism", + "Collaborative synergy, knowledge sharing, and mutual support", + "Eliminating local community autonomy", + "Replacing all regional cultural expressions" + ], + correct_option_index=1, + explanation="Synergy and cooperation empower global communities while preserving local identity.", + order=1, + ) + + def test_course_list_and_filters(self): + # 1. General list + response = self.client.get("/api/v1/lms/courses/") + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(len(response.data), 1) + self.assertEqual(response.data[0]["title"], self.course.title) + self.assertEqual(response.data[0]["total_lessons"], 2) + + # 2. Filter by category + res_cat = self.client.get(f"/api/v1/lms/courses/?category={CourseCategory.CULTURAL_DIPLOMACY}") + self.assertEqual(res_cat.status_code, status.HTTP_200_OK) + self.assertEqual(len(res_cat.data), 1) + + # 3. Filter by non-matching category + res_empty = self.client.get(f"/api/v1/lms/courses/?category={CourseCategory.SPECIALIZED_LANGUAGES}") + self.assertEqual(res_empty.status_code, status.HTTP_200_OK) + self.assertEqual(len(res_empty.data), 0) + + # 4. Search query + res_search = self.client.get("/api/v1/lms/courses/?search=Diplomacy") + self.assertEqual(res_search.status_code, status.HTTP_200_OK) + self.assertEqual(len(res_search.data), 1) + + def test_course_create_authorized(self): + self.client.force_authenticate(user=self.admin_user) + payload = { + "title": "Modern Mosque & Center Administration", + "category": "center_management", + "language": "en", + "level": "introductory", + "instructor_name": "Dr. Ali Reza", + "description": "Organizational governance, financial sustainability, and youth engagement programs.", + "duration_hours": 8.0, + "organization_name": "Razavi Academy", + "is_published": True, + "is_featured": False, + } + response = self.client.post("/api/v1/lms/courses/", payload, format="json") + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(response.data["title"], "Modern Mosque & Center Administration") + self.assertTrue(Course.objects.filter(title="Modern Mosque & Center Administration").exists()) + + def test_course_detail_retrieval(self): + response = self.client.get(f"/api/v1/lms/courses/{self.course.slug}/") + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["id"], self.course.id) + self.assertEqual(len(response.data["modules"]), 1) + self.assertEqual(response.data["modules"][0]["title"], "Foundations of Cultural Dialogue") + self.assertEqual(len(response.data["modules"][0]["lessons"]), 2) + self.assertFalse(response.data["is_enrolled"]) + + def test_course_enrollment_flow(self): + self.client.force_authenticate(user=self.student_user) + + # 1. Enroll + response = self.client.post(f"/api/v1/lms/courses/{self.course.id}/enroll/") + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(response.data["course_id"], self.course.id) + self.assertEqual(response.data["progress_percentage"], 0) + self.assertTrue(CourseEnrollment.objects.filter(user=self.student_user, course=self.course).exists()) + + # 2. Duplicate enroll returns 200 + response_dup = self.client.post(f"/api/v1/lms/courses/{self.course.id}/enroll/") + self.assertEqual(response_dup.status_code, status.HTTP_200_OK) + + # 3. Check course detail shows is_enrolled=True + detail_res = self.client.get(f"/api/v1/lms/courses/{self.course.id}/") + self.assertEqual(detail_res.status_code, status.HTTP_200_OK) + self.assertTrue(detail_res.data["is_enrolled"]) + + def test_lesson_detail_view(self): + # Unauthenticated student view of preview lesson + response = self.client.get(f"/api/v1/lms/lessons/{self.lesson1.id}/") + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["title"], self.lesson1.title) + self.assertIn("# Razavi Discourse", response.data["content"]) + + # Quiz lesson view + res_quiz = self.client.get(f"/api/v1/lms/lessons/{self.lesson2.id}/") + self.assertEqual(res_quiz.status_code, status.HTTP_200_OK) + self.assertEqual(len(res_quiz.data["quiz_questions"]), 2) + # Student view shouldn't leak correct_option_index + self.assertNotIn("correct_option_index", res_quiz.data["quiz_questions"][0]) + + def test_lesson_completion_and_progress_recalculation(self): + self.client.force_authenticate(user=self.student_user) + + # Complete Lesson 1 + response = self.client.post(f"/api/v1/lms/lessons/{self.lesson1.id}/complete/") + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertTrue(response.data["is_completed"]) + # Course has 2 lessons -> 1 completed = 50% + self.assertEqual(response.data["course_progress_percentage"], 50) + + # Verify progress endpoint + prog_res = self.client.get(f"/api/v1/lms/courses/{self.course.id}/progress/") + self.assertEqual(prog_res.status_code, status.HTTP_200_OK) + self.assertEqual(prog_res.data["progress_percentage"], 50) + self.assertEqual(prog_res.data["completed_lessons_count"], 1) + self.assertEqual(prog_res.data["total_lessons_count"], 2) + self.assertFalse(prog_res.data["is_completed"]) + + def test_quiz_submission_scoring_and_certificate_issuance(self): + self.client.force_authenticate(user=self.student_user) + + # 1. Complete Lesson 1 first (50%) + self.client.post(f"/api/v1/lms/lessons/{self.lesson1.id}/complete/") + + # 2. Submit FAILING quiz (1 wrong, 1 correct -> 50% < 70%) + fail_payload = { + "answers": [ + {"question_id": self.q1.id, "selected_option": 0}, # Correct + {"question_id": self.q2.id, "selected_option": 0}, # Wrong + ] + } + fail_res = self.client.post(f"/api/v1/lms/lessons/{self.lesson2.id}/submit-quiz/", fail_payload, format="json") + self.assertEqual(fail_res.status_code, status.HTTP_200_OK) + self.assertEqual(fail_res.data["score"], 50.0) + self.assertFalse(fail_res.data["passed"]) + self.assertFalse(fail_res.data["is_lesson_completed"]) + self.assertEqual(fail_res.data["course_progress_percentage"], 50) + + # 3. Submit PASSING quiz (2 correct -> 100% >= 70%) + pass_payload = { + "answers": [ + {"question_id": self.q1.id, "selected_option": 0}, # Correct + {"question_id": self.q2.id, "selected_option": 1}, # Correct + ] + } + pass_res = self.client.post(f"/api/v1/lms/lessons/{self.lesson2.id}/submit-quiz/", pass_payload, format="json") + self.assertEqual(pass_res.status_code, status.HTTP_200_OK) + self.assertEqual(pass_res.data["score"], 100.0) + self.assertTrue(pass_res.data["passed"]) + self.assertTrue(pass_res.data["is_lesson_completed"]) + # All 2 lessons completed -> 100% course progress + self.assertEqual(pass_res.data["course_progress_percentage"], 100) + self.assertIsNotNone(pass_res.data["certificate_code"]) + + cert_code = pass_res.data["certificate_code"] + self.assertTrue(cert_code.startswith("CERT-RAZAVI-")) + + # 4. Check DigitalCertificate model persisted + cert = DigitalCertificate.objects.get(certificate_code=cert_code) + self.assertEqual(cert.user, self.student_user) + self.assertEqual(cert.course, self.course) + self.assertEqual(cert.final_score, 100.0) + + # 5. Verify via Public Verification Endpoint (No auth required) + anon_client = APIClient() + verify_res = anon_client.get(f"/api/v1/lms/certificates/{cert_code}/") + self.assertEqual(verify_res.status_code, status.HTTP_200_OK) + self.assertTrue(verify_res.data["is_valid"]) + self.assertEqual(verify_res.data["recipient_name"], "Ali Rezaei") + self.assertEqual(verify_res.data["course_title"], self.course.title) + self.assertEqual(verify_res.data["final_score"], 100.0) + + # 6. Verify via User Certificates Endpoint + my_certs_res = self.client.get("/api/v1/lms/my-certificates/") + self.assertEqual(my_certs_res.status_code, status.HTTP_200_OK) + self.assertEqual(len(my_certs_res.data), 1) + self.assertEqual(my_certs_res.data[0]["certificate_code"], cert_code) diff --git a/apps/lms/urls.py b/apps/lms/urls.py new file mode 100644 index 0000000..715445f --- /dev/null +++ b/apps/lms/urls.py @@ -0,0 +1,31 @@ +from django.urls import path +from apps.lms.views import ( + CourseListCreateView, + CourseDetailView, + CourseEnrollView, + CourseProgressView, + LessonDetailView, + LessonCompleteView, + LessonQuizSubmitView, + DigitalCertificateListView, + PublicCertificateVerifyView, +) + +urlpatterns = [ + # Course Catalog & Management + path('courses/', CourseListCreateView.as_view(), name='lms_course_list_create'), + path('courses//', CourseDetailView.as_view(), name='lms_course_detail'), + + # Enrollment & Progress + path('courses//enroll/', CourseEnrollView.as_view(), name='lms_course_enroll'), + path('courses//progress/', CourseProgressView.as_view(), name='lms_course_progress'), + + # Lessons & Interactive Quizzes + path('lessons//', LessonDetailView.as_view(), name='lms_lesson_detail'), + path('lessons//complete/', LessonCompleteView.as_view(), name='lms_lesson_complete'), + path('lessons//submit-quiz/', LessonQuizSubmitView.as_view(), name='lms_lesson_submit_quiz'), + + # Certificates & Verification + path('my-certificates/', DigitalCertificateListView.as_view(), name='lms_my_certificates'), + path('certificates//', PublicCertificateVerifyView.as_view(), name='lms_certificate_verify'), +] diff --git a/apps/lms/views/__init__.py b/apps/lms/views/__init__.py new file mode 100644 index 0000000..7b7557f --- /dev/null +++ b/apps/lms/views/__init__.py @@ -0,0 +1,23 @@ +from apps.lms.views.course_views import ( + CourseListCreateView, + CourseDetailView, + CourseEnrollView, + CourseProgressView, + LessonDetailView, + LessonCompleteView, + LessonQuizSubmitView, + DigitalCertificateListView, + PublicCertificateVerifyView, +) + +__all__ = [ + 'CourseListCreateView', + 'CourseDetailView', + 'CourseEnrollView', + 'CourseProgressView', + 'LessonDetailView', + 'LessonCompleteView', + 'LessonQuizSubmitView', + 'DigitalCertificateListView', + 'PublicCertificateVerifyView', +] diff --git a/apps/lms/views/course_views.py b/apps/lms/views/course_views.py new file mode 100644 index 0000000..af12025 --- /dev/null +++ b/apps/lms/views/course_views.py @@ -0,0 +1,463 @@ +import logging +from django.db.models import Q +from django.shortcuts import get_object_or_404 +from django.utils import timezone +from django.utils.translation import gettext_lazy as _ +from rest_framework import status +from rest_framework.views import APIView +from rest_framework.generics import GenericAPIView +from rest_framework.permissions import AllowAny, IsAuthenticated +from rest_framework.response import Response +from drf_spectacular.utils import extend_schema, OpenApiResponse, OpenApiParameter + +from apps.lms.models import ( + Course, + CourseModule, + Lesson, + QuizQuestion, + CourseEnrollment, + LessonProgress, + DigitalCertificate, +) +from apps.lms.permissions import IsCourseInstructorOrAdmin +from apps.lms.serializers import ( + CourseListSerializer, + CourseDetailSerializer, + CourseCreateUpdateSerializer, + CourseModuleSerializer, + LessonDetailSerializer, + QuizSubmitRequestSerializer, + QuizResultSerializer, + CourseProgressSerializer, + LessonCompleteResponseSerializer, + DigitalCertificateSerializer, + PublicCertificateVerifySerializer, +) + +logger = logging.getLogger(__name__) + + +def get_course_by_pk_or_slug(pk_or_slug, queryset=None): + if queryset is None: + queryset = Course.objects.all() + if pk_or_slug.isdigit(): + return get_object_or_404(queryset, pk=int(pk_or_slug)) + return get_object_or_404(queryset, slug=pk_or_slug) + + +class CourseListCreateView(GenericAPIView): + permission_classes = [AllowAny] + serializer_class = CourseListSerializer + queryset = Course.objects.filter(is_published=True) + + @extend_schema( + summary="List online academy courses", + description="Retrieves a list of published courses with category, language, level, and keyword filtering.", + parameters=[ + OpenApiParameter('category', str, description="Filter by course category (e.g. razavi_teachings, cultural_diplomacy)"), + OpenApiParameter('language', str, description="Filter by language code (fa, ar, en, ur, fr)"), + OpenApiParameter('level', str, description="Filter by difficulty level (introductory, intermediate, advanced)"), + OpenApiParameter('search', str, description="Keyword search in title, instructor, or description"), + OpenApiParameter('featured', bool, description="Filter only featured courses"), + ], + responses={200: CourseListSerializer(many=True)}, + tags=["LMS - Online Academy"], + ) + def get(self, request, *args, **kwargs): + qs = Course.objects.all() + # If user is not staff, only show published courses + if not (request.user and request.user.is_authenticated and request.user.is_staff): + qs = qs.filter(is_published=True) + + category = request.query_params.get('category') + if category: + qs = qs.filter(category=category) + + language = request.query_params.get('language') + if language: + qs = qs.filter(language=language) + + level = request.query_params.get('level') + if level: + qs = qs.filter(level=level) + + featured = request.query_params.get('featured') + if featured is not None: + qs = qs.filter(is_featured=featured.lower() in ('true', '1')) + + search = request.query_params.get('search') + if search: + qs = qs.filter( + Q(title__icontains=search) | + Q(description__icontains=search) | + Q(instructor_name__icontains=search) | + Q(organization_name__icontains=search) + ) + + serializer = self.get_serializer(qs, many=True, context={'request': request}) + return Response(serializer.data, status=status.HTTP_200_OK) + + @extend_schema( + summary="Create a new course", + description="Creates a new LMS course. Creator is automatically set as course author.", + request=CourseCreateUpdateSerializer, + responses={ + 201: CourseDetailSerializer, + 400: OpenApiResponse(description="Validation error"), + 403: OpenApiResponse(description="Permission denied"), + }, + tags=["LMS - Online Academy"], + ) + def post(self, request, *args, **kwargs): + if not request.user or not request.user.is_authenticated: + return Response({'detail': _("Authentication credentials were not provided.")}, status=status.HTTP_401_UNAUTHORIZED) + + serializer = CourseCreateUpdateSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + course = serializer.save(created_by=request.user) + + response_serializer = CourseDetailSerializer(course, context={'request': request}) + return Response(response_serializer.data, status=status.HTTP_201_CREATED) + + +class CourseDetailView(GenericAPIView): + permission_classes = [AllowAny] + serializer_class = CourseDetailSerializer + queryset = Course.objects.all() + + @extend_schema( + summary="Retrieve course syllabus and details", + description="Returns full course information, curriculum modules, lessons list, instructor details, and authenticated user enrollment status.", + responses={ + 200: CourseDetailSerializer, + 404: OpenApiResponse(description="Course not found"), + }, + tags=["LMS - Online Academy"], + ) + def get(self, request, pk_or_slug, *args, **kwargs): + course = get_course_by_pk_or_slug(pk_or_slug, self.get_queryset()) + serializer = self.get_serializer(course, context={'request': request}) + return Response(serializer.data, status=status.HTTP_200_OK) + + @extend_schema( + summary="Update course metadata", + description="Partially updates course information. Restricted to instructor or platform administrator.", + request=CourseCreateUpdateSerializer, + responses={ + 200: CourseDetailSerializer, + 403: OpenApiResponse(description="Permission denied"), + }, + tags=["LMS - Online Academy"], + ) + def patch(self, request, pk_or_slug, *args, **kwargs): + course = get_course_by_pk_or_slug(pk_or_slug, self.get_queryset()) + self.check_object_permissions(request, course) + + serializer = CourseCreateUpdateSerializer(course, data=request.data, partial=True) + serializer.is_valid(raise_exception=True) + serializer.save() + + response_serializer = CourseDetailSerializer(course, context={'request': request}) + return Response(response_serializer.data, status=status.HTTP_200_OK) + + @extend_schema( + summary="Delete a course", + description="Deletes a course and its modules. Restricted to instructor or platform administrator.", + responses={204: OpenApiResponse(description="Course deleted")}, + tags=["LMS - Online Academy"], + ) + def delete(self, request, pk_or_slug, *args, **kwargs): + course = get_course_by_pk_or_slug(pk_or_slug, self.get_queryset()) + self.check_object_permissions(request, course) + course.delete() + return Response(status=status.HTTP_204_NO_CONTENT) + + +class CourseEnrollView(GenericAPIView): + permission_classes = [IsAuthenticated] + serializer_class = CourseDetailSerializer + queryset = Course.objects.all() + + @extend_schema( + summary="Enroll current user in course", + description="Enrolls the authenticated user into the specified course. Returns enrollment status.", + responses={ + 200: OpenApiResponse(description="Already enrolled"), + 201: OpenApiResponse(description="Successfully enrolled"), + 404: OpenApiResponse(description="Course not found"), + }, + tags=["LMS - Online Academy"], + ) + def post(self, request, pk_or_slug, *args, **kwargs): + course = get_course_by_pk_or_slug(pk_or_slug, self.get_queryset()) + + enrollment, created = CourseEnrollment.objects.get_or_create( + user=request.user, + course=course + ) + + status_code = status.HTTP_201_CREATED if created else status.HTTP_200_OK + return Response({ + 'message': _("Enrolled successfully.") if created else _("Already enrolled in this course."), + 'course_id': course.id, + 'course_title': course.title, + 'enrolled_at': enrollment.enrolled_at, + 'progress_percentage': enrollment.progress_percentage, + }, status=status_code) + + +class CourseProgressView(GenericAPIView): + permission_classes = [IsAuthenticated] + serializer_class = CourseProgressSerializer + queryset = Course.objects.all() + + @extend_schema( + summary="Get user progress in course", + description="Returns completion percentage, completed lessons tally, and certificate code if finished.", + responses={ + 200: CourseProgressSerializer, + 404: OpenApiResponse(description="Course or enrollment not found"), + }, + tags=["LMS - Online Academy"], + ) + def get(self, request, pk_or_slug, *args, **kwargs): + course = get_course_by_pk_or_slug(pk_or_slug, self.get_queryset()) + enrollment = CourseEnrollment.objects.filter(user=request.user, course=course).first() + + all_lesson_ids = Lesson.objects.filter(module__course=course).values_list('id', flat=True) + total_lessons = len(all_lesson_ids) + completed_lessons = LessonProgress.objects.filter( + user=request.user, + lesson_id__in=all_lesson_ids, + is_completed=True + ).count() + + cert = DigitalCertificate.objects.filter(user=request.user, course=course).first() + + data = { + 'course_id': course.id, + 'course_title': course.title, + 'progress_percentage': enrollment.progress_percentage if enrollment else 0, + 'completed_lessons_count': completed_lessons, + 'total_lessons_count': total_lessons, + 'is_completed': (enrollment.progress_percentage >= 100) if enrollment else False, + 'certificate_code': cert.certificate_code if cert else None, + } + return Response(data, status=status.HTTP_200_OK) + + +class LessonDetailView(GenericAPIView): + permission_classes = [AllowAny] + serializer_class = LessonDetailSerializer + queryset = Lesson.objects.all() + + @extend_schema( + summary="Get lesson details and content", + description="Retrieves lesson video link, markdown content, slides attachment, and quiz questions.", + responses={ + 200: LessonDetailSerializer, + 404: OpenApiResponse(description="Lesson not found"), + }, + tags=["LMS - Online Academy"], + ) + def get(self, request, pk, *args, **kwargs): + lesson = get_object_or_404(Lesson, pk=pk) + serializer = self.get_serializer(lesson, context={'request': request}) + return Response(serializer.data, status=status.HTTP_200_OK) + + +class LessonCompleteView(GenericAPIView): + permission_classes = [IsAuthenticated] + serializer_class = LessonCompleteResponseSerializer + queryset = Lesson.objects.all() + + @extend_schema( + summary="Mark lesson as complete", + description="Marks a reading or video lesson as complete. Automatically recalculates course progress and issues certificate if 100%.", + responses={ + 200: LessonCompleteResponseSerializer, + 404: OpenApiResponse(description="Lesson not found"), + }, + tags=["LMS - Online Academy"], + ) + def post(self, request, pk, *args, **kwargs): + lesson = get_object_or_404(Lesson, pk=pk) + course = lesson.module.course + + # Ensure enrollment exists + enrollment, _created = CourseEnrollment.objects.get_or_create( + user=request.user, + course=course + ) + + progress, _p_created = LessonProgress.objects.get_or_create( + user=request.user, + lesson=lesson + ) + progress.is_completed = True + progress.completed_at = timezone.now() + progress.save() + + # Recalculate course overall progress + new_progress = enrollment.recalculate_progress() + + cert = DigitalCertificate.objects.filter(user=request.user, course=course).first() + + return Response({ + 'message': _("Lesson marked as complete."), + 'lesson_id': lesson.id, + 'is_completed': True, + 'course_progress_percentage': new_progress, + 'certificate_code': cert.certificate_code if cert else None, + }, status=status.HTTP_200_OK) + + +class LessonQuizSubmitView(GenericAPIView): + permission_classes = [IsAuthenticated] + serializer_class = QuizSubmitRequestSerializer + queryset = Lesson.objects.all() + + @extend_schema( + summary="Submit quiz answers and evaluate score", + description="Evaluates user quiz responses. If score >= 70%, marks lesson complete, recalculates progress, and issues certificate if 100%.", + request=QuizSubmitRequestSerializer, + responses={ + 200: QuizResultSerializer, + 400: OpenApiResponse(description="Invalid submission"), + 404: OpenApiResponse(description="Lesson not found"), + }, + tags=["LMS - Online Academy"], + ) + def post(self, request, pk, *args, **kwargs): + lesson = get_object_or_404(Lesson, pk=pk) + course = lesson.module.course + + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + answers = serializer.validated_data['answers'] + + # Ensure user is enrolled + enrollment, _e_created = CourseEnrollment.objects.get_or_create( + user=request.user, + course=course + ) + + questions = lesson.quiz_questions.all().order_by('order', 'id') + total_questions = questions.count() + + if total_questions == 0: + return Response({'error': _("No questions found in this quiz.")}, status=status.HTTP_400_BAD_REQUEST) + + # Map answers + answers_map = {ans['question_id']: ans['selected_option'] for ans in answers} + + correct_count = 0 + detailed_results = [] + + for q in questions: + user_choice = answers_map.get(q.id) + is_correct = (user_choice == q.correct_option_index) + if is_correct: + correct_count += 1 + detailed_results.append({ + 'question_id': q.id, + 'is_correct': is_correct, + 'selected_option': user_choice if user_choice is not None else -1, + 'correct_option': q.correct_option_index, + 'explanation': q.explanation or '', + }) + + score = round((correct_count / total_questions) * 100.0, 1) + passed = score >= 70.0 + + progress, _prog_created = LessonProgress.objects.get_or_create( + user=request.user, + lesson=lesson + ) + progress.quiz_score = score + if passed: + progress.is_completed = True + progress.completed_at = timezone.now() + progress.save() + + # Recalculate progress if passed + new_progress = enrollment.recalculate_progress() + cert = DigitalCertificate.objects.filter(user=request.user, course=course).first() + + result_data = { + 'score': score, + 'passed': passed, + 'total_questions': total_questions, + 'correct_count': correct_count, + 'question_results': detailed_results, + 'is_lesson_completed': progress.is_completed, + 'course_progress_percentage': new_progress, + 'certificate_code': cert.certificate_code if cert else None, + } + return Response(result_data, status=status.HTTP_200_OK) + + +class DigitalCertificateListView(GenericAPIView): + permission_classes = [IsAuthenticated] + serializer_class = DigitalCertificateSerializer + queryset = DigitalCertificate.objects.all() + + def get_queryset(self): + if getattr(self, 'swagger_fake_view', False) or not self.request.user.is_authenticated: + return DigitalCertificate.objects.none() + return DigitalCertificate.objects.filter(user=self.request.user, is_revoked=False) + + @extend_schema( + summary="List user earned digital certificates", + description="Retrieves a list of all official digital certificates earned by the authenticated user across completed academy courses.", + responses={200: DigitalCertificateSerializer(many=True)}, + tags=["LMS - Online Academy"], + ) + def get(self, request, *args, **kwargs): + queryset = self.get_queryset() + serializer = self.get_serializer(queryset, many=True) + return Response(serializer.data, status=status.HTTP_200_OK) + + +class PublicCertificateVerifyView(GenericAPIView): + permission_classes = [AllowAny] + serializer_class = PublicCertificateVerifySerializer + queryset = DigitalCertificate.objects.all() + + @extend_schema( + summary="Verify digital certificate authenticity (Public / QR)", + description="Public endpoint for verifying credentials by certificate code or UUID. Does not require login.", + responses={ + 200: PublicCertificateVerifySerializer, + 404: OpenApiResponse(description="Certificate code not found"), + }, + tags=["LMS - Online Academy"], + ) + def get(self, request, code, *args, **kwargs): + # Look up by code or uuid + cert = None + try: + cert = DigitalCertificate.objects.filter( + Q(certificate_code__iexact=code.strip()) | + Q(uuid__iexact=code.strip()) + ).first() + except Exception: + pass + + if not cert: + return Response({ + 'is_valid': False, + 'detail': _("Certificate with this verification code was not found or has been revoked.") + }, status=status.HTTP_404_NOT_FOUND) + + data = { + 'certificate_code': cert.certificate_code, + 'uuid': cert.uuid, + 'recipient_name': cert.user.get_full_name(), + 'course_title': cert.course.title, + 'organization': cert.course.organization_name or (_("Razavi International Hub") if not cert.course.institution else cert.course.institution.name), + 'issue_date': cert.issue_date, + 'final_score': cert.final_score, + 'is_valid': not cert.is_revoked, + } + return Response(data, status=status.HTTP_200_OK) diff --git a/apps/tickets/__init__.py b/apps/tickets/__init__.py new file mode 100644 index 0000000..91657d4 --- /dev/null +++ b/apps/tickets/__init__.py @@ -0,0 +1 @@ +# Tickets app diff --git a/apps/tickets/admin.py b/apps/tickets/admin.py new file mode 100644 index 0000000..5dff847 --- /dev/null +++ b/apps/tickets/admin.py @@ -0,0 +1,27 @@ +from django.contrib import admin +from unfold.admin import ModelAdmin, TabularInline +from apps.tickets.models import Ticket, TicketMessage + + +class TicketMessageInline(TabularInline): + model = TicketMessage + extra = 1 + fields = ('sender', 'is_official_response', 'department_label', 'content', 'attachment', 'created_at') + readonly_fields = ('created_at',) + + +@admin.register(Ticket) +class TicketAdmin(ModelAdmin): + list_display = ('tracking_code', 'subject', 'category', 'priority', 'status', 'user', 'institution', 'satisfaction_rating', 'created_at') + list_filter = ('status', 'priority', 'category', 'created_at') + search_fields = ('tracking_code', 'subject', 'user__email', 'user__fullname', 'institution__name') + readonly_fields = ('tracking_code', 'created_at', 'updated_at', 'closed_at') + inlines = [TicketMessageInline] + + +@admin.register(TicketMessage) +class TicketMessageAdmin(ModelAdmin): + list_display = ('ticket', 'sender', 'is_official_response', 'department_label', 'created_at') + list_filter = ('is_official_response', 'created_at') + search_fields = ('ticket__tracking_code', 'content', 'sender__email', 'sender__fullname') + readonly_fields = ('created_at',) diff --git a/apps/tickets/apps.py b/apps/tickets/apps.py new file mode 100644 index 0000000..74e1c12 --- /dev/null +++ b/apps/tickets/apps.py @@ -0,0 +1,8 @@ +from django.apps import AppConfig +from django.utils.translation import gettext_lazy as _ + + +class TicketsConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'apps.tickets' + verbose_name = _('Official Inquiries & Support Ticketing') diff --git a/apps/tickets/migrations/0001_initial.py b/apps/tickets/migrations/0001_initial.py new file mode 100644 index 0000000..85e16f0 --- /dev/null +++ b/apps/tickets/migrations/0001_initial.py @@ -0,0 +1,60 @@ +# Generated by Django 4.2.30 on 2026-09-15 13:16 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('profiles', '0001_initial'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Ticket', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('tracking_code', models.CharField(blank=True, help_text='e.g. TK-RAZAVI-2026-AB12CD', max_length=100, unique=True, verbose_name='Tracking Code')), + ('subject', models.CharField(max_length=255, verbose_name='Subject / Title')), + ('category', models.CharField(choices=[('cultural_affairs', 'Cultural & International Affairs'), ('religious_inquiry', 'Religious & Theological Inquiry'), ('inter_center_cooperation', 'Inter-Center Cooperation & Partnerships'), ('financial_legal', 'Financial & Legal Affairs'), ('technical_support', 'Technical & Portal Support'), ('general', 'General Inquiry')], default='general', max_length=50, verbose_name='Category')), + ('priority', models.CharField(choices=[('urgent', 'Urgent'), ('high', 'High'), ('medium', 'Medium'), ('low', 'Low')], default='medium', max_length=20, verbose_name='Priority')), + ('status', models.CharField(choices=[('open', 'Open'), ('in_progress', 'In Progress'), ('answered', 'Official Response Provided'), ('closed', 'Closed')], default='open', max_length=20, verbose_name='Status')), + ('satisfaction_rating', models.PositiveSmallIntegerField(blank=True, null=True, verbose_name='Satisfaction Rating (1-5 Stars)')), + ('feedback_comment', models.TextField(blank=True, null=True, verbose_name='User Feedback Notes')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated At')), + ('closed_at', models.DateTimeField(blank=True, null=True, verbose_name='Closed At')), + ('assigned_expert', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='assigned_tickets', to=settings.AUTH_USER_MODEL, verbose_name='Assigned Expert / Officer')), + ('institution', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='tickets', to='profiles.institution', verbose_name='Affiliated Institution')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='tickets', to=settings.AUTH_USER_MODEL, verbose_name='Submitter / User')), + ], + options={ + 'verbose_name': 'Ticket', + 'verbose_name_plural': 'Tickets', + 'ordering': ('-created_at',), + }, + ), + migrations.CreateModel( + name='TicketMessage', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('is_official_response', models.BooleanField(default=False, verbose_name='Is Official Response from Astan / Expert')), + ('department_label', models.CharField(blank=True, default='', max_length=255, verbose_name='Department / Authority Label')), + ('content', models.TextField(verbose_name='Message Content')), + ('attachment', models.FileField(blank=True, null=True, upload_to='tickets/attachments/%Y/%m/', verbose_name='Attachment')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), + ('sender', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='ticket_messages', to=settings.AUTH_USER_MODEL, verbose_name='Sender')), + ('ticket', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='messages', to='tickets.ticket', verbose_name='Ticket')), + ], + options={ + 'verbose_name': 'Ticket Message', + 'verbose_name_plural': 'Ticket Messages', + 'ordering': ('created_at',), + }, + ), + ] diff --git a/apps/tickets/migrations/__init__.py b/apps/tickets/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/tickets/models/__init__.py b/apps/tickets/models/__init__.py new file mode 100644 index 0000000..bee471c --- /dev/null +++ b/apps/tickets/models/__init__.py @@ -0,0 +1,15 @@ +from apps.tickets.models.ticket import ( + Ticket, + TicketMessage, + TicketCategory, + TicketPriority, + TicketStatus, +) + +__all__ = [ + 'Ticket', + 'TicketMessage', + 'TicketCategory', + 'TicketPriority', + 'TicketStatus', +] diff --git a/apps/tickets/models/ticket.py b/apps/tickets/models/ticket.py new file mode 100644 index 0000000..dc44669 --- /dev/null +++ b/apps/tickets/models/ticket.py @@ -0,0 +1,149 @@ +import random +from django.conf import settings +from django.db import models +from django.utils import timezone +from django.utils.translation import gettext_lazy as _ +from apps.profiles.models import Institution + + +class TicketCategory(models.TextChoices): + CULTURAL_AFFAIRS = 'cultural_affairs', _('Cultural & International Affairs') + RELIGIOUS_INQUIRY = 'religious_inquiry', _('Religious & Theological Inquiry') + INTER_CENTER_COOPERATION = 'inter_center_cooperation', _('Inter-Center Cooperation & Partnerships') + FINANCIAL_LEGAL = 'financial_legal', _('Financial & Legal Affairs') + TECHNICAL_SUPPORT = 'technical_support', _('Technical & Portal Support') + GENERAL = 'general', _('General Inquiry') + + +class TicketPriority(models.TextChoices): + URGENT = 'urgent', _('Urgent') + HIGH = 'high', _('High') + MEDIUM = 'medium', _('Medium') + LOW = 'low', _('Low') + + +class TicketStatus(models.TextChoices): + OPEN = 'open', _('Open') + IN_PROGRESS = 'in_progress', _('In Progress') + ANSWERED = 'answered', _('Official Response Provided') + CLOSED = 'closed', _('Closed') + + +class Ticket(models.Model): + tracking_code = models.CharField( + max_length=100, + unique=True, + blank=True, + verbose_name=_('Tracking Code'), + help_text=_('e.g. TK-RAZAVI-2026-AB12CD') + ) + subject = models.CharField(max_length=255, verbose_name=_('Subject / Title')) + category = models.CharField( + max_length=50, + choices=TicketCategory.choices, + default=TicketCategory.GENERAL, + verbose_name=_('Category') + ) + priority = models.CharField( + max_length=20, + choices=TicketPriority.choices, + default=TicketPriority.MEDIUM, + verbose_name=_('Priority') + ) + status = models.CharField( + max_length=20, + choices=TicketStatus.choices, + default=TicketStatus.OPEN, + verbose_name=_('Status') + ) + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name='tickets', + verbose_name=_('Submitter / User') + ) + institution = models.ForeignKey( + Institution, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name='tickets', + verbose_name=_('Affiliated Institution') + ) + assigned_expert = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name='assigned_tickets', + verbose_name=_('Assigned Expert / Officer') + ) + satisfaction_rating = models.PositiveSmallIntegerField( + null=True, + blank=True, + verbose_name=_('Satisfaction Rating (1-5 Stars)') + ) + feedback_comment = models.TextField( + blank=True, + null=True, + verbose_name=_('User Feedback Notes') + ) + created_at = models.DateTimeField(auto_now_add=True, verbose_name=_('Created At')) + updated_at = models.DateTimeField(auto_now=True, verbose_name=_('Updated At')) + closed_at = models.DateTimeField(null=True, blank=True, verbose_name=_('Closed At')) + + class Meta: + ordering = ('-created_at',) + verbose_name = _('Ticket') + verbose_name_plural = _('Tickets') + + def __str__(self): + return f"{self.tracking_code} - {self.subject} ({self.get_status_display()})" + + def save(self, *args, **kwargs): + if not self.tracking_code: + year = timezone.now().year + rand_code = ''.join(random.choices('ABCDEFGHJKLMNPQRSTUVWXYZ23456789', k=6)) + self.tracking_code = f"TK-RAZAVI-{year}-{rand_code}" + super().save(*args, **kwargs) + + +class TicketMessage(models.Model): + ticket = models.ForeignKey( + Ticket, + on_delete=models.CASCADE, + related_name='messages', + verbose_name=_('Ticket') + ) + sender = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name='ticket_messages', + verbose_name=_('Sender') + ) + is_official_response = models.BooleanField( + default=False, + verbose_name=_('Is Official Response from Astan / Expert') + ) + department_label = models.CharField( + max_length=255, + blank=True, + default='', + verbose_name=_('Department / Authority Label') + ) + content = models.TextField(verbose_name=_('Message Content')) + attachment = models.FileField( + upload_to='tickets/attachments/%Y/%m/', + blank=True, + null=True, + verbose_name=_('Attachment') + ) + created_at = models.DateTimeField(auto_now_add=True, verbose_name=_('Created At')) + + class Meta: + ordering = ('created_at',) + verbose_name = _('Ticket Message') + verbose_name_plural = _('Ticket Messages') + + def __str__(self): + return f"Message on {self.ticket.tracking_code} by {self.sender} ({self.created_at})" diff --git a/apps/tickets/permissions.py b/apps/tickets/permissions.py new file mode 100644 index 0000000..5c4f8da --- /dev/null +++ b/apps/tickets/permissions.py @@ -0,0 +1,24 @@ +from rest_framework.permissions import BasePermission, SAFE_METHODS + + +class IsTicketOwnerOrStaff(BasePermission): + """ + Grants access to the ticket creator, member of affiliated institution, or staff/admin. + """ + def has_permission(self, request, view): + return bool(request.user and request.user.is_authenticated and request.user.is_active) + + def has_object_permission(self, request, view, obj): + if not request.user or not request.user.is_authenticated: + return False + + if request.user.is_staff or getattr(request.user, 'is_super_admin', False) or getattr(request.user, 'is_regional_admin', False): + return True + + if obj.user == request.user: + return True + + if obj.institution and obj.institution.is_editor(request.user): + return True + + return False diff --git a/apps/tickets/serializers/__init__.py b/apps/tickets/serializers/__init__.py new file mode 100644 index 0000000..e432c89 --- /dev/null +++ b/apps/tickets/serializers/__init__.py @@ -0,0 +1,17 @@ +from apps.tickets.serializers.ticket_serializers import ( + TicketMessageSerializer, + TicketMessageCreateSerializer, + TicketListSerializer, + TicketDetailSerializer, + TicketCreateSerializer, + TicketRateSerializer, +) + +__all__ = [ + 'TicketMessageSerializer', + 'TicketMessageCreateSerializer', + 'TicketListSerializer', + 'TicketDetailSerializer', + 'TicketCreateSerializer', + 'TicketRateSerializer', +] diff --git a/apps/tickets/serializers/ticket_serializers.py b/apps/tickets/serializers/ticket_serializers.py new file mode 100644 index 0000000..78ed91f --- /dev/null +++ b/apps/tickets/serializers/ticket_serializers.py @@ -0,0 +1,94 @@ +from rest_framework import serializers +from apps.tickets.models import ( + Ticket, + TicketMessage, + TicketCategory, + TicketPriority, + TicketStatus, +) + + +class TicketMessageSerializer(serializers.ModelSerializer): + sender_name = serializers.CharField(source='sender.get_full_name', read_only=True) + sender_id = serializers.IntegerField(source='sender.id', read_only=True) + + class Meta: + model = TicketMessage + fields = [ + 'id', 'sender_id', 'sender_name', + 'is_official_response', 'department_label', + 'content', 'attachment', 'created_at', + ] + read_only_fields = ['id', 'sender_id', 'sender_name', 'is_official_response', 'created_at'] + + +class TicketMessageCreateSerializer(serializers.ModelSerializer): + class Meta: + model = TicketMessage + fields = ['content', 'attachment', 'department_label'] + + +class TicketListSerializer(serializers.ModelSerializer): + submitter_name = serializers.CharField(source='user.get_full_name', read_only=True) + institution_name = serializers.CharField(source='institution.name', read_only=True) + messages_count = serializers.IntegerField(source='messages.count', read_only=True) + + class Meta: + model = Ticket + fields = [ + 'id', 'tracking_code', 'subject', 'category', + 'priority', 'status', 'submitter_name', + 'institution', 'institution_name', 'messages_count', + 'satisfaction_rating', 'created_at', 'updated_at', 'closed_at', + ] + + +class TicketDetailSerializer(serializers.ModelSerializer): + submitter_name = serializers.CharField(source='user.get_full_name', read_only=True) + institution_name = serializers.CharField(source='institution.name', read_only=True) + expert_name = serializers.CharField(source='assigned_expert.get_full_name', read_only=True) + messages = TicketMessageSerializer(many=True, read_only=True) + + class Meta: + model = Ticket + fields = [ + 'id', 'tracking_code', 'subject', 'category', + 'priority', 'status', 'submitter_name', + 'institution', 'institution_name', 'assigned_expert', + 'expert_name', 'satisfaction_rating', 'feedback_comment', + 'messages', 'created_at', 'updated_at', 'closed_at', + ] + + +class TicketCreateSerializer(serializers.ModelSerializer): + content = serializers.CharField(write_only=True, required=True, help_text="Initial message description") + attachment = serializers.FileField(write_only=True, required=False, allow_null=True) + + class Meta: + model = Ticket + fields = [ + 'id', 'tracking_code', 'subject', 'category', + 'priority', 'institution', 'content', 'attachment', + ] + read_only_fields = ['id', 'tracking_code'] + + def create(self, validated_data): + content = validated_data.pop('content') + attachment = validated_data.pop('attachment', None) + user = self.context['request'].user + + ticket = Ticket.objects.create(user=user, **validated_data) + + TicketMessage.objects.create( + ticket=ticket, + sender=user, + content=content, + attachment=attachment, + is_official_response=False, + ) + return ticket + + +class TicketRateSerializer(serializers.Serializer): + rating = serializers.IntegerField(min_value=1, max_value=5, help_text="1 to 5 stars") + feedback = serializers.CharField(required=False, allow_blank=True) diff --git a/apps/tickets/tests/__init__.py b/apps/tickets/tests/__init__.py new file mode 100644 index 0000000..48a405a --- /dev/null +++ b/apps/tickets/tests/__init__.py @@ -0,0 +1 @@ +# Tickets tests diff --git a/apps/tickets/tests/test_phase9_tickets.py b/apps/tickets/tests/test_phase9_tickets.py new file mode 100644 index 0000000..45e2bdb --- /dev/null +++ b/apps/tickets/tests/test_phase9_tickets.py @@ -0,0 +1,146 @@ +from django.test import TestCase +from django.contrib.auth import get_user_model +from rest_framework.test import APIClient +from rest_framework import status + +from apps.profiles.models import Institution +from apps.tickets.models import Ticket, TicketMessage, TicketStatus, TicketPriority, TicketCategory + +User = get_user_model() + + +class Phase9TicketsAPITests(TestCase): + """ + Automated test suite for Phase 9: Official Ticketing + - Ticket Submission with Tracking Code Generation + - Ticket List & Filtering + - Ticket Detail & Message History + - User Reply & Official Astan Response Flow + - Ticket Closure & Satisfaction Rating + """ + + def setUp(self): + self.client = APIClient() + + self.user = User.objects.create_user( + email="representative@beirutcenter.org", + password="Password123!", + fullname="Sheikh Ali Ammar", + user_type=User.UserType.INSTITUTION_ADMIN, + ) + self.staff_user = User.objects.create_user( + email="officer@astan.ir", + password="AdminPassword123!", + fullname="Mohammad Hosseini", + user_type=User.UserType.SUPER_ADMIN, + is_staff=True, + ) + self.institution = Institution.objects.create( + name="Imam Ali Cultural Center Beirut", + country="Lebanon", + city="Beirut", + type=Institution.InstitutionType.CULTURAL_CENTER, + ) + + def test_ticket_creation_and_auto_tracking_code(self): + self.client.force_authenticate(user=self.user) + payload = { + "subject": "Request for specialized cultural books and exhibition materials", + "category": TicketCategory.CULTURAL_AFFAIRS, + "priority": TicketPriority.HIGH, + "institution": self.institution.id, + "content": "We are organizing the annual Razavi Cultural Week in Beirut and request books in Arabic.", + } + response = self.client.post("/api/v1/tickets/", payload, format="json") + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertTrue(response.data["tracking_code"].startswith("TK-RAZAVI-")) + self.assertEqual(response.data["status"], TicketStatus.OPEN) + self.assertEqual(len(response.data["messages"]), 1) + self.assertEqual(response.data["messages"][0]["content"], payload["content"]) + + def test_ticket_listing_and_filtering(self): + self.client.force_authenticate(user=self.user) + + # Create two tickets + t1 = Ticket.objects.create( + user=self.user, + subject="Theological inquiry", + category=TicketCategory.RELIGIOUS_INQUIRY, + priority=TicketPriority.MEDIUM, + status=TicketStatus.OPEN, + ) + t2 = Ticket.objects.create( + user=self.user, + subject="Financial grant clarification", + category=TicketCategory.FINANCIAL_LEGAL, + priority=TicketPriority.URGENT, + status=TicketStatus.IN_PROGRESS, + ) + + response = self.client.get("/api/v1/tickets/") + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(len(response.data), 2) + + # Filter by priority + res_filter = self.client.get(f"/api/v1/tickets/?priority={TicketPriority.URGENT}") + self.assertEqual(res_filter.status_code, status.HTTP_200_OK) + self.assertEqual(len(res_filter.data), 1) + self.assertEqual(res_filter.data[0]["id"], t2.id) + + def test_ticket_dialogue_reply_and_official_response(self): + ticket = Ticket.objects.create( + user=self.user, + subject="Cooperation Inquiry", + category=TicketCategory.INTER_CENTER_COOPERATION, + status=TicketStatus.OPEN, + ) + + # 1. Staff replies with official response + self.client.force_authenticate(user=self.staff_user) + reply_payload = { + "content": "Your request has been approved by the Department of International Cultural Affairs.", + "department_label": "International Cultural Affairs Desk", + } + response = self.client.post(f"/api/v1/tickets/{ticket.tracking_code}/reply/", reply_payload, format="json") + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertTrue(response.data["is_official_response"]) + self.assertEqual(response.data["department_label"], "International Cultural Affairs Desk") + + # Ticket status should automatically transition to 'answered' + ticket.refresh_from_db() + self.assertEqual(ticket.status, TicketStatus.ANSWERED) + self.assertEqual(ticket.assigned_expert, self.staff_user) + + # 2. User replies back + self.client.force_authenticate(user=self.user) + user_reply = {"content": "Thank you for the prompt official confirmation."} + res_user = self.client.post(f"/api/v1/tickets/{ticket.tracking_code}/reply/", user_reply, format="json") + self.assertEqual(res_user.status_code, status.HTTP_201_CREATED) + self.assertFalse(res_user.data["is_official_response"]) + + ticket.refresh_from_db() + self.assertEqual(ticket.status, TicketStatus.IN_PROGRESS) + + def test_ticket_close_and_rating(self): + ticket = Ticket.objects.create( + user=self.user, + subject="General questions", + status=TicketStatus.ANSWERED, + ) + self.client.force_authenticate(user=self.user) + + # 1. Close ticket + close_res = self.client.post(f"/api/v1/tickets/{ticket.id}/close/") + self.assertEqual(close_res.status_code, status.HTTP_200_OK) + self.assertEqual(close_res.data["status"], TicketStatus.CLOSED) + self.assertIsNotNone(close_res.data["closed_at"]) + + # 2. Submit rating + rate_payload = { + "rating": 5, + "feedback": "Outstanding speed and support from Astan officers.", + } + rate_res = self.client.post(f"/api/v1/tickets/{ticket.id}/rate/", rate_payload, format="json") + self.assertEqual(rate_res.status_code, status.HTTP_200_OK) + self.assertEqual(rate_res.data["satisfaction_rating"], 5) + self.assertEqual(rate_res.data["feedback_comment"], rate_payload["feedback"]) diff --git a/apps/tickets/urls.py b/apps/tickets/urls.py new file mode 100644 index 0000000..6d2b095 --- /dev/null +++ b/apps/tickets/urls.py @@ -0,0 +1,17 @@ +from django.urls import path +from apps.tickets.views import ( + TicketListCreateView, + TicketDetailView, + TicketReplyView, + TicketCloseView, + TicketRateView, +) + +urlpatterns = [ + # Ticket Inquiries + path('', TicketListCreateView.as_view(), name='ticket_list_create'), + path('/', TicketDetailView.as_view(), name='ticket_detail'), + path('/reply/', TicketReplyView.as_view(), name='ticket_reply'), + path('/close/', TicketCloseView.as_view(), name='ticket_close'), + path('/rate/', TicketRateView.as_view(), name='ticket_rate'), +] diff --git a/apps/tickets/views/__init__.py b/apps/tickets/views/__init__.py new file mode 100644 index 0000000..8c64d39 --- /dev/null +++ b/apps/tickets/views/__init__.py @@ -0,0 +1,15 @@ +from apps.tickets.views.ticket_views import ( + TicketListCreateView, + TicketDetailView, + TicketReplyView, + TicketCloseView, + TicketRateView, +) + +__all__ = [ + 'TicketListCreateView', + 'TicketDetailView', + 'TicketReplyView', + 'TicketCloseView', + 'TicketRateView', +] diff --git a/apps/tickets/views/ticket_views.py b/apps/tickets/views/ticket_views.py new file mode 100644 index 0000000..f52e2a1 --- /dev/null +++ b/apps/tickets/views/ticket_views.py @@ -0,0 +1,260 @@ +import logging +from django.db.models import Q +from django.shortcuts import get_object_or_404 +from django.utils import timezone +from django.utils.translation import gettext_lazy as _ +from rest_framework import status +from rest_framework.views import APIView +from rest_framework.generics import GenericAPIView +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework.parsers import MultiPartParser, FormParser, JSONParser +from drf_spectacular.utils import extend_schema, OpenApiResponse, OpenApiParameter + +from apps.tickets.models import Ticket, TicketMessage, TicketStatus +from apps.tickets.permissions import IsTicketOwnerOrStaff +from apps.tickets.serializers import ( + TicketListSerializer, + TicketDetailSerializer, + TicketCreateSerializer, + TicketMessageSerializer, + TicketMessageCreateSerializer, + TicketRateSerializer, +) + +logger = logging.getLogger(__name__) + + +def get_ticket_by_id_or_code(pk_or_code, queryset=None): + if queryset is None: + queryset = Ticket.objects.all() + if str(pk_or_code).isdigit(): + return get_object_or_404(queryset, pk=int(pk_or_code)) + return get_object_or_404(queryset, tracking_code__iexact=str(pk_or_code).strip()) + + +class TicketListCreateView(GenericAPIView): + permission_classes = [IsAuthenticated] + parser_classes = [MultiPartParser, FormParser, JSONParser] + serializer_class = TicketListSerializer + queryset = Ticket.objects.all() + + def get_serializer_class(self): + if self.request.method == 'POST': + return TicketCreateSerializer + return TicketListSerializer + + def get_queryset(self): + if getattr(self, 'swagger_fake_view', False) or not self.request.user.is_authenticated: + return Ticket.objects.none() + + user = self.request.user + if user.is_staff or getattr(user, 'is_super_admin', False) or getattr(user, 'is_regional_admin', False): + qs = Ticket.objects.all() + else: + # User's own tickets or institution tickets + institution_ids = user.institution_memberships.values_list('institution_id', flat=True) + qs = Ticket.objects.filter(Q(user=user) | Q(institution_id__in=institution_ids)).distinct() + return qs + + @extend_schema( + summary="List user and institution tickets", + description="Retrieves a list of official inquiry tickets submitted by the user or their institution.", + parameters=[ + OpenApiParameter('status', str, description="Filter by status (open, in_progress, answered, closed)"), + OpenApiParameter('category', str, description="Filter by category (cultural_affairs, religious_inquiry, etc.)"), + OpenApiParameter('priority', str, description="Filter by priority (urgent, high, medium, low)"), + OpenApiParameter('search', str, description="Search in subject or tracking code"), + ], + responses={200: TicketListSerializer(many=True)}, + tags=["Ticketing & Official Inquiries"], + ) + def get(self, request, *args, **kwargs): + qs = self.get_queryset() + + status_param = request.query_params.get('status') + if status_param: + qs = qs.filter(status=status_param) + + category = request.query_params.get('category') + if category: + qs = qs.filter(category=category) + + priority = request.query_params.get('priority') + if priority: + qs = qs.filter(priority=priority) + + search = request.query_params.get('search') + if search: + qs = qs.filter(Q(subject__icontains=search) | Q(tracking_code__icontains=search)) + + serializer = TicketListSerializer(qs, many=True) + return Response(serializer.data, status=status.HTTP_200_OK) + + @extend_schema( + summary="Submit an official ticket inquiry", + description="Creates a new official inquiry ticket with initial message content and optional file attachment.", + request=TicketCreateSerializer, + responses={ + 201: TicketDetailSerializer, + 400: OpenApiResponse(description="Validation error"), + }, + tags=["Ticketing & Official Inquiries"], + ) + def post(self, request, *args, **kwargs): + serializer = TicketCreateSerializer(data=request.data, context={'request': request}) + serializer.is_valid(raise_exception=True) + ticket = serializer.save() + + response_serializer = TicketDetailSerializer(ticket) + return Response(response_serializer.data, status=status.HTTP_201_CREATED) + + +class TicketDetailView(GenericAPIView): + permission_classes = [IsAuthenticated, IsTicketOwnerOrStaff] + serializer_class = TicketDetailSerializer + queryset = Ticket.objects.all() + + @extend_schema( + summary="Retrieve ticket thread and responses", + description="Returns full ticket details including all dialogue messages and attachments.", + responses={ + 200: TicketDetailSerializer, + 403: OpenApiResponse(description="Permission denied"), + 404: OpenApiResponse(description="Ticket not found"), + }, + tags=["Ticketing & Official Inquiries"], + ) + def get(self, request, pk_or_code, *args, **kwargs): + ticket = get_ticket_by_id_or_code(pk_or_code, self.get_queryset()) + self.check_object_permissions(request, ticket) + serializer = self.get_serializer(ticket) + return Response(serializer.data, status=status.HTTP_200_OK) + + @extend_schema( + summary="Update ticket status or priority (Staff/Owner)", + description="Updates ticket priority, category, or assigned expert.", + request=TicketListSerializer, + responses={200: TicketDetailSerializer}, + tags=["Ticketing & Official Inquiries"], + ) + def patch(self, request, pk_or_code, *args, **kwargs): + ticket = get_ticket_by_id_or_code(pk_or_code, self.get_queryset()) + self.check_object_permissions(request, ticket) + + if 'priority' in request.data: + ticket.priority = request.data['priority'] + if 'category' in request.data: + ticket.category = request.data['category'] + if 'status' in request.data and request.user.is_staff: + ticket.status = request.data['status'] + if 'assigned_expert' in request.data and request.user.is_staff: + ticket.assigned_expert_id = request.data['assigned_expert'] + + ticket.save() + serializer = self.get_serializer(ticket) + return Response(serializer.data, status=status.HTTP_200_OK) + + +class TicketReplyView(GenericAPIView): + permission_classes = [IsAuthenticated, IsTicketOwnerOrStaff] + parser_classes = [MultiPartParser, FormParser, JSONParser] + serializer_class = TicketMessageCreateSerializer + queryset = Ticket.objects.all() + + @extend_schema( + summary="Reply to ticket inquiry", + description="Appends a new message or official response to an existing ticket thread.", + request=TicketMessageCreateSerializer, + responses={ + 201: TicketMessageSerializer, + 400: OpenApiResponse(description="Invalid reply"), + 403: OpenApiResponse(description="Permission denied"), + }, + tags=["Ticketing & Official Inquiries"], + ) + def post(self, request, pk_or_code, *args, **kwargs): + ticket = get_ticket_by_id_or_code(pk_or_code, self.get_queryset()) + self.check_object_permissions(request, ticket) + + if ticket.status == TicketStatus.CLOSED: + return Response({'error': _("Cannot reply to a closed ticket.")}, status=status.HTTP_400_BAD_REQUEST) + + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + + is_staff_user = request.user.is_staff or getattr(request.user, 'is_super_admin', False) + + msg = TicketMessage.objects.create( + ticket=ticket, + sender=request.user, + content=serializer.validated_data['content'], + attachment=serializer.validated_data.get('attachment'), + is_official_response=is_staff_user, + department_label=serializer.validated_data.get('department_label', 'Astan Quds Razavi Official Support' if is_staff_user else ''), + ) + + # Update ticket status automatically + if is_staff_user: + ticket.status = TicketStatus.ANSWERED + if not ticket.assigned_expert: + ticket.assigned_expert = request.user + else: + ticket.status = TicketStatus.IN_PROGRESS + ticket.save() + + response_serializer = TicketMessageSerializer(msg) + return Response(response_serializer.data, status=status.HTTP_201_CREATED) + + +class TicketCloseView(GenericAPIView): + permission_classes = [IsAuthenticated, IsTicketOwnerOrStaff] + serializer_class = TicketDetailSerializer + queryset = Ticket.objects.all() + + @extend_schema( + summary="Close ticket inquiry", + description="Marks a ticket as closed and records the resolution timestamp.", + responses={200: TicketDetailSerializer}, + tags=["Ticketing & Official Inquiries"], + ) + def post(self, request, pk_or_code, *args, **kwargs): + ticket = get_ticket_by_id_or_code(pk_or_code, self.get_queryset()) + self.check_object_permissions(request, ticket) + + ticket.status = TicketStatus.CLOSED + ticket.closed_at = timezone.now() + ticket.save() + + serializer = self.get_serializer(ticket) + return Response(serializer.data, status=status.HTTP_200_OK) + + +class TicketRateView(GenericAPIView): + permission_classes = [IsAuthenticated, IsTicketOwnerOrStaff] + serializer_class = TicketRateSerializer + queryset = Ticket.objects.all() + + @extend_schema( + summary="Submit satisfaction rating for ticket", + description="Records 1 to 5 star rating and optional user feedback upon inquiry completion.", + request=TicketRateSerializer, + responses={ + 200: TicketDetailSerializer, + 400: OpenApiResponse(description="Validation error"), + }, + tags=["Ticketing & Official Inquiries"], + ) + def post(self, request, pk_or_code, *args, **kwargs): + ticket = get_ticket_by_id_or_code(pk_or_code, self.get_queryset()) + self.check_object_permissions(request, ticket) + + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + + ticket.satisfaction_rating = serializer.validated_data['rating'] + ticket.feedback_comment = serializer.validated_data.get('feedback', '') + ticket.save() + + response_serializer = TicketDetailSerializer(ticket) + return Response(response_serializer.data, status=status.HTTP_200_OK) diff --git a/config/settings/base.py b/config/settings/base.py index 2251465..053fd1c 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -37,6 +37,12 @@ LOCAL_APPS = [ 'apps.chat.apps.ChatConfig', 'apps.events.apps.EventsConfig', 'apps.meetings.apps.MeetingsConfig', + 'apps.lms.apps.LMSConfig', + 'apps.tickets.apps.TicketsConfig', + 'apps.dynamic_forms.apps.DynamicFormsConfig', + 'apps.donations.apps.DonationsConfig', + 'apps.diplomacy.apps.DiplomacyConfig', + 'apps.analytics.apps.AnalyticsConfig', 'apps.api.apps.ApiConfig', 'dynamic_preferences', ] @@ -234,11 +240,23 @@ SPECTACULAR_SETTINGS = { 'ENUM_NAME_OVERRIDES': { 'VerificationDocumentStatusEnum': 'apps.account.models.verification.VerificationDocument.Status', 'CMSPostStatusEnum': 'apps.cms.models.post.Post.Status', - 'CMSPostLanguageEnum': 'apps.cms.models.post.Post.LanguageChoices', 'CMSCommentStatusEnum': 'apps.cms.models.post.PostComment.Status', 'ProjectStatusEnum': 'apps.projects.models.project.Project.Status', 'MeetingStatusEnum': 'apps.meetings.models.meeting.MeetingStatus', 'EventRegistrationStatusEnum': 'apps.events.models.event.RegistrationStatus', + 'EventCategoryEnum': 'apps.events.models.event.EventCategory', + 'LMSCourseCategoryEnum': 'apps.lms.models.course.CourseCategory', + 'LMSCourseLevelEnum': 'apps.lms.models.course.CourseLevel', + 'ContentLanguageEnum': 'apps.lms.models.course.CourseLanguage', + 'LMSLessonTypeEnum': 'apps.lms.models.course.Lesson.LessonType', + 'TicketCategoryEnum': 'apps.tickets.models.ticket.TicketCategory', + 'TicketPriorityEnum': 'apps.tickets.models.ticket.TicketPriority', + 'TicketStatusEnum': 'apps.tickets.models.ticket.TicketStatus', + 'WorkflowStatusEnum': 'apps.dynamic_forms.models.form.WorkflowStatus', + 'DonationCampaignCategoryEnum': 'apps.donations.models.donation.CampaignCategory', + 'DonationCampaignPriorityEnum': 'apps.donations.models.donation.CampaignPriority', + 'PaymentStatusEnum': 'apps.donations.models.donation.PaymentStatus', + 'DiplomacyReportStatusEnum': 'apps.diplomacy.models.report.ReportStatus', }, } diff --git a/config/urls.py b/config/urls.py index d643c37..2a0a29c 100644 --- a/config/urls.py +++ b/config/urls.py @@ -52,6 +52,24 @@ api_v1_patterns = [ # Inter-Center Video Meetings & Scheduling path('meetings/', include('apps.meetings.urls')), + # Online Learning Management System (LMS) + path('lms/', include('apps.lms.urls')), + + # Official Inquiries & Support Ticketing + path('tickets/', include('apps.tickets.urls')), + + # Dynamic Custom Forms & Surveys + path('forms/', include('apps.dynamic_forms.urls')), + + # Donations & Financial Campaigns + path('donations/', include('apps.donations.urls')), + + # Field Representative Diplomacy Reports + path('diplomacy/', include('apps.diplomacy.urls')), + + # Executive Analytics Dashboard + path('analytics/', include('apps.analytics.urls')), + path('', include('apps.api.urls')), path('account/', include('apps.account.urls')), path('settings/', include('dynamic_preferences.urls')), diff --git a/schema.yml b/schema.yml index 136efdf..f01cbc7 100644 --- a/schema.yml +++ b/schema.yml @@ -1239,6 +1239,28 @@ paths: schema: $ref: '#/components/schemas/WebUserRegister' description: '' + /api/v1/analytics/dashboard/: + get: + operationId: v1_analytics_dashboard_retrieve + description: Aggregates platform KPIs across international institutions, collaborative + projects, LMS academy, official ticketing, campaigns, and field diplomacy + reports. + summary: Executive KPIs & Global Analytics Dashboard + tags: + - Executive Analytics & KPIs + security: + - jwtAuth: [] + - tokenAuth: [] + - cookieAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/ExecutiveDashboard' + description: '' /api/v1/auth/documents/: get: operationId: v1_auth_documents_list @@ -2243,6 +2265,277 @@ paths: schema: $ref: '#/components/schemas/SupportMessageCreate' description: '' + /api/v1/diplomacy/reports/: + get: + operationId: v1_diplomacy_reports_list + description: Retrieves periodic reports submitted by regional mission representatives + (Module 6.5). + summary: List field representative activity reports + parameters: + - name: page + required: false + in: query + description: A page number within the paginated result set. + schema: + type: integer + - name: page_size + required: false + in: query + description: Number of results to return per page. + schema: + type: integer + - in: query + name: region_hub + schema: + type: string + description: Filter by regional hub name + - in: query + name: status + schema: + type: string + description: Filter by status (submitted, under_review, approved, returned) + tags: + - Diplomacy & Field Reports + security: + - jwtAuth: [] + - tokenAuth: [] + - cookieAuth: [] + - jwtAuth: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/PaginatedRepresentativeReportListList' + description: '' + post: + operationId: v1_diplomacy_reports_create + description: Submits a field audit, visits summary, and achievements report + for headquarters review. + summary: Submit periodic field activity report + tags: + - Diplomacy & Field Reports + requestBody: + content: + multipart/form-data: + schema: + $ref: '#/components/schemas/RepresentativeReportCreateRequest' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/RepresentativeReportCreateRequest' + application/json: + schema: + $ref: '#/components/schemas/RepresentativeReportCreateRequest' + required: true + security: + - jwtAuth: [] + - tokenAuth: [] + - cookieAuth: [] + - jwtAuth: [] + responses: + '201': + content: + application/json: + schema: + $ref: '#/components/schemas/RepresentativeReportDetail' + description: '' + '400': + description: Validation error + /api/v1/diplomacy/reports/{id}/: + get: + operationId: v1_diplomacy_reports_retrieve + description: Returns full report contents, achievements, challenges, attachment, + and headquarters feedback. + summary: Retrieve representative report details + parameters: + - in: path + name: id + schema: + type: integer + required: true + tags: + - Diplomacy & Field Reports + security: + - jwtAuth: [] + - tokenAuth: [] + - cookieAuth: [] + - jwtAuth: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/RepresentativeReportDetail' + description: '' + '403': + description: Permission denied + '404': + description: Report not found + /api/v1/diplomacy/reports/{id}/review/: + patch: + operationId: v1_diplomacy_reports_review_partial_update + description: Approves or returns report with headquarters feedback directives + (Staff/Admin only). + summary: Review and give directives on field report + parameters: + - in: path + name: id + schema: + type: integer + required: true + tags: + - Diplomacy & Field Reports + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/PatchedRepresentativeReportReviewRequest' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/PatchedRepresentativeReportReviewRequest' + multipart/form-data: + schema: + $ref: '#/components/schemas/PatchedRepresentativeReportReviewRequest' + security: + - jwtAuth: [] + - tokenAuth: [] + - cookieAuth: [] + - jwtAuth: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/RepresentativeReportDetail' + description: '' + '403': + description: Permission denied + /api/v1/donations/campaigns/: + get: + operationId: v1_donations_campaigns_list + description: Retrieves charitable campaigns with target, raised amounts, progress + percentages, and filters. + summary: List active donation appeals & campaigns + parameters: + - in: query + name: category + schema: + type: string + description: Filter by campaign category + - in: query + name: featured + schema: + type: boolean + description: Filter featured campaigns + - name: page + required: false + in: query + description: A page number within the paginated result set. + schema: + type: integer + - name: page_size + required: false + in: query + description: Number of results to return per page. + schema: + type: integer + - in: query + name: priority + schema: + type: string + description: Filter by priority (urgent, high, medium) + - in: query + name: search + schema: + type: string + description: Search in campaign title or description + tags: + - Donations & Financial Campaigns + security: + - jwtAuth: [] + - tokenAuth: [] + - cookieAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/PaginatedDonationCampaignListList' + description: '' + /api/v1/donations/campaigns/{pk_or_slug}/: + get: + operationId: v1_donations_campaigns_retrieve + description: Returns detailed campaign goal, beneficiary center, progress rate, + and recent donors. + summary: Retrieve campaign details & recent contributions + parameters: + - in: path + name: pk_or_slug + schema: + type: string + required: true + tags: + - Donations & Financial Campaigns + security: + - jwtAuth: [] + - tokenAuth: [] + - cookieAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/DonationCampaignDetail' + description: '' + '404': + description: Campaign not found + /api/v1/donations/campaigns/{pk_or_slug}/contribute/: + post: + operationId: v1_donations_campaigns_contribute_create + description: Processes a donation contribution, persists transaction, and increments + campaign accumulated funds. + summary: Make a charitable contribution to campaign + parameters: + - in: path + name: pk_or_slug + schema: + type: string + required: true + tags: + - Donations & Financial Campaigns + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/DonationContributeRequestRequest' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/DonationContributeRequestRequest' + multipart/form-data: + schema: + $ref: '#/components/schemas/DonationContributeRequestRequest' + required: true + security: + - jwtAuth: [] + - tokenAuth: [] + - cookieAuth: [] + - jwtAuth: [] + - {} + responses: + '201': + content: + application/json: + schema: + $ref: '#/components/schemas/DonationTransaction' + description: '' + '400': + description: Validation error + '404': + description: Campaign not found /api/v1/events/: get: operationId: v1_events_list @@ -2613,32 +2906,11 @@ paths: schema: $ref: '#/components/schemas/PaginatedEventCalendarItemList' description: '' - /api/v1/health/: - get: - operationId: v1_health_retrieve - description: Returns system status, current timestamp, and API version - summary: Check API server health status - tags: - - v1 - security: - - jwtAuth: [] - - tokenAuth: [] - - cookieAuth: [] - - jwtAuth: [] - - {} - responses: - '200': - content: - application/json: - schema: - $ref: '#/components/schemas/HealthCheckResponse' - description: '' - /api/v1/map/clusters/: + /api/v1/forms/: get: - operationId: v1_map_clusters_list - description: Returns aggregated institution counts and density breakdown per - country and region. - summary: Regional density & geographic cluster overview + operationId: v1_forms_list + description: Retrieves available custom dynamic forms and survey workflows. + summary: List active dynamic forms parameters: - name: page required: false @@ -2653,7 +2925,7 @@ paths: schema: type: integer tags: - - Smart Geo-Atlas + - Dynamic Forms & Surveys security: - jwtAuth: [] - tokenAuth: [] @@ -2665,47 +2937,119 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/PaginatedRegionalDensityList' + $ref: '#/components/schemas/PaginatedFormDefinitionListList' description: '' - /api/v1/map/institutions/: - get: - operationId: v1_map_institutions_list - description: Returns geographic markers or aggregated clusters for institutions - within the current map viewport. - summary: Query map markers with bounding box & clustering - parameters: - - in: query - name: city - schema: - type: string - description: Filter by city - - in: query - name: cluster - schema: - type: boolean - description: Whether to cluster nearby pins (default true) - - in: query - name: country + post: + operationId: v1_forms_create + description: Defines a new custom form, questions, validation rules, and schema + (Staff/Admin only). + summary: Create new dynamic form schema + tags: + - Dynamic Forms & Surveys + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/FormDefinitionDetailRequest' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/FormDefinitionDetailRequest' + multipart/form-data: + schema: + $ref: '#/components/schemas/FormDefinitionDetailRequest' + required: true + security: + - jwtAuth: [] + - tokenAuth: [] + - cookieAuth: [] + - jwtAuth: [] + - {} + responses: + '201': + content: + application/json: + schema: + $ref: '#/components/schemas/FormDefinitionDetail' + description: '' + '403': + description: Permission denied + /api/v1/forms/{pk_or_slug}/: + get: + operationId: v1_forms_retrieve + description: Retrieves the detailed form definition, instructions, and fields + JSON schema. + summary: Get form schema definition + parameters: + - in: path + name: pk_or_slug schema: type: string - description: Filter by country - - in: query - name: east - schema: - type: number - format: double - description: Easternmost longitude of viewport - - in: query - name: is_featured - schema: - type: boolean - description: Filter by featured status - - in: query - name: north + required: true + tags: + - Dynamic Forms & Surveys + security: + - jwtAuth: [] + - tokenAuth: [] + - cookieAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/FormDefinitionDetail' + description: '' + '404': + description: Form not found + /api/v1/forms/{pk_or_slug}/responses/: + post: + operationId: v1_forms_responses_create + description: Records user answers to custom dynamic form questions with optional + file attachments. + summary: Submit dynamic form responses + parameters: + - in: path + name: pk_or_slug schema: - type: number - format: double - description: Northernmost latitude of viewport + type: string + required: true + tags: + - Dynamic Forms & Surveys + requestBody: + content: + multipart/form-data: + schema: + $ref: '#/components/schemas/FormResponseSubmitRequest' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/FormResponseSubmitRequest' + application/json: + schema: + $ref: '#/components/schemas/FormResponseSubmitRequest' + required: true + security: + - jwtAuth: [] + - tokenAuth: [] + - cookieAuth: [] + - jwtAuth: [] + - {} + responses: + '201': + content: + application/json: + schema: + $ref: '#/components/schemas/FormResponse' + description: '' + '400': + description: Invalid response data + /api/v1/forms/{pk_or_slug}/submissions/: + get: + operationId: v1_forms_submissions_list + description: Retrieves submissions for a form. Admins see all submissions; users + see their own. + summary: List submitted form responses + parameters: - name: page required: false in: query @@ -2718,40 +3062,69 @@ paths: description: Number of results to return per page. schema: type: integer - - in: query - name: search - schema: - type: string - description: Search text query - - in: query - name: south - schema: - type: number - format: double - description: Southernmost latitude of viewport - - in: query - name: tag - schema: - type: string - description: Filter by tag string - - in: query - name: type + - in: path + name: pk_or_slug schema: type: string - description: Filter by institution type - - in: query - name: west - schema: - type: number - format: double - description: Westernmost longitude of viewport - - in: query - name: zoom + required: true + tags: + - Dynamic Forms & Surveys + security: + - jwtAuth: [] + - tokenAuth: [] + - cookieAuth: [] + - jwtAuth: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/PaginatedFormResponseList' + description: '' + /api/v1/forms/responses/{response_id}/review/: + patch: + operationId: v1_forms_responses_review_partial_update + description: Updates workflow status (under_review, approved, rejected) and + adds admin notes (Staff only). + summary: Review and update form response workflow state + parameters: + - in: path + name: response_id schema: type: integer - description: Current map zoom level (1 to 20, default 10) + required: true tags: - - Smart Geo-Atlas + - Dynamic Forms & Surveys + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/PatchedFormResponseReviewRequest' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/PatchedFormResponseReviewRequest' + multipart/form-data: + schema: + $ref: '#/components/schemas/PatchedFormResponseReviewRequest' + security: + - jwtAuth: [] + - tokenAuth: [] + - cookieAuth: [] + - jwtAuth: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/FormResponse' + description: '' + /api/v1/health/: + get: + operationId: v1_health_retrieve + description: Returns system status, current timestamp, and API version + summary: Check API server health status + tags: + - v1 security: - jwtAuth: [] - tokenAuth: [] @@ -2763,16 +3136,22 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/PaginatedMapClusterItemList' + $ref: '#/components/schemas/HealthCheckResponse' description: '' - /api/v1/map/stats/: + /api/v1/lms/certificates/{code}/: get: - operationId: v1_map_stats_retrieve - description: Returns total institutions, countries covered, cities count, and - classification distributions. - summary: Get global atlas statistics + operationId: v1_lms_certificates_retrieve + description: Public endpoint for verifying credentials by certificate code or + UUID. Does not require login. + summary: Verify digital certificate authenticity (Public / QR) + parameters: + - in: path + name: code + schema: + type: string + required: true tags: - - Smart Geo-Atlas + - LMS - Online Academy security: - jwtAuth: [] - tokenAuth: [] @@ -2784,26 +3163,37 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/MapStats' + $ref: '#/components/schemas/PublicCertificateVerify' description: '' - /api/v1/meetings/: + '404': + description: Certificate code not found + /api/v1/lms/courses/: get: - operationId: v1_meetings_list - description: List bilateral video meetings involving the authenticated user's - affiliated institutions. - summary: List scheduled & pending meetings + operationId: v1_lms_courses_list + description: Retrieves a list of published courses with category, language, + level, and keyword filtering. + summary: List online academy courses parameters: - in: query - name: end_date + name: category schema: type: string - format: date - description: Filter meetings to date (YYYY-MM-DD) + description: Filter by course category (e.g. razavi_teachings, cultural_diplomacy) - in: query - name: institution_id + name: featured schema: - type: integer - description: Filter meetings by participating institution ID + type: boolean + description: Filter only featured courses + - in: query + name: language + schema: + type: string + description: Filter by language code (fa, ar, en, ur, fr) + - in: query + name: level + schema: + type: string + description: Filter by difficulty level (introductory, intermediate, advanced) - name: page required: false in: query @@ -2820,265 +3210,173 @@ paths: name: search schema: type: string - description: Search in title or agenda - - in: query - name: start_date - schema: - type: string - format: date - description: Filter meetings from date (YYYY-MM-DD) - - in: query - name: status - schema: - type: string - description: Meeting status (pending, scheduled, completed, cancelled, declined) + description: Keyword search in title, instructor, or description tags: - - Meetings + - LMS - Online Academy security: - jwtAuth: [] - tokenAuth: [] - cookieAuth: [] - jwtAuth: [] + - {} responses: '200': content: application/json: schema: - $ref: '#/components/schemas/PaginatedMeetingListList' + $ref: '#/components/schemas/PaginatedCourseListList' description: '' post: - operationId: v1_meetings_create - description: Schedule/propose a bilateral meeting between institutions (requires - institution admin/editor role). - summary: Propose a new video meeting + operationId: v1_lms_courses_create + description: Creates a new LMS course. Creator is automatically set as course + author. + summary: Create a new course tags: - - Meetings + - LMS - Online Academy requestBody: content: application/json: schema: - $ref: '#/components/schemas/MeetingCreateRequest' + $ref: '#/components/schemas/CourseCreateUpdateRequest' application/x-www-form-urlencoded: schema: - $ref: '#/components/schemas/MeetingCreateRequest' + $ref: '#/components/schemas/CourseCreateUpdateRequest' multipart/form-data: schema: - $ref: '#/components/schemas/MeetingCreateRequest' + $ref: '#/components/schemas/CourseCreateUpdateRequest' required: true security: - jwtAuth: [] - tokenAuth: [] - cookieAuth: [] - jwtAuth: [] + - {} responses: '201': content: application/json: schema: - $ref: '#/components/schemas/MeetingDetail' + $ref: '#/components/schemas/CourseDetail' description: '' - /api/v1/meetings/{id}/: + '400': + description: Validation error + '403': + description: Permission denied + /api/v1/lms/courses/{pk_or_slug}/: get: - operationId: v1_meetings_retrieve - description: Get full meeting details including video link, agenda, and participants. - summary: Retrieve meeting details + operationId: v1_lms_courses_retrieve + description: Returns full course information, curriculum modules, lessons list, + instructor details, and authenticated user enrollment status. + summary: Retrieve course syllabus and details parameters: - in: path - name: id + name: pk_or_slug schema: - type: integer + type: string required: true tags: - - Meetings + - LMS - Online Academy security: - jwtAuth: [] - tokenAuth: [] - cookieAuth: [] - jwtAuth: [] + - {} responses: '200': content: application/json: schema: - $ref: '#/components/schemas/MeetingDetail' + $ref: '#/components/schemas/CourseDetail' description: '' - put: - operationId: v1_meetings_update + '404': + description: Course not found + patch: + operationId: v1_lms_courses_partial_update + description: Partially updates course information. Restricted to instructor + or platform administrator. + summary: Update course metadata parameters: - in: path - name: id + name: pk_or_slug schema: - type: integer - required: true - tags: - - v1 - requestBody: - content: - application/json: - schema: - $ref: '#/components/schemas/MeetingUpdateRequest' - application/x-www-form-urlencoded: - schema: - $ref: '#/components/schemas/MeetingUpdateRequest' - multipart/form-data: - schema: - $ref: '#/components/schemas/MeetingUpdateRequest' - required: true - security: - - jwtAuth: [] - - tokenAuth: [] - - cookieAuth: [] - - jwtAuth: [] - responses: - '200': - content: - application/json: - schema: - $ref: '#/components/schemas/MeetingUpdate' - description: '' - patch: - operationId: v1_meetings_partial_update - description: Accept, reschedule, decline, complete, or update meeting room URL - and agenda notes. - summary: Update or respond to meeting request - parameters: - - in: path - name: id - schema: - type: integer + type: string required: true tags: - - Meetings + - LMS - Online Academy requestBody: content: application/json: schema: - $ref: '#/components/schemas/PatchedMeetingUpdateRequest' + $ref: '#/components/schemas/PatchedCourseCreateUpdateRequest' application/x-www-form-urlencoded: schema: - $ref: '#/components/schemas/PatchedMeetingUpdateRequest' + $ref: '#/components/schemas/PatchedCourseCreateUpdateRequest' multipart/form-data: schema: - $ref: '#/components/schemas/PatchedMeetingUpdateRequest' + $ref: '#/components/schemas/PatchedCourseCreateUpdateRequest' security: - jwtAuth: [] - tokenAuth: [] - cookieAuth: [] - jwtAuth: [] + - {} responses: '200': content: application/json: schema: - $ref: '#/components/schemas/MeetingDetail' + $ref: '#/components/schemas/CourseDetail' description: '' + '403': + description: Permission denied delete: - operationId: v1_meetings_destroy - description: Cancel or delete a meeting request. - summary: Cancel / delete meeting + operationId: v1_lms_courses_destroy + description: Deletes a course and its modules. Restricted to instructor or platform + administrator. + summary: Delete a course parameters: - in: path - name: id + name: pk_or_slug schema: - type: integer + type: string required: true tags: - - Meetings + - LMS - Online Academy security: - jwtAuth: [] - tokenAuth: [] - cookieAuth: [] - jwtAuth: [] + - {} responses: '204': - description: No response body - /api/v1/profiles/: - get: - operationId: v1_profiles_list - description: Returns a paginated list of mosques, cultural centers, and institutes - with filtering. - summary: List active institutions + description: Course deleted + /api/v1/lms/courses/{pk_or_slug}/enroll/: + post: + operationId: v1_lms_courses_enroll_create + description: Enrolls the authenticated user into the specified course. Returns + enrollment status. + summary: Enroll current user in course parameters: - - in: query - name: city - schema: - type: string - description: Filter by city name - - in: query - name: country - schema: - type: string - description: Filter by country name - - in: query - name: is_featured - schema: - type: boolean - description: Filter by featured status - - name: page - required: false - in: query - description: A page number within the paginated result set. - schema: - type: integer - - name: page_size - required: false - in: query - description: Number of results to return per page. - schema: - type: integer - - in: query - name: search - schema: - type: string - description: Search query across name, city, country, description - - in: query - name: tag - schema: - type: string - description: Filter by tag string - - in: query - name: type - schema: - type: string - description: Filter by institution type (mosque, cultural_center, etc.) - - in: query - name: verification_status + - in: path + name: pk_or_slug schema: type: string - description: Filter by verification status (pending, approved, rejected) - tags: - - Institutions & Profiles - security: - - jwtAuth: [] - - tokenAuth: [] - - cookieAuth: [] - - jwtAuth: [] - - {} - responses: - '200': - content: - application/json: - schema: - $ref: '#/components/schemas/PaginatedInstitutionListList' - description: '' - post: - operationId: v1_profiles_create - description: Registers a new institution profile and designates the creator - as primary Administrator. - summary: Register a new institution + required: true tags: - - Institutions & Profiles + - LMS - Online Academy requestBody: content: application/json: schema: - $ref: '#/components/schemas/InstitutionCreateUpdateRequest' + $ref: '#/components/schemas/CourseDetailRequest' application/x-www-form-urlencoded: schema: - $ref: '#/components/schemas/InstitutionCreateUpdateRequest' + $ref: '#/components/schemas/CourseDetailRequest' multipart/form-data: schema: - $ref: '#/components/schemas/InstitutionCreateUpdateRequest' + $ref: '#/components/schemas/CourseDetailRequest' required: true security: - jwtAuth: [] @@ -3086,22 +3384,18 @@ paths: - cookieAuth: [] - jwtAuth: [] responses: + '200': + description: Already enrolled '201': - content: - application/json: - schema: - $ref: '#/components/schemas/InstitutionDetail' - description: '' - '400': - description: Validation error - '401': - description: Authentication required - /api/v1/profiles/{pk_or_slug}/: + description: Successfully enrolled + '404': + description: Course not found + /api/v1/lms/courses/{pk_or_slug}/progress/: get: - operationId: v1_profiles_retrieve - description: Retrieves complete institution information, historical timeline - milestones, and media gallery. - summary: Get full institution profile + operationId: v1_lms_courses_progress_retrieve + description: Returns completion percentage, completed lessons tally, and certificate + code if finished. + summary: Get user progress in course parameters: - in: path name: pk_or_slug @@ -3109,107 +3403,75 @@ paths: type: string required: true tags: - - Institutions & Profiles + - LMS - Online Academy security: - jwtAuth: [] - tokenAuth: [] - cookieAuth: [] - jwtAuth: [] - - {} responses: '200': content: application/json: schema: - $ref: '#/components/schemas/InstitutionDetail' + $ref: '#/components/schemas/CourseProgress' description: '' '404': - description: Institution not found - patch: - operationId: v1_profiles_partial_update - description: Updates details of an institution. Requires Admin or Editor role - on the institution. - summary: Update institution profile + description: Course or enrollment not found + /api/v1/lms/lessons/{id}/: + get: + operationId: v1_lms_lessons_retrieve + description: Retrieves lesson video link, markdown content, slides attachment, + and quiz questions. + summary: Get lesson details and content parameters: - in: path - name: pk_or_slug + name: id schema: - type: string + type: integer required: true tags: - - Institutions & Profiles - requestBody: - content: - application/json: - schema: - $ref: '#/components/schemas/PatchedInstitutionCreateUpdateRequest' - application/x-www-form-urlencoded: - schema: - $ref: '#/components/schemas/PatchedInstitutionCreateUpdateRequest' - multipart/form-data: - schema: - $ref: '#/components/schemas/PatchedInstitutionCreateUpdateRequest' + - LMS - Online Academy security: - jwtAuth: [] - tokenAuth: [] - cookieAuth: [] - jwtAuth: [] + - {} responses: '200': content: application/json: schema: - $ref: '#/components/schemas/InstitutionDetail' + $ref: '#/components/schemas/LessonDetail' description: '' - '403': - description: Permission denied - delete: - operationId: v1_profiles_destroy - description: Deactivates an institution. Permitted for institution administrator - or platform administrator. - summary: Deactivate / Delete institution - parameters: - - in: path - name: pk_or_slug - schema: - type: string - required: true - tags: - - Institutions & Profiles - security: - - jwtAuth: [] - - tokenAuth: [] - - cookieAuth: [] - - jwtAuth: [] - responses: - '204': - description: Institution successfully deactivated - '403': - description: Permission denied - /api/v1/profiles/{pk_or_slug}/follow/: + '404': + description: Lesson not found + /api/v1/lms/lessons/{id}/complete/: post: - operationId: v1_profiles_follow_create - description: Follows the specified institution to receive updates and news. - summary: Follow institution + operationId: v1_lms_lessons_complete_create + description: Marks a reading or video lesson as complete. Automatically recalculates + course progress and issues certificate if 100%. + summary: Mark lesson as complete parameters: - in: path - name: pk_or_slug + name: id schema: - type: string + type: integer required: true tags: - - Institutions & Profiles + - LMS - Online Academy requestBody: content: application/json: schema: - $ref: '#/components/schemas/FollowResponseRequest' + $ref: '#/components/schemas/LessonCompleteResponseRequest' application/x-www-form-urlencoded: schema: - $ref: '#/components/schemas/FollowResponseRequest' + $ref: '#/components/schemas/LessonCompleteResponseRequest' multipart/form-data: schema: - $ref: '#/components/schemas/FollowResponseRequest' + $ref: '#/components/schemas/LessonCompleteResponseRequest' required: true security: - jwtAuth: [] @@ -3221,20 +3483,36 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/FollowResponse' + $ref: '#/components/schemas/LessonCompleteResponse' description: '' - delete: - operationId: v1_profiles_follow_destroy - description: Unfollows the specified institution. - summary: Unfollow institution + '404': + description: Lesson not found + /api/v1/lms/lessons/{id}/submit-quiz/: + post: + operationId: v1_lms_lessons_submit_quiz_create + description: Evaluates user quiz responses. If score >= 70%, marks lesson complete, + recalculates progress, and issues certificate if 100%. + summary: Submit quiz answers and evaluate score parameters: - in: path - name: pk_or_slug + name: id schema: - type: string + type: integer required: true tags: - - Institutions & Profiles + - LMS - Online Academy + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/QuizSubmitRequestRequest' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/QuizSubmitRequestRequest' + multipart/form-data: + schema: + $ref: '#/components/schemas/QuizSubmitRequestRequest' + required: true security: - jwtAuth: [] - tokenAuth: [] @@ -3245,13 +3523,18 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/FollowResponse' + $ref: '#/components/schemas/QuizResult' description: '' - /api/v1/profiles/{pk_or_slug}/media/: + '400': + description: Invalid submission + '404': + description: Lesson not found + /api/v1/lms/my-certificates/: get: - operationId: v1_profiles_media_list - description: Returns uploaded photos, videos, and documents for an institution. - summary: List media gallery assets + operationId: v1_lms_my_certificates_list + description: Retrieves a list of all official digital certificates earned by + the authenticated user across completed academy courses. + summary: List user earned digital certificates parameters: - name: page required: false @@ -3265,95 +3548,191 @@ paths: description: Number of results to return per page. schema: type: integer - - in: path - name: pk_or_slug - schema: - type: string - required: true tags: - - Institutions & Profiles + - LMS - Online Academy security: - jwtAuth: [] - tokenAuth: [] - cookieAuth: [] - jwtAuth: [] - - {} responses: '200': content: application/json: schema: - $ref: '#/components/schemas/PaginatedInstitutionMediaList' + $ref: '#/components/schemas/PaginatedDigitalCertificateList' description: '' - post: - operationId: v1_profiles_media_create - description: Uploads a photo, video, or publication to the institution media - gallery. - summary: Upload media to gallery + /api/v1/map/clusters/: + get: + operationId: v1_map_clusters_list + description: Returns aggregated institution counts and density breakdown per + country and region. + summary: Regional density & geographic cluster overview parameters: - - in: path - name: pk_or_slug + - name: page + required: false + in: query + description: A page number within the paginated result set. schema: - type: string - required: true + type: integer + - name: page_size + required: false + in: query + description: Number of results to return per page. + schema: + type: integer tags: - - Institutions & Profiles - requestBody: - content: - multipart/form-data: - schema: - $ref: '#/components/schemas/InstitutionMediaRequest' - application/x-www-form-urlencoded: - schema: - $ref: '#/components/schemas/InstitutionMediaRequest' - application/json: - schema: - $ref: '#/components/schemas/InstitutionMediaRequest' - required: true + - Smart Geo-Atlas security: - jwtAuth: [] - tokenAuth: [] - cookieAuth: [] - jwtAuth: [] + - {} responses: - '201': + '200': content: application/json: schema: - $ref: '#/components/schemas/InstitutionMedia' + $ref: '#/components/schemas/PaginatedRegionalDensityList' description: '' - /api/v1/profiles/{pk_or_slug}/media/{media_id}/: - delete: - operationId: v1_profiles_media_destroy - description: Removes an asset from the institution media gallery. - summary: Delete media item + /api/v1/map/institutions/: + get: + operationId: v1_map_institutions_list + description: Returns geographic markers or aggregated clusters for institutions + within the current map viewport. + summary: Query map markers with bounding box & clustering parameters: - - in: path - name: media_id + - in: query + name: city + schema: + type: string + description: Filter by city + - in: query + name: cluster + schema: + type: boolean + description: Whether to cluster nearby pins (default true) + - in: query + name: country + schema: + type: string + description: Filter by country + - in: query + name: east + schema: + type: number + format: double + description: Easternmost longitude of viewport + - in: query + name: is_featured + schema: + type: boolean + description: Filter by featured status + - in: query + name: north + schema: + type: number + format: double + description: Northernmost latitude of viewport + - name: page + required: false + in: query + description: A page number within the paginated result set. schema: type: integer - required: true - - in: path - name: pk_or_slug + - name: page_size + required: false + in: query + description: Number of results to return per page. + schema: + type: integer + - in: query + name: search schema: type: string - required: true + description: Search text query + - in: query + name: south + schema: + type: number + format: double + description: Southernmost latitude of viewport + - in: query + name: tag + schema: + type: string + description: Filter by tag string + - in: query + name: type + schema: + type: string + description: Filter by institution type + - in: query + name: west + schema: + type: number + format: double + description: Westernmost longitude of viewport + - in: query + name: zoom + schema: + type: integer + description: Current map zoom level (1 to 20, default 10) tags: - - Institutions & Profiles + - Smart Geo-Atlas security: - jwtAuth: [] - tokenAuth: [] - cookieAuth: [] - jwtAuth: [] + - {} responses: - '204': - description: Media deleted - /api/v1/profiles/{pk_or_slug}/members/: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/PaginatedMapClusterItemList' + description: '' + /api/v1/map/stats/: get: - operationId: v1_profiles_members_list - description: Retrieves administrative and editorial team members of the institution. - summary: List institution team members + operationId: v1_map_stats_retrieve + description: Returns total institutions, countries covered, cities count, and + classification distributions. + summary: Get global atlas statistics + tags: + - Smart Geo-Atlas + security: + - jwtAuth: [] + - tokenAuth: [] + - cookieAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/MapStats' + description: '' + /api/v1/meetings/: + get: + operationId: v1_meetings_list + description: List bilateral video meetings involving the authenticated user's + affiliated institutions. + summary: List scheduled & pending meetings parameters: + - in: query + name: end_date + schema: + type: string + format: date + description: Filter meetings to date (YYYY-MM-DD) + - in: query + name: institution_id + schema: + type: integer + description: Filter meetings by participating institution ID - name: page required: false in: query @@ -3366,50 +3745,54 @@ paths: description: Number of results to return per page. schema: type: integer - - in: path - name: pk_or_slug + - in: query + name: search schema: type: string - required: true + description: Search in title or agenda + - in: query + name: start_date + schema: + type: string + format: date + description: Filter meetings from date (YYYY-MM-DD) + - in: query + name: status + schema: + type: string + description: Meeting status (pending, scheduled, completed, cancelled, declined) tags: - - Institutions & Profiles + - Meetings security: - jwtAuth: [] - tokenAuth: [] - cookieAuth: [] - jwtAuth: [] - - {} responses: '200': content: application/json: schema: - $ref: '#/components/schemas/PaginatedInstitutionMemberList' + $ref: '#/components/schemas/PaginatedMeetingListList' description: '' post: - operationId: v1_profiles_members_create - description: Adds or updates a member role (admin, editor, viewer) for an existing - registered user by email. - summary: Delegate role / Add team member - parameters: - - in: path - name: pk_or_slug - schema: - type: string - required: true + operationId: v1_meetings_create + description: Schedule/propose a bilateral meeting between institutions (requires + institution admin/editor role). + summary: Propose a new video meeting tags: - - Institutions & Profiles + - Meetings requestBody: content: application/json: schema: - $ref: '#/components/schemas/InstitutionMemberAddRequest' + $ref: '#/components/schemas/MeetingCreateRequest' application/x-www-form-urlencoded: schema: - $ref: '#/components/schemas/InstitutionMemberAddRequest' + $ref: '#/components/schemas/MeetingCreateRequest' multipart/form-data: schema: - $ref: '#/components/schemas/InstitutionMemberAddRequest' + $ref: '#/components/schemas/MeetingCreateRequest' required: true security: - jwtAuth: [] @@ -3421,130 +3804,115 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/InstitutionMember' + $ref: '#/components/schemas/MeetingDetail' description: '' - '400': - description: User not found or invalid payload - '403': - description: Only institution administrators can delegate roles - /api/v1/profiles/{pk_or_slug}/members/{member_id}/: - delete: - operationId: v1_profiles_members_destroy - description: Revokes institution membership and delegated role for a user. - summary: Remove team member + /api/v1/meetings/{id}/: + get: + operationId: v1_meetings_retrieve + description: Get full meeting details including video link, agenda, and participants. + summary: Retrieve meeting details parameters: - in: path - name: member_id + name: id schema: type: integer required: true - - in: path - name: pk_or_slug - schema: - type: string - required: true tags: - - Institutions & Profiles + - Meetings security: - jwtAuth: [] - tokenAuth: [] - cookieAuth: [] - jwtAuth: [] responses: - '204': - description: Member removed - /api/v1/profiles/{pk_or_slug}/timeline/: - get: - operationId: v1_profiles_timeline_list - description: Returns historical milestone entries for an institution. - summary: List timeline milestones + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/MeetingDetail' + description: '' + put: + operationId: v1_meetings_update parameters: - - name: page - required: false - in: query - description: A page number within the paginated result set. - schema: - type: integer - - name: page_size - required: false - in: query - description: Number of results to return per page. - schema: - type: integer - in: path - name: pk_or_slug + name: id schema: - type: string + type: integer required: true tags: - - Institutions & Profiles + - v1 + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/MeetingUpdateRequest' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/MeetingUpdateRequest' + multipart/form-data: + schema: + $ref: '#/components/schemas/MeetingUpdateRequest' + required: true security: - jwtAuth: [] - tokenAuth: [] - cookieAuth: [] - jwtAuth: [] - - {} responses: '200': content: application/json: schema: - $ref: '#/components/schemas/PaginatedInstitutionTimelineList' + $ref: '#/components/schemas/MeetingUpdate' description: '' - post: - operationId: v1_profiles_timeline_create - description: Creates a new milestone in the institution historical timeline. - summary: Add timeline milestone + patch: + operationId: v1_meetings_partial_update + description: Accept, reschedule, decline, complete, or update meeting room URL + and agenda notes. + summary: Update or respond to meeting request parameters: - in: path - name: pk_or_slug + name: id schema: - type: string + type: integer required: true tags: - - Institutions & Profiles + - Meetings requestBody: content: application/json: schema: - $ref: '#/components/schemas/InstitutionTimelineRequest' + $ref: '#/components/schemas/PatchedMeetingUpdateRequest' application/x-www-form-urlencoded: schema: - $ref: '#/components/schemas/InstitutionTimelineRequest' + $ref: '#/components/schemas/PatchedMeetingUpdateRequest' multipart/form-data: schema: - $ref: '#/components/schemas/InstitutionTimelineRequest' - required: true + $ref: '#/components/schemas/PatchedMeetingUpdateRequest' security: - jwtAuth: [] - tokenAuth: [] - cookieAuth: [] - jwtAuth: [] responses: - '201': + '200': content: application/json: schema: - $ref: '#/components/schemas/InstitutionTimeline' + $ref: '#/components/schemas/MeetingDetail' description: '' - /api/v1/profiles/{pk_or_slug}/timeline/{entry_id}/: delete: - operationId: v1_profiles_timeline_destroy - description: Removes a timeline entry from the institution profile. - summary: Delete timeline milestone + operationId: v1_meetings_destroy + description: Cancel or delete a meeting request. + summary: Cancel / delete meeting parameters: - in: path - name: entry_id + name: id schema: type: integer required: true - - in: path - name: pk_or_slug - schema: - type: string - required: true tags: - - Institutions & Profiles + - Meetings security: - jwtAuth: [] - tokenAuth: [] @@ -3552,29 +3920,29 @@ paths: - jwtAuth: [] responses: '204': - description: Entry deleted - /api/v1/projects/: + description: No response body + /api/v1/profiles/: get: - operationId: v1_projects_list - description: Returns a paginated list of collaborative projects with filtering - by institution, category, and status. - summary: List collaborative projects + operationId: v1_profiles_list + description: Returns a paginated list of mosques, cultural centers, and institutes + with filtering. + summary: List active institutions parameters: - in: query - name: category + name: city schema: type: string - description: Filter by category name + description: Filter by city name - in: query - name: institution_id + name: country schema: - type: integer - description: Filter by owner or collaborating institution ID + type: string + description: Filter by country name - in: query - name: owner_institution_id + name: is_featured schema: - type: integer - description: Filter specifically by owner institution ID + type: boolean + description: Filter by featured status - name: page required: false in: query @@ -3591,14 +3959,24 @@ paths: name: search schema: type: string - description: Search query across title and description + description: Search query across name, city, country, description - in: query - name: status + name: tag schema: type: string - description: Filter by project status (active, completed, on_hold, planning) + description: Filter by tag string + - in: query + name: type + schema: + type: string + description: Filter by institution type (mosque, cultural_center, etc.) + - in: query + name: verification_status + schema: + type: string + description: Filter by verification status (pending, approved, rejected) tags: - - Collaborative Projects & Kanban + - Institutions & Profiles security: - jwtAuth: [] - tokenAuth: [] @@ -3610,26 +3988,26 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/PaginatedProjectListList' + $ref: '#/components/schemas/PaginatedInstitutionListList' description: '' post: - operationId: v1_projects_create - description: Creates a new multi-institution collaborative project with default - Kanban columns. - summary: Create collaborative project + operationId: v1_profiles_create + description: Registers a new institution profile and designates the creator + as primary Administrator. + summary: Register a new institution tags: - - Collaborative Projects & Kanban + - Institutions & Profiles requestBody: content: application/json: schema: - $ref: '#/components/schemas/ProjectCreateUpdateRequest' + $ref: '#/components/schemas/InstitutionCreateUpdateRequest' application/x-www-form-urlencoded: schema: - $ref: '#/components/schemas/ProjectCreateUpdateRequest' + $ref: '#/components/schemas/InstitutionCreateUpdateRequest' multipart/form-data: schema: - $ref: '#/components/schemas/ProjectCreateUpdateRequest' + $ref: '#/components/schemas/InstitutionCreateUpdateRequest' required: true security: - jwtAuth: [] @@ -3641,18 +4019,18 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/ProjectDetail' + $ref: '#/components/schemas/InstitutionDetail' description: '' '400': description: Validation error '401': description: Authentication required - /api/v1/projects/{pk_or_slug}/: + /api/v1/profiles/{pk_or_slug}/: get: - operationId: v1_projects_retrieve - description: Retrieves full project details, collaborating institutions, and - Kanban boards. - summary: Get project details + operationId: v1_profiles_retrieve + description: Retrieves complete institution information, historical timeline + milestones, and media gallery. + summary: Get full institution profile parameters: - in: path name: pk_or_slug @@ -3660,7 +4038,7 @@ paths: type: string required: true tags: - - Collaborative Projects & Kanban + - Institutions & Profiles security: - jwtAuth: [] - tokenAuth: [] @@ -3672,15 +4050,15 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/ProjectDetail' + $ref: '#/components/schemas/InstitutionDetail' description: '' '404': - description: Project not found + description: Institution not found patch: - operationId: v1_projects_partial_update - description: Updates project status, progress percentage, or details. Permitted - for owner and collaborating editors. - summary: Update project + operationId: v1_profiles_partial_update + description: Updates details of an institution. Requires Admin or Editor role + on the institution. + summary: Update institution profile parameters: - in: path name: pk_or_slug @@ -3688,18 +4066,18 @@ paths: type: string required: true tags: - - Collaborative Projects & Kanban + - Institutions & Profiles requestBody: content: application/json: schema: - $ref: '#/components/schemas/PatchedProjectCreateUpdateRequest' + $ref: '#/components/schemas/PatchedInstitutionCreateUpdateRequest' application/x-www-form-urlencoded: schema: - $ref: '#/components/schemas/PatchedProjectCreateUpdateRequest' + $ref: '#/components/schemas/PatchedInstitutionCreateUpdateRequest' multipart/form-data: schema: - $ref: '#/components/schemas/PatchedProjectCreateUpdateRequest' + $ref: '#/components/schemas/PatchedInstitutionCreateUpdateRequest' security: - jwtAuth: [] - tokenAuth: [] @@ -3710,14 +4088,15 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/ProjectDetail' + $ref: '#/components/schemas/InstitutionDetail' description: '' '403': description: Permission denied delete: - operationId: v1_projects_destroy - description: Removes a collaborative project and associated Kanban boards. - summary: Delete project + operationId: v1_profiles_destroy + description: Deactivates an institution. Permitted for institution administrator + or platform administrator. + summary: Deactivate / Delete institution parameters: - in: path name: pk_or_slug @@ -3725,7 +4104,7 @@ paths: type: string required: true tags: - - Collaborative Projects & Kanban + - Institutions & Profiles security: - jwtAuth: [] - tokenAuth: [] @@ -3733,52 +4112,50 @@ paths: - jwtAuth: [] responses: '204': - description: Project deleted successfully + description: Institution successfully deactivated '403': description: Permission denied - /api/v1/projects/{pk_or_slug}/documents/: - get: - operationId: v1_projects_documents_list - description: Returns uploaded agreements, contracts, and progress reports for - a project. - summary: List project documents & MOUs + /api/v1/profiles/{pk_or_slug}/follow/: + post: + operationId: v1_profiles_follow_create + description: Follows the specified institution to receive updates and news. + summary: Follow institution parameters: - - name: page - required: false - in: query - description: A page number within the paginated result set. - schema: - type: integer - - name: page_size - required: false - in: query - description: Number of results to return per page. - schema: - type: integer - in: path name: pk_or_slug schema: type: string required: true tags: - - Collaborative Projects & Kanban + - Institutions & Profiles + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/FollowResponseRequest' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/FollowResponseRequest' + multipart/form-data: + schema: + $ref: '#/components/schemas/FollowResponseRequest' + required: true security: - jwtAuth: [] - tokenAuth: [] - cookieAuth: [] - jwtAuth: [] - - {} responses: '200': content: application/json: schema: - $ref: '#/components/schemas/PaginatedProjectDocumentList' + $ref: '#/components/schemas/FollowResponse' description: '' - post: - operationId: v1_projects_documents_create - description: Uploads an agreement, contract, or milestone report. - summary: Upload project document + delete: + operationId: v1_profiles_follow_destroy + description: Unfollows the specified institution. + summary: Unfollow institution parameters: - in: path name: pk_or_slug @@ -3786,69 +4163,24 @@ paths: type: string required: true tags: - - Collaborative Projects & Kanban - requestBody: - content: - multipart/form-data: - schema: - $ref: '#/components/schemas/ProjectDocumentRequest' - application/x-www-form-urlencoded: - schema: - $ref: '#/components/schemas/ProjectDocumentRequest' - application/json: - schema: - $ref: '#/components/schemas/ProjectDocumentRequest' - required: true + - Institutions & Profiles security: - jwtAuth: [] - tokenAuth: [] - cookieAuth: [] - jwtAuth: [] responses: - '201': + '200': content: application/json: schema: - $ref: '#/components/schemas/ProjectDocument' + $ref: '#/components/schemas/FollowResponse' description: '' - '400': - description: Validation error - '403': - description: Permission denied - /api/v1/projects/{pk_or_slug}/documents/{doc_id}/: - delete: - operationId: v1_projects_documents_destroy - description: Removes a document attachment from the project. - summary: Delete project document - parameters: - - in: path - name: doc_id - schema: - type: integer - required: true - - in: path - name: pk_or_slug - schema: - type: string - required: true - tags: - - Collaborative Projects & Kanban - security: - - jwtAuth: [] - - tokenAuth: [] - - cookieAuth: [] - - jwtAuth: [] - responses: - '204': - description: Document removed successfully - '403': - description: Permission denied - /api/v1/projects/{pk_or_slug}/kanban/: + /api/v1/profiles/{pk_or_slug}/media/: get: - operationId: v1_projects_kanban_list - description: Returns hierarchical Kanban columns with nested task lists for - drag-and-drop board interfaces. - summary: Get project Kanban board hierarchy + operationId: v1_profiles_media_list + description: Returns uploaded photos, videos, and documents for an institution. + summary: List media gallery assets parameters: - name: page required: false @@ -3868,7 +4200,7 @@ paths: type: string required: true tags: - - Collaborative Projects & Kanban + - Institutions & Profiles security: - jwtAuth: [] - tokenAuth: [] @@ -3880,13 +4212,13 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/PaginatedKanbanColumnList' + $ref: '#/components/schemas/PaginatedInstitutionMediaList' description: '' - /api/v1/projects/{pk_or_slug}/tasks/: post: - operationId: v1_projects_tasks_create - description: Adds a new task under a specific column of the project. - summary: Create Kanban task + operationId: v1_profiles_media_create + description: Uploads a photo, video, or publication to the institution media + gallery. + summary: Upload media to gallery parameters: - in: path name: pk_or_slug @@ -3894,18 +4226,18 @@ paths: type: string required: true tags: - - Collaborative Projects & Kanban + - Institutions & Profiles requestBody: content: - application/json: + multipart/form-data: schema: - $ref: '#/components/schemas/KanbanTaskCreateUpdateRequest' + $ref: '#/components/schemas/InstitutionMediaRequest' application/x-www-form-urlencoded: schema: - $ref: '#/components/schemas/KanbanTaskCreateUpdateRequest' - multipart/form-data: + $ref: '#/components/schemas/InstitutionMediaRequest' + application/json: schema: - $ref: '#/components/schemas/KanbanTaskCreateUpdateRequest' + $ref: '#/components/schemas/InstitutionMediaRequest' required: true security: - jwtAuth: [] @@ -3917,75 +4249,131 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/KanbanTask' + $ref: '#/components/schemas/InstitutionMedia' description: '' - '400': - description: Validation error - '403': - description: Permission denied - /api/v1/projects/{pk_or_slug}/tasks/{task_id}/: - patch: - operationId: v1_projects_tasks_partial_update - description: Updates task attributes, priority, assignee, or moves task to another - column / reorders within column. - summary: Update or move Kanban task + /api/v1/profiles/{pk_or_slug}/media/{media_id}/: + delete: + operationId: v1_profiles_media_destroy + description: Removes an asset from the institution media gallery. + summary: Delete media item parameters: + - in: path + name: media_id + schema: + type: integer + required: true - in: path name: pk_or_slug schema: type: string required: true - - in: path - name: task_id + tags: + - Institutions & Profiles + security: + - jwtAuth: [] + - tokenAuth: [] + - cookieAuth: [] + - jwtAuth: [] + responses: + '204': + description: Media deleted + /api/v1/profiles/{pk_or_slug}/members/: + get: + operationId: v1_profiles_members_list + description: Retrieves administrative and editorial team members of the institution. + summary: List institution team members + parameters: + - name: page + required: false + in: query + description: A page number within the paginated result set. + schema: + type: integer + - name: page_size + required: false + in: query + description: Number of results to return per page. schema: type: integer + - in: path + name: pk_or_slug + schema: + type: string required: true tags: - - Collaborative Projects & Kanban + - Institutions & Profiles + security: + - jwtAuth: [] + - tokenAuth: [] + - cookieAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/PaginatedInstitutionMemberList' + description: '' + post: + operationId: v1_profiles_members_create + description: Adds or updates a member role (admin, editor, viewer) for an existing + registered user by email. + summary: Delegate role / Add team member + parameters: + - in: path + name: pk_or_slug + schema: + type: string + required: true + tags: + - Institutions & Profiles requestBody: content: application/json: schema: - $ref: '#/components/schemas/PatchedKanbanTaskCreateUpdateRequest' + $ref: '#/components/schemas/InstitutionMemberAddRequest' application/x-www-form-urlencoded: schema: - $ref: '#/components/schemas/PatchedKanbanTaskCreateUpdateRequest' + $ref: '#/components/schemas/InstitutionMemberAddRequest' multipart/form-data: schema: - $ref: '#/components/schemas/PatchedKanbanTaskCreateUpdateRequest' + $ref: '#/components/schemas/InstitutionMemberAddRequest' + required: true security: - jwtAuth: [] - tokenAuth: [] - cookieAuth: [] - jwtAuth: [] responses: - '200': + '201': content: application/json: schema: - $ref: '#/components/schemas/KanbanTask' + $ref: '#/components/schemas/InstitutionMember' description: '' '400': - description: Validation error + description: User not found or invalid payload '403': - description: Permission denied + description: Only institution administrators can delegate roles + /api/v1/profiles/{pk_or_slug}/members/{member_id}/: delete: - operationId: v1_projects_tasks_destroy - description: Removes a task from the project Kanban board. - summary: Delete Kanban task + operationId: v1_profiles_members_destroy + description: Revokes institution membership and delegated role for a user. + summary: Remove team member parameters: - in: path - name: pk_or_slug + name: member_id schema: - type: string + type: integer required: true - in: path - name: task_id + name: pk_or_slug schema: - type: integer + type: string required: true tags: - - Collaborative Projects & Kanban + - Institutions & Profiles security: - jwtAuth: [] - tokenAuth: [] @@ -3993,217 +4381,580 @@ paths: - jwtAuth: [] responses: '204': - description: Task deleted successfully - '403': - description: Permission denied - /api/v1/settings/about-us/: + description: Member removed + /api/v1/profiles/{pk_or_slug}/timeline/: get: - operationId: v1_settings_about_us_retrieve + operationId: v1_profiles_timeline_list + description: Returns historical milestone entries for an institution. + summary: List timeline milestones + parameters: + - name: page + required: false + in: query + description: A page number within the paginated result set. + schema: + type: integer + - name: page_size + required: false + in: query + description: Number of results to return per page. + schema: + type: integer + - in: path + name: pk_or_slug + schema: + type: string + required: true tags: - - v1 + - Institutions & Profiles security: + - jwtAuth: [] - tokenAuth: [] + - cookieAuth: [] - jwtAuth: [] + - {} responses: '200': content: application/json: schema: - $ref: '#/components/schemas/AboutUs' + $ref: '#/components/schemas/PaginatedInstitutionTimelineList' description: '' - put: - operationId: v1_settings_about_us_update + post: + operationId: v1_profiles_timeline_create + description: Creates a new milestone in the institution historical timeline. + summary: Add timeline milestone + parameters: + - in: path + name: pk_or_slug + schema: + type: string + required: true tags: - - v1 + - Institutions & Profiles requestBody: content: application/json: schema: - $ref: '#/components/schemas/AboutUsRequest' + $ref: '#/components/schemas/InstitutionTimelineRequest' application/x-www-form-urlencoded: schema: - $ref: '#/components/schemas/AboutUsRequest' + $ref: '#/components/schemas/InstitutionTimelineRequest' multipart/form-data: schema: - $ref: '#/components/schemas/AboutUsRequest' + $ref: '#/components/schemas/InstitutionTimelineRequest' + required: true security: + - jwtAuth: [] - tokenAuth: [] + - cookieAuth: [] - jwtAuth: [] responses: - '200': + '201': content: application/json: schema: - $ref: '#/components/schemas/AboutUs' + $ref: '#/components/schemas/InstitutionTimeline' description: '' - /api/v1/settings/about-us-dobodi/: - get: - operationId: v1_settings_about_us_dobodi_retrieve - tags: - - v1 - security: - - tokenAuth: [] - - jwtAuth: [] + /api/v1/profiles/{pk_or_slug}/timeline/{entry_id}/: + delete: + operationId: v1_profiles_timeline_destroy + description: Removes a timeline entry from the institution profile. + summary: Delete timeline milestone + parameters: + - in: path + name: entry_id + schema: + type: integer + required: true + - in: path + name: pk_or_slug + schema: + type: string + required: true + tags: + - Institutions & Profiles + security: + - jwtAuth: [] + - tokenAuth: [] + - cookieAuth: [] + - jwtAuth: [] + responses: + '204': + description: Entry deleted + /api/v1/projects/: + get: + operationId: v1_projects_list + description: Returns a paginated list of collaborative projects with filtering + by institution, category, and status. + summary: List collaborative projects + parameters: + - in: query + name: category + schema: + type: string + description: Filter by category name + - in: query + name: institution_id + schema: + type: integer + description: Filter by owner or collaborating institution ID + - in: query + name: owner_institution_id + schema: + type: integer + description: Filter specifically by owner institution ID + - name: page + required: false + in: query + description: A page number within the paginated result set. + schema: + type: integer + - name: page_size + required: false + in: query + description: Number of results to return per page. + schema: + type: integer + - in: query + name: search + schema: + type: string + description: Search query across title and description + - in: query + name: status + schema: + type: string + description: Filter by project status (active, completed, on_hold, planning) + tags: + - Collaborative Projects & Kanban + security: + - jwtAuth: [] + - tokenAuth: [] + - cookieAuth: [] + - jwtAuth: [] + - {} responses: '200': content: application/json: schema: - $ref: '#/components/schemas/AboutUsDobodi' + $ref: '#/components/schemas/PaginatedProjectListList' description: '' - put: - operationId: v1_settings_about_us_dobodi_update + post: + operationId: v1_projects_create + description: Creates a new multi-institution collaborative project with default + Kanban columns. + summary: Create collaborative project tags: - - v1 + - Collaborative Projects & Kanban requestBody: content: application/json: schema: - $ref: '#/components/schemas/AboutUsDobodiRequest' + $ref: '#/components/schemas/ProjectCreateUpdateRequest' application/x-www-form-urlencoded: schema: - $ref: '#/components/schemas/AboutUsDobodiRequest' + $ref: '#/components/schemas/ProjectCreateUpdateRequest' multipart/form-data: schema: - $ref: '#/components/schemas/AboutUsDobodiRequest' + $ref: '#/components/schemas/ProjectCreateUpdateRequest' + required: true security: + - jwtAuth: [] - tokenAuth: [] + - cookieAuth: [] - jwtAuth: [] responses: - '200': + '201': content: application/json: schema: - $ref: '#/components/schemas/AboutUsDobodi' + $ref: '#/components/schemas/ProjectDetail' description: '' - /api/v1/settings/card/: + '400': + description: Validation error + '401': + description: Authentication required + /api/v1/projects/{pk_or_slug}/: get: - operationId: v1_settings_card_retrieve + operationId: v1_projects_retrieve + description: Retrieves full project details, collaborating institutions, and + Kanban boards. + summary: Get project details + parameters: + - in: path + name: pk_or_slug + schema: + type: string + required: true tags: - - v1 + - Collaborative Projects & Kanban security: + - jwtAuth: [] - tokenAuth: [] + - cookieAuth: [] - jwtAuth: [] + - {} responses: '200': content: application/json: schema: - $ref: '#/components/schemas/Card' + $ref: '#/components/schemas/ProjectDetail' description: '' - put: - operationId: v1_settings_card_update + '404': + description: Project not found + patch: + operationId: v1_projects_partial_update + description: Updates project status, progress percentage, or details. Permitted + for owner and collaborating editors. + summary: Update project + parameters: + - in: path + name: pk_or_slug + schema: + type: string + required: true tags: - - v1 + - Collaborative Projects & Kanban requestBody: content: application/json: schema: - $ref: '#/components/schemas/CardRequest' + $ref: '#/components/schemas/PatchedProjectCreateUpdateRequest' application/x-www-form-urlencoded: schema: - $ref: '#/components/schemas/CardRequest' + $ref: '#/components/schemas/PatchedProjectCreateUpdateRequest' multipart/form-data: schema: - $ref: '#/components/schemas/CardRequest' + $ref: '#/components/schemas/PatchedProjectCreateUpdateRequest' security: + - jwtAuth: [] - tokenAuth: [] + - cookieAuth: [] - jwtAuth: [] responses: '200': content: application/json: schema: - $ref: '#/components/schemas/Card' + $ref: '#/components/schemas/ProjectDetail' description: '' - /api/v1/settings/faq-course/: - get: - operationId: v1_settings_faq_course_retrieve + '403': + description: Permission denied + delete: + operationId: v1_projects_destroy + description: Removes a collaborative project and associated Kanban boards. + summary: Delete project + parameters: + - in: path + name: pk_or_slug + schema: + type: string + required: true tags: - - v1 + - Collaborative Projects & Kanban security: - jwtAuth: [] - tokenAuth: [] - cookieAuth: [] - jwtAuth: [] - - {} responses: - '200': - content: - application/json: - schema: - $ref: '#/components/schemas/FAQItem' - description: '' - /api/v1/settings/faq-general/: + '204': + description: Project deleted successfully + '403': + description: Permission denied + /api/v1/projects/{pk_or_slug}/documents/: get: - operationId: v1_settings_faq_general_retrieve + operationId: v1_projects_documents_list + description: Returns uploaded agreements, contracts, and progress reports for + a project. + summary: List project documents & MOUs + parameters: + - name: page + required: false + in: query + description: A page number within the paginated result set. + schema: + type: integer + - name: page_size + required: false + in: query + description: Number of results to return per page. + schema: + type: integer + - in: path + name: pk_or_slug + schema: + type: string + required: true tags: - - v1 + - Collaborative Projects & Kanban security: + - jwtAuth: [] - tokenAuth: [] + - cookieAuth: [] - jwtAuth: [] + - {} responses: '200': content: application/json: schema: - $ref: '#/components/schemas/FAQItem' + $ref: '#/components/schemas/PaginatedProjectDocumentList' description: '' - put: - operationId: v1_settings_faq_general_update + post: + operationId: v1_projects_documents_create + description: Uploads an agreement, contract, or milestone report. + summary: Upload project document + parameters: + - in: path + name: pk_or_slug + schema: + type: string + required: true tags: - - v1 + - Collaborative Projects & Kanban requestBody: content: - application/json: + multipart/form-data: schema: - $ref: '#/components/schemas/FAQItemRequest' + $ref: '#/components/schemas/ProjectDocumentRequest' application/x-www-form-urlencoded: schema: - $ref: '#/components/schemas/FAQItemRequest' - multipart/form-data: + $ref: '#/components/schemas/ProjectDocumentRequest' + application/json: schema: - $ref: '#/components/schemas/FAQItemRequest' + $ref: '#/components/schemas/ProjectDocumentRequest' + required: true security: - - tokenAuth: [] - jwtAuth: [] - responses: - '200': - content: - application/json: - schema: - $ref: '#/components/schemas/FAQItem' - description: '' - /api/v1/settings/support/: - get: - operationId: v1_settings_support_retrieve - tags: - - v1 - security: - tokenAuth: [] + - cookieAuth: [] - jwtAuth: [] responses: - '200': + '201': content: application/json: schema: - $ref: '#/components/schemas/Support' + $ref: '#/components/schemas/ProjectDocument' + description: '' + '400': + description: Validation error + '403': + description: Permission denied + /api/v1/projects/{pk_or_slug}/documents/{doc_id}/: + delete: + operationId: v1_projects_documents_destroy + description: Removes a document attachment from the project. + summary: Delete project document + parameters: + - in: path + name: doc_id + schema: + type: integer + required: true + - in: path + name: pk_or_slug + schema: + type: string + required: true + tags: + - Collaborative Projects & Kanban + security: + - jwtAuth: [] + - tokenAuth: [] + - cookieAuth: [] + - jwtAuth: [] + responses: + '204': + description: Document removed successfully + '403': + description: Permission denied + /api/v1/projects/{pk_or_slug}/kanban/: + get: + operationId: v1_projects_kanban_list + description: Returns hierarchical Kanban columns with nested task lists for + drag-and-drop board interfaces. + summary: Get project Kanban board hierarchy + parameters: + - name: page + required: false + in: query + description: A page number within the paginated result set. + schema: + type: integer + - name: page_size + required: false + in: query + description: Number of results to return per page. + schema: + type: integer + - in: path + name: pk_or_slug + schema: + type: string + required: true + tags: + - Collaborative Projects & Kanban + security: + - jwtAuth: [] + - tokenAuth: [] + - cookieAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/PaginatedKanbanColumnList' + description: '' + /api/v1/projects/{pk_or_slug}/tasks/: + post: + operationId: v1_projects_tasks_create + description: Adds a new task under a specific column of the project. + summary: Create Kanban task + parameters: + - in: path + name: pk_or_slug + schema: + type: string + required: true + tags: + - Collaborative Projects & Kanban + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/KanbanTaskCreateUpdateRequest' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/KanbanTaskCreateUpdateRequest' + multipart/form-data: + schema: + $ref: '#/components/schemas/KanbanTaskCreateUpdateRequest' + required: true + security: + - jwtAuth: [] + - tokenAuth: [] + - cookieAuth: [] + - jwtAuth: [] + responses: + '201': + content: + application/json: + schema: + $ref: '#/components/schemas/KanbanTask' + description: '' + '400': + description: Validation error + '403': + description: Permission denied + /api/v1/projects/{pk_or_slug}/tasks/{task_id}/: + patch: + operationId: v1_projects_tasks_partial_update + description: Updates task attributes, priority, assignee, or moves task to another + column / reorders within column. + summary: Update or move Kanban task + parameters: + - in: path + name: pk_or_slug + schema: + type: string + required: true + - in: path + name: task_id + schema: + type: integer + required: true + tags: + - Collaborative Projects & Kanban + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/PatchedKanbanTaskCreateUpdateRequest' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/PatchedKanbanTaskCreateUpdateRequest' + multipart/form-data: + schema: + $ref: '#/components/schemas/PatchedKanbanTaskCreateUpdateRequest' + security: + - jwtAuth: [] + - tokenAuth: [] + - cookieAuth: [] + - jwtAuth: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/KanbanTask' + description: '' + '400': + description: Validation error + '403': + description: Permission denied + delete: + operationId: v1_projects_tasks_destroy + description: Removes a task from the project Kanban board. + summary: Delete Kanban task + parameters: + - in: path + name: pk_or_slug + schema: + type: string + required: true + - in: path + name: task_id + schema: + type: integer + required: true + tags: + - Collaborative Projects & Kanban + security: + - jwtAuth: [] + - tokenAuth: [] + - cookieAuth: [] + - jwtAuth: [] + responses: + '204': + description: Task deleted successfully + '403': + description: Permission denied + /api/v1/settings/about-us/: + get: + operationId: v1_settings_about_us_retrieve + tags: + - v1 + security: + - tokenAuth: [] + - jwtAuth: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/AboutUs' description: '' put: - operationId: v1_settings_support_update + operationId: v1_settings_about_us_update tags: - v1 requestBody: content: application/json: schema: - $ref: '#/components/schemas/SupportRequest' + $ref: '#/components/schemas/AboutUsRequest' application/x-www-form-urlencoded: schema: - $ref: '#/components/schemas/SupportRequest' + $ref: '#/components/schemas/AboutUsRequest' multipart/form-data: schema: - $ref: '#/components/schemas/SupportRequest' + $ref: '#/components/schemas/AboutUsRequest' security: - tokenAuth: [] - jwtAuth: [] @@ -4212,371 +4963,1346 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/Support' + $ref: '#/components/schemas/AboutUs' description: '' - /api/v1/version/: + /api/v1/settings/about-us-dobodi/: get: - operationId: v1_version_retrieve - description: Returns latest active mobile/web application version details - summary: Get active application version + operationId: v1_settings_about_us_dobodi_retrieve tags: - v1 security: - - jwtAuth: [] - tokenAuth: [] - - cookieAuth: [] - jwtAuth: [] - - {} responses: '200': content: application/json: schema: - $ref: '#/components/schemas/AppVersion' + $ref: '#/components/schemas/AboutUsDobodi' description: '' - '404': - description: No active version found -components: - schemas: - AboutUs: - type: object - properties: - content: - type: string - default: '' - AboutUsDobodi: - type: object - properties: - arabic_text: - type: string - default: '' - translated_text: - type: string - default: '' - title: - type: string - default: '' + put: + operationId: v1_settings_about_us_dobodi_update + tags: + - v1 + requestBody: content: + application/json: + schema: + $ref: '#/components/schemas/AboutUsDobodiRequest' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/AboutUsDobodiRequest' + multipart/form-data: + schema: + $ref: '#/components/schemas/AboutUsDobodiRequest' + security: + - tokenAuth: [] + - jwtAuth: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/AboutUsDobodi' + description: '' + /api/v1/settings/card/: + get: + operationId: v1_settings_card_retrieve + tags: + - v1 + security: + - tokenAuth: [] + - jwtAuth: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/Card' + description: '' + put: + operationId: v1_settings_card_update + tags: + - v1 + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/CardRequest' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/CardRequest' + multipart/form-data: + schema: + $ref: '#/components/schemas/CardRequest' + security: + - tokenAuth: [] + - jwtAuth: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/Card' + description: '' + /api/v1/settings/faq-course/: + get: + operationId: v1_settings_faq_course_retrieve + tags: + - v1 + security: + - jwtAuth: [] + - tokenAuth: [] + - cookieAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/FAQItem' + description: '' + /api/v1/settings/faq-general/: + get: + operationId: v1_settings_faq_general_retrieve + tags: + - v1 + security: + - tokenAuth: [] + - jwtAuth: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/FAQItem' + description: '' + put: + operationId: v1_settings_faq_general_update + tags: + - v1 + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/FAQItemRequest' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/FAQItemRequest' + multipart/form-data: + schema: + $ref: '#/components/schemas/FAQItemRequest' + security: + - tokenAuth: [] + - jwtAuth: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/FAQItem' + description: '' + /api/v1/settings/support/: + get: + operationId: v1_settings_support_retrieve + tags: + - v1 + security: + - tokenAuth: [] + - jwtAuth: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/Support' + description: '' + put: + operationId: v1_settings_support_update + tags: + - v1 + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/SupportRequest' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/SupportRequest' + multipart/form-data: + schema: + $ref: '#/components/schemas/SupportRequest' + security: + - tokenAuth: [] + - jwtAuth: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/Support' + description: '' + /api/v1/tickets/: + get: + operationId: v1_tickets_list + description: Retrieves a list of official inquiry tickets submitted by the user + or their institution. + summary: List user and institution tickets + parameters: + - in: query + name: category + schema: + type: string + description: Filter by category (cultural_affairs, religious_inquiry, etc.) + - name: page + required: false + in: query + description: A page number within the paginated result set. + schema: + type: integer + - name: page_size + required: false + in: query + description: Number of results to return per page. + schema: + type: integer + - in: query + name: priority + schema: + type: string + description: Filter by priority (urgent, high, medium, low) + - in: query + name: search + schema: + type: string + description: Search in subject or tracking code + - in: query + name: status + schema: + type: string + description: Filter by status (open, in_progress, answered, closed) + tags: + - Ticketing & Official Inquiries + security: + - jwtAuth: [] + - tokenAuth: [] + - cookieAuth: [] + - jwtAuth: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/PaginatedTicketListList' + description: '' + post: + operationId: v1_tickets_create + description: Creates a new official inquiry ticket with initial message content + and optional file attachment. + summary: Submit an official ticket inquiry + tags: + - Ticketing & Official Inquiries + requestBody: + content: + multipart/form-data: + schema: + $ref: '#/components/schemas/TicketCreateRequest' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/TicketCreateRequest' + application/json: + schema: + $ref: '#/components/schemas/TicketCreateRequest' + required: true + security: + - jwtAuth: [] + - tokenAuth: [] + - cookieAuth: [] + - jwtAuth: [] + responses: + '201': + content: + application/json: + schema: + $ref: '#/components/schemas/TicketDetail' + description: '' + '400': + description: Validation error + /api/v1/tickets/{pk_or_code}/: + get: + operationId: v1_tickets_retrieve + description: Returns full ticket details including all dialogue messages and + attachments. + summary: Retrieve ticket thread and responses + parameters: + - in: path + name: pk_or_code + schema: + type: string + required: true + tags: + - Ticketing & Official Inquiries + security: + - jwtAuth: [] + - tokenAuth: [] + - cookieAuth: [] + - jwtAuth: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/TicketDetail' + description: '' + '403': + description: Permission denied + '404': + description: Ticket not found + patch: + operationId: v1_tickets_partial_update + description: Updates ticket priority, category, or assigned expert. + summary: Update ticket status or priority (Staff/Owner) + parameters: + - in: path + name: pk_or_code + schema: + type: string + required: true + tags: + - Ticketing & Official Inquiries + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/PatchedTicketListRequest' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/PatchedTicketListRequest' + multipart/form-data: + schema: + $ref: '#/components/schemas/PatchedTicketListRequest' + security: + - jwtAuth: [] + - tokenAuth: [] + - cookieAuth: [] + - jwtAuth: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/TicketDetail' + description: '' + /api/v1/tickets/{pk_or_code}/close/: + post: + operationId: v1_tickets_close_create + description: Marks a ticket as closed and records the resolution timestamp. + summary: Close ticket inquiry + parameters: + - in: path + name: pk_or_code + schema: + type: string + required: true + tags: + - Ticketing & Official Inquiries + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/TicketDetailRequest' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/TicketDetailRequest' + multipart/form-data: + schema: + $ref: '#/components/schemas/TicketDetailRequest' + required: true + security: + - jwtAuth: [] + - tokenAuth: [] + - cookieAuth: [] + - jwtAuth: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/TicketDetail' + description: '' + /api/v1/tickets/{pk_or_code}/rate/: + post: + operationId: v1_tickets_rate_create + description: Records 1 to 5 star rating and optional user feedback upon inquiry + completion. + summary: Submit satisfaction rating for ticket + parameters: + - in: path + name: pk_or_code + schema: + type: string + required: true + tags: + - Ticketing & Official Inquiries + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/TicketRateRequest' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/TicketRateRequest' + multipart/form-data: + schema: + $ref: '#/components/schemas/TicketRateRequest' + required: true + security: + - jwtAuth: [] + - tokenAuth: [] + - cookieAuth: [] + - jwtAuth: [] + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/TicketDetail' + description: '' + '400': + description: Validation error + /api/v1/tickets/{pk_or_code}/reply/: + post: + operationId: v1_tickets_reply_create + description: Appends a new message or official response to an existing ticket + thread. + summary: Reply to ticket inquiry + parameters: + - in: path + name: pk_or_code + schema: + type: string + required: true + tags: + - Ticketing & Official Inquiries + requestBody: + content: + multipart/form-data: + schema: + $ref: '#/components/schemas/TicketMessageCreateRequest' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/TicketMessageCreateRequest' + application/json: + schema: + $ref: '#/components/schemas/TicketMessageCreateRequest' + required: true + security: + - jwtAuth: [] + - tokenAuth: [] + - cookieAuth: [] + - jwtAuth: [] + responses: + '201': + content: + application/json: + schema: + $ref: '#/components/schemas/TicketMessage' + description: '' + '400': + description: Invalid reply + '403': + description: Permission denied + /api/v1/version/: + get: + operationId: v1_version_retrieve + description: Returns latest active mobile/web application version details + summary: Get active application version + tags: + - v1 + security: + - jwtAuth: [] + - tokenAuth: [] + - cookieAuth: [] + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/AppVersion' + description: '' + '404': + description: No active version found +components: + schemas: + AboutUs: + type: object + properties: + content: + type: string + default: '' + AboutUsDobodi: + type: object + properties: + arabic_text: + type: string + default: '' + translated_text: + type: string + default: '' + title: + type: string + default: '' + content: + type: string + default: '' + AboutUsDobodiRequest: + type: object + properties: + arabic_text: + type: string + default: '' + translated_text: + type: string + default: '' + title: + type: string + default: '' + content: + type: string + default: '' + AboutUsRequest: + type: object + properties: + content: + type: string + default: '' + AdminNotification: + type: object + properties: + id: + type: integer + readOnly: true + title: + type: string + maxLength: 255 + message: + type: string + maxLength: 1024 + is_read: + type: boolean + notification_type: + type: string + nullable: true + maxLength: 50 + action: + type: string + maxLength: 50 + navigate_to: + type: string + nullable: true + maxLength: 255 + created_at: + type: string + format: date-time + readOnly: true + updated_at: + type: string + format: date-time + readOnly: true + user_fullname: + type: string + readOnly: true + user_email: + type: string + readOnly: true + user_id: + type: integer + readOnly: true + required: + - created_at + - id + - message + - title + - updated_at + - user_email + - user_fullname + - user_id + AdminNotificationRequest: + type: object + properties: + title: + type: string + minLength: 1 + maxLength: 255 + message: + type: string + minLength: 1 + maxLength: 1024 + is_read: + type: boolean + notification_type: + type: string + nullable: true + maxLength: 50 + action: + type: string + minLength: 1 + maxLength: 50 + navigate_to: + type: string + nullable: true + maxLength: 255 + required: + - message + - title + AdminUser: + 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 + phone_number: + type: string + nullable: true + description: e.g., +1 555 1234567 + maxLength: 128 + avatar: + type: string + nullable: true + gender: + nullable: true + oneOf: + - $ref: '#/components/schemas/GenderEnum' + - $ref: '#/components/schemas/BlankEnum' + - $ref: '#/components/schemas/NullEnum' + birthdate: + type: string + format: date + nullable: true + info: + type: string + nullable: true + title: Bio / Info + skill: + type: string + nullable: true + title: Skill / Role + maxLength: 512 + city: + type: string + nullable: true + maxLength: 255 + country: + type: string + nullable: true + maxLength: 255 + device_id: + type: string + readOnly: true + device_os: + type: string + readOnly: true + user_agent: + type: string + readOnly: true + client_ip: + type: string + readOnly: true + fcm: + type: string + nullable: true + title: FCM Token + maxLength: 512 + user_type: + $ref: '#/components/schemas/UserTypeEnum' + is_active: + type: boolean + title: Active + description: Designates whether this user should be treated as active. + is_staff: + type: boolean + is_superuser: + type: boolean + title: Superuser status + description: Designates that this user has all permissions without explicitly + assigning them. + date_joined: + type: string + format: date-time + readOnly: true + last_login: + type: string + format: date-time + readOnly: true + nullable: true + auth_token: + type: string + readOnly: true + plain_password: + type: string + readOnly: true + required: + - auth_token + - client_ip + - date_joined + - device_id + - device_os + - email + - id + - last_login + - plain_password + - user_agent + AdminUserRequest: + type: object + properties: + fullname: + type: string + nullable: true + title: Full Name + description: Full name of the user. + maxLength: 255 + email: + type: string + format: email + minLength: 1 + phone_number: + type: string + nullable: true + description: e.g., +1 555 1234567 + maxLength: 128 + password: + type: string + writeOnly: true + minLength: 1 + avatar: + type: string + nullable: true + minLength: 1 + gender: + nullable: true + oneOf: + - $ref: '#/components/schemas/GenderEnum' + - $ref: '#/components/schemas/BlankEnum' + - $ref: '#/components/schemas/NullEnum' + birthdate: + type: string + format: date + nullable: true + info: + type: string + nullable: true + title: Bio / Info + skill: + type: string + nullable: true + title: Skill / Role + maxLength: 512 + city: + type: string + nullable: true + maxLength: 255 + country: + type: string + nullable: true + maxLength: 255 + fcm: + type: string + nullable: true + title: FCM Token + maxLength: 512 + user_type: + $ref: '#/components/schemas/UserTypeEnum' + is_active: + type: boolean + title: Active + description: Designates whether this user should be treated as active. + is_staff: + type: boolean + is_superuser: + type: boolean + title: Superuser status + description: Designates that this user has all permissions without explicitly + assigning them. + required: + - email + AppTypeEnum: + enum: + - google_play + - app_store + - direct + type: string + description: |- + * `google_play` - Google Play + * `app_store` - Apple App Store + * `direct` - Direct Download + AppVersion: + type: object + properties: + id: + type: integer + readOnly: true + version: + type: string + description: Application version in format X.Y.Z (e.g., 1.0.0) + pattern: ^\d+\.\d+\.\d+$ + maxLength: 20 + apk_file: + type: string + format: uri + nullable: true + readOnly: true + description: + type: string + description: Release notes and changes for this version + app_type: + allOf: + - $ref: '#/components/schemas/AppTypeEnum' + title: App Distribution Platform + downloads_count: + type: integer + is_active: + type: boolean + title: Active + description: Is this version currently active? + created_at: + type: string + format: date-time + readOnly: true + updated_at: + type: string + format: date-time + readOnly: true + required: + - apk_file + - created_at + - id + - updated_at + - version + BlankEnum: + enum: + - '' + CMSCommentStatusEnum: + enum: + - pending + - approved + - spam + type: string + description: |- + * `pending` - Pending Moderation + * `approved` - Approved + * `spam` - Spam / Rejected + CMSPostStatusEnum: + enum: + - draft + - review + - published + - archived + type: string + description: |- + * `draft` - Draft + * `review` - Under Review + * `published` - Published + * `archived` - Archived + Card: + type: object + properties: + card_number: + type: string + default: '' + card_name: + type: string + default: '' + whatsapp_number: type: string default: '' - AboutUsDobodiRequest: + CardRequest: type: object properties: - arabic_text: - type: string - default: '' - translated_text: + card_number: type: string default: '' - title: + card_name: type: string default: '' - content: + whatsapp_number: type: string default: '' - AboutUsRequest: + CentrifugoTokenResponse: type: object properties: - content: + token: type: string - default: '' - AdminNotification: + description: Signed HMAC-SHA256 JWT for Centrifugo WebSocket connection + ws_url: + type: string + description: WebSocket endpoint URL for frontend client + user_id: + type: integer + description: Authenticated user ID + expires_in: + type: integer + description: Token lifetime in seconds + required: + - expires_in + - token + - user_id + - ws_url + ChatInstitutionMini: type: object properties: id: type: integer readOnly: true - title: - type: string - maxLength: 255 - message: + name: type: string - maxLength: 1024 - is_read: - type: boolean - notification_type: + readOnly: true + title: Institution Name + description: Official name of the mosque, center, or institute. + slug: type: string + readOnly: true nullable: true - maxLength: 50 - action: + title: Slug / URL Identifier + pattern: ^[-\w]+$ + type: + allOf: + - $ref: '#/components/schemas/TypeEnum' + readOnly: true + title: Institution Type + type_display: type: string - maxLength: 50 - navigate_to: + readOnly: true + avatar: type: string + format: uri nullable: true - maxLength: 255 - created_at: + readOnly: true + title: Logo / Avatar + city: type: string - format: date-time readOnly: true - updated_at: + country: type: string - format: date-time readOnly: true - user_fullname: + required: + - avatar + - city + - country + - id + - name + - slug + - type + - type_display + ChatMessage: + type: object + properties: + id: + type: integer + readOnly: true + room: + type: integer + readOnly: true + title: Chat Room + sender: + allOf: + - $ref: '#/components/schemas/ChatUserMini' + readOnly: true + sender_institution: + allOf: + - $ref: '#/components/schemas/ChatInstitutionMini' + readOnly: true + content: type: string readOnly: true - user_email: + nullable: true + title: Message Text + attachment: type: string + format: uri + nullable: true readOnly: true - user_id: + title: Attachment File + attachment_name: + type: string + readOnly: true + nullable: true + title: Attachment Filename + is_read_by_me: + type: boolean + readOnly: true + read_by_count: type: integer readOnly: true + created_at: + type: string + format: date-time + readOnly: true + title: Timestamp required: + - attachment + - attachment_name + - content - created_at - id - - message - - title - - updated_at - - user_email - - user_fullname - - user_id - AdminNotificationRequest: + - is_read_by_me + - read_by_count + - room + - sender + - sender_institution + ChatMessageCreateRequest: type: object properties: - title: - type: string - minLength: 1 - maxLength: 255 - message: + content: type: string - minLength: 1 - maxLength: 1024 - is_read: - type: boolean - notification_type: + attachment: type: string + format: binary nullable: true - maxLength: 50 - action: - type: string - minLength: 1 - maxLength: 50 - navigate_to: - type: string + sender_institution_id: + type: integer nullable: true - maxLength: 255 - required: - - message - - title - AdminUser: + ChatRoomCreateRequest: type: object properties: - id: - type: integer - readOnly: true - fullname: + room_type: + allOf: + - $ref: '#/components/schemas/RoomTypeEnum' + default: direct + title: type: string - nullable: true - title: Full Name - description: Full name of the user. maxLength: 255 - email: - type: string - format: email - phone_number: - type: string - nullable: true - description: e.g., +1 555 1234567 - maxLength: 128 - avatar: - type: string - nullable: true - gender: - nullable: true - oneOf: - - $ref: '#/components/schemas/GenderEnum' - - $ref: '#/components/schemas/BlankEnum' - - $ref: '#/components/schemas/NullEnum' - birthdate: - type: string - format: date + target_user_id: + type: integer nullable: true - info: - type: string + target_institution_id: + type: integer nullable: true - title: Bio / Info - skill: + participant_user_ids: + type: array + items: + type: integer + participant_institution_ids: + type: array + items: + type: integer + initial_message: type: string - nullable: true - title: Skill / Role - maxLength: 512 - city: + ChatRoomDetail: + type: object + properties: + id: + type: integer + readOnly: true + room_type: + allOf: + - $ref: '#/components/schemas/RoomTypeEnum' + readOnly: true + title: type: string + readOnly: true nullable: true - maxLength: 255 - country: + title: Conversation Title + display_title: type: string - nullable: true - maxLength: 255 - device_id: + readOnly: true + participant_users: + type: array + items: + $ref: '#/components/schemas/ChatUserMini' + readOnly: true + participant_institutions: + type: array + items: + $ref: '#/components/schemas/ChatInstitutionMini' + readOnly: true + unread_count: + type: integer + readOnly: true + centrifugo_channel: type: string readOnly: true - device_os: + created_at: type: string + format: date-time readOnly: true - user_agent: + updated_at: type: string + format: date-time readOnly: true - client_ip: - type: string + title: Last Activity + required: + - centrifugo_channel + - created_at + - display_title + - id + - participant_institutions + - participant_users + - room_type + - title + - unread_count + - updated_at + ChatRoomList: + type: object + properties: + id: + type: integer readOnly: true - fcm: + room_type: + allOf: + - $ref: '#/components/schemas/RoomTypeEnum' + readOnly: true + title: type: string + readOnly: true nullable: true - title: FCM Token - maxLength: 512 - user_type: - $ref: '#/components/schemas/UserTypeEnum' - is_active: - type: boolean - title: Active - description: Designates whether this user should be treated as active. - is_staff: - type: boolean - is_superuser: - type: boolean - title: Superuser status - description: Designates that this user has all permissions without explicitly - assigning them. - date_joined: + title: Conversation Title + display_title: type: string - format: date-time readOnly: true - last_login: - type: string - format: date-time + participant_users: + type: array + items: + $ref: '#/components/schemas/ChatUserMini' readOnly: true + participant_institutions: + type: array + items: + $ref: '#/components/schemas/ChatInstitutionMini' + readOnly: true + last_message: + allOf: + - $ref: '#/components/schemas/ChatMessage' nullable: true - auth_token: + readOnly: true + unread_count: + type: integer + readOnly: true + centrifugo_channel: type: string readOnly: true - plain_password: + updated_at: type: string + format: date-time readOnly: true + title: Last Activity required: - - auth_token - - client_ip - - date_joined - - device_id - - device_os - - email + - centrifugo_channel + - display_title - id - - last_login - - plain_password - - user_agent - AdminUserRequest: + - last_message + - participant_institutions + - participant_users + - room_type + - title + - unread_count + - updated_at + ChatUserMini: type: object properties: + id: + type: integer + readOnly: true + email: + type: string + format: email + readOnly: true + nullable: true + title: Email Address + description: User primary email address. fullname: type: string + readOnly: true nullable: true title: Full Name description: Full name of the user. - maxLength: 255 - email: - type: string - format: email - minLength: 1 - phone_number: + avatar: type: string + format: uri nullable: true - description: e.g., +1 555 1234567 - maxLength: 128 - password: + readOnly: true + user_type: + allOf: + - $ref: '#/components/schemas/UserTypeEnum' + readOnly: true + required: + - avatar + - email + - fullname + - id + - user_type + ContentLanguageEnum: + enum: + - fa + - ar + - en + - ur + - fr + type: string + description: |- + * `fa` - Persian + * `ar` - Arabic + * `en` - English + * `ur` - Urdu + * `fr` - French + CourseCreateUpdateRequest: + type: object + properties: + title: type: string - writeOnly: true minLength: 1 - avatar: + title: Course Title + maxLength: 255 + category: + $ref: '#/components/schemas/LMSCourseCategoryEnum' + language: + $ref: '#/components/schemas/ContentLanguageEnum' + level: + allOf: + - $ref: '#/components/schemas/LMSCourseLevelEnum' + title: Difficulty Level + cover_image: type: string + format: binary nullable: true - minLength: 1 - gender: - nullable: true - oneOf: - - $ref: '#/components/schemas/GenderEnum' - - $ref: '#/components/schemas/BlankEnum' - - $ref: '#/components/schemas/NullEnum' - birthdate: + instructor_name: type: string - format: date - nullable: true - info: + minLength: 1 + maxLength: 255 + instructor_title: type: string nullable: true - title: Bio / Info - skill: + title: Instructor Title / Specialty + maxLength: 255 + instructor_avatar: type: string + format: binary nullable: true - title: Skill / Role - maxLength: 512 - city: - type: string + institution: + type: integer nullable: true - maxLength: 255 - country: + title: Partner Institution / Center + organization_name: type: string nullable: true + title: Organization / Academy Name maxLength: 255 - fcm: + description: type: string - nullable: true - title: FCM Token - maxLength: 512 - user_type: - $ref: '#/components/schemas/UserTypeEnum' - is_active: - type: boolean - title: Active - description: Designates whether this user should be treated as active. - is_staff: + minLength: 1 + title: Course Description & Syllabus Summary + duration_hours: + type: number + format: double + title: Duration (Hours) + is_published: type: boolean - is_superuser: + is_featured: type: boolean - title: Superuser status - description: Designates that this user has all permissions without explicitly - assigning them. required: - - email - AppTypeEnum: - enum: - - google_play - - app_store - - direct - type: string - description: |- - * `google_play` - Google Play - * `app_store` - Apple App Store - * `direct` - Direct Download - AppVersion: + - description + - instructor_name + - title + CourseDetail: type: object properties: id: type: integer readOnly: true - version: + title: type: string - description: Application version in format X.Y.Z (e.g., 1.0.0) - pattern: ^\d+\.\d+\.\d+$ - maxLength: 20 - apk_file: + title: Course Title + maxLength: 255 + slug: + nullable: true + title: Slug / URL Identifier + oneOf: + - type: string + pattern: ^[-\w]+$ + maxLength: 280 + - type: string + maxLength: 0 + category: + $ref: '#/components/schemas/LMSCourseCategoryEnum' + language: + $ref: '#/components/schemas/ContentLanguageEnum' + level: + allOf: + - $ref: '#/components/schemas/LMSCourseLevelEnum' + title: Difficulty Level + cover_image: + type: string + format: uri + nullable: true + instructor_name: + type: string + maxLength: 255 + instructor_title: + type: string + nullable: true + title: Instructor Title / Specialty + maxLength: 255 + instructor_avatar: type: string format: uri nullable: true + institution: + type: integer + nullable: true + title: Partner Institution / Center + organization_name: + type: string + nullable: true + title: Organization / Academy Name + maxLength: 255 + description: + type: string + title: Course Description & Syllabus Summary + duration_hours: + type: number + format: double + title: Duration (Hours) + rating: + type: number + format: double + title: Average Rating + reviews_count: + type: integer + is_published: + type: boolean + is_featured: + type: boolean + modules: + type: array + items: + $ref: '#/components/schemas/CourseModule' readOnly: true - description: - type: string - description: Release notes and changes for this version - app_type: - allOf: - - $ref: '#/components/schemas/AppTypeEnum' - title: App Distribution Platform - downloads_count: + total_lessons: type: integer - is_active: + readOnly: true + total_students: + type: integer + readOnly: true + is_enrolled: type: boolean - title: Active - description: Is this version currently active? + readOnly: true + progress_percentage: + type: integer + readOnly: true + certificate_code: + type: string + readOnly: true created_at: type: string format: date-time @@ -4586,283 +6312,450 @@ components: format: date-time readOnly: true required: - - apk_file + - certificate_code - created_at + - description - id + - instructor_name + - is_enrolled + - modules + - progress_percentage + - title + - total_lessons + - total_students - updated_at - - version - BlankEnum: - enum: - - '' - CMSCommentStatusEnum: - enum: - - pending - - approved - - spam - type: string - description: |- - * `pending` - Pending Moderation - * `approved` - Approved - * `spam` - Spam / Rejected - CMSPostLanguageEnum: - enum: - - fa - - ar - - en - - ur - - fr - type: string - description: |- - * `fa` - Persian - * `ar` - Arabic - * `en` - English - * `ur` - Urdu - * `fr` - French - CMSPostStatusEnum: - enum: - - draft - - review - - published - - archived - type: string - description: |- - * `draft` - Draft - * `review` - Under Review - * `published` - Published - * `archived` - Archived - Card: + CourseDetailRequest: type: object properties: - card_number: - type: string - default: '' - card_name: + title: type: string - default: '' - whatsapp_number: + minLength: 1 + title: Course Title + maxLength: 255 + slug: + nullable: true + title: Slug / URL Identifier + oneOf: + - type: string + pattern: ^[-\w]+$ + maxLength: 280 + - type: string + maxLength: 0 + category: + $ref: '#/components/schemas/LMSCourseCategoryEnum' + language: + $ref: '#/components/schemas/ContentLanguageEnum' + level: + allOf: + - $ref: '#/components/schemas/LMSCourseLevelEnum' + title: Difficulty Level + cover_image: type: string - default: '' - CardRequest: - type: object - properties: - card_number: + format: binary + nullable: true + instructor_name: type: string - default: '' - card_name: + minLength: 1 + maxLength: 255 + instructor_title: type: string - default: '' - whatsapp_number: + nullable: true + title: Instructor Title / Specialty + maxLength: 255 + instructor_avatar: type: string - default: '' - CategoryEnum: - enum: - - religious - - educational - - cultural - - counseling - - conference - - workshop - type: string - description: |- - * `religious` - Religious - * `educational` - Educational - * `cultural` - Cultural - * `counseling` - Counseling - * `conference` - Conference - * `workshop` - Workshop - CentrifugoTokenResponse: - type: object - properties: - token: + format: binary + nullable: true + institution: + type: integer + nullable: true + title: Partner Institution / Center + organization_name: type: string - description: Signed HMAC-SHA256 JWT for Centrifugo WebSocket connection - ws_url: + nullable: true + title: Organization / Academy Name + maxLength: 255 + description: type: string - description: WebSocket endpoint URL for frontend client - user_id: - type: integer - description: Authenticated user ID - expires_in: + minLength: 1 + title: Course Description & Syllabus Summary + duration_hours: + type: number + format: double + title: Duration (Hours) + rating: + type: number + format: double + title: Average Rating + reviews_count: type: integer - description: Token lifetime in seconds + is_published: + type: boolean + is_featured: + type: boolean required: - - expires_in - - token - - user_id - - ws_url - ChatInstitutionMini: + - description + - instructor_name + - title + CourseList: type: object properties: id: type: integer readOnly: true - name: + title: type: string - readOnly: true - title: Institution Name - description: Official name of the mosque, center, or institute. + title: Course Title + maxLength: 255 slug: - type: string - readOnly: true nullable: true title: Slug / URL Identifier - pattern: ^[-\w]+$ - type: + oneOf: + - type: string + pattern: ^[-\w]+$ + maxLength: 280 + - type: string + maxLength: 0 + category: + $ref: '#/components/schemas/LMSCourseCategoryEnum' + language: + $ref: '#/components/schemas/ContentLanguageEnum' + level: allOf: - - $ref: '#/components/schemas/TypeEnum' - readOnly: true - title: Institution Type - type_display: + - $ref: '#/components/schemas/LMSCourseLevelEnum' + title: Difficulty Level + cover_image: type: string - readOnly: true - avatar: + format: uri + nullable: true + instructor_name: + type: string + maxLength: 255 + instructor_title: + type: string + nullable: true + title: Instructor Title / Specialty + maxLength: 255 + instructor_avatar: type: string format: uri nullable: true + organization_name: + type: string + nullable: true + title: Organization / Academy Name + maxLength: 255 + duration_hours: + type: number + format: double + title: Duration (Hours) + rating: + type: number + format: double + title: Average Rating + reviews_count: + type: integer + is_featured: + type: boolean + total_lessons: + type: integer readOnly: true - title: Logo / Avatar - city: + total_students: + type: integer + readOnly: true + is_enrolled: + type: boolean + readOnly: true + progress_percentage: + type: integer + readOnly: true + created_at: type: string + format: date-time readOnly: true - country: + required: + - created_at + - id + - instructor_name + - is_enrolled + - progress_percentage + - title + - total_lessons + - total_students + CourseModule: + type: object + properties: + id: + type: integer + readOnly: true + title: + type: string + title: Module Title + maxLength: 255 + description: type: string + nullable: true + title: Module Description + order: + type: integer + title: Display Order + lessons: + type: array + items: + $ref: '#/components/schemas/LessonList' readOnly: true required: - - avatar - - city - - country - id - - name - - slug - - type - - type_display - ChatMessage: + - lessons + - title + CourseModuleRequest: + type: object + properties: + title: + type: string + minLength: 1 + title: Module Title + maxLength: 255 + description: + type: string + nullable: true + title: Module Description + order: + type: integer + title: Display Order + required: + - title + CourseProgress: + type: object + properties: + course_id: + type: integer + course_title: + type: string + progress_percentage: + type: integer + completed_lessons_count: + type: integer + total_lessons_count: + type: integer + is_completed: + type: boolean + certificate_code: + type: string + nullable: true + required: + - certificate_code + - completed_lessons_count + - course_id + - course_title + - is_completed + - progress_percentage + - total_lessons_count + DeviceOsEnum: + enum: + - android + - apple + - web + type: string + description: |- + * `android` - Android + * `apple` - Apple iOS + * `web` - Web + DigitalCertificate: type: object properties: id: type: integer readOnly: true - room: - type: integer - readOnly: true - title: Chat Room - sender: - allOf: - - $ref: '#/components/schemas/ChatUserMini' + certificate_code: + type: string + title: Verification Code + description: e.g. CERT-RAZAVI-2026-AB12CD + maxLength: 100 + uuid: + type: string + format: uuid readOnly: true - sender_institution: - allOf: - - $ref: '#/components/schemas/ChatInstitutionMini' + title: Unique UUID + recipient_name: + type: string readOnly: true - content: + course_title: type: string readOnly: true - nullable: true - title: Message Text - attachment: + course_slug: type: string - format: uri - nullable: true readOnly: true - title: Attachment File - attachment_name: + organization: type: string readOnly: true + issue_date: + type: string + format: date + final_score: + type: number + format: double + title: Final Score (%) + certificate_file: + type: string + format: uri nullable: true - title: Attachment Filename - is_read_by_me: + title: Certificate PDF Document + is_revoked: type: boolean - readOnly: true - read_by_count: - type: integer - readOnly: true created_at: type: string format: date-time readOnly: true - title: Timestamp required: - - attachment - - attachment_name - - content + - certificate_code + - course_slug + - course_title - created_at - id - - is_read_by_me - - read_by_count - - room - - sender - - sender_institution - ChatMessageCreateRequest: + - organization + - recipient_name + - uuid + DiplomacyAnalytics: type: object properties: - content: - type: string - attachment: - type: string - format: binary - nullable: true - sender_institution_id: + total_reports_submitted: type: integer - nullable: true - ChatRoomCreateRequest: + approved_reports_count: + type: integer + total_centers_visited: + type: integer + required: + - approved_reports_count + - total_centers_visited + - total_reports_submitted + DiplomacyReportStatusEnum: + enum: + - submitted + - under_review + - approved + - returned + type: string + description: |- + * `submitted` - Submitted + * `under_review` - Under Review + * `approved` - Approved by Headquarters + * `returned` - Returned for Revision + DocTypeEnum: + enum: + - mou + - report + - contract + - other + type: string + description: |- + * `mou` - MOU / Bilateral Agreement + * `report` - Progress / Milestone Report + * `contract` - Contract / Agreement + * `other` - Other Document + DocumentTypeEnum: + enum: + - national_id + - institution_license + - recommendation_letter + - student_card + - other + type: string + description: |- + * `national_id` - National ID / Passport + * `institution_license` - Institution Registration / License + * `recommendation_letter` - Recommendation Letter + * `student_card` - Student / Member Card + * `other` - Other Document + DonationCampaignCategoryEnum: + enum: + - mosque_construction + - humanitarian_relief + - pilgrim_support + - cultural_publications + - educational_scholarships + - general + type: string + description: |- + * `mosque_construction` - Mosque & Center Construction / Renovation + * `humanitarian_relief` - Humanitarian & Disaster Relief + * `pilgrim_support` - Imam Reza (AS) Pilgrim Services + * `cultural_publications` - Quran & Islamic Publications Translation + * `educational_scholarships` - Seminary & Academic Scholarships + * `general` - General Waqf & Endowment + DonationCampaignDetail: type: object properties: - room_type: - allOf: - - $ref: '#/components/schemas/RoomTypeEnum' - default: direct + id: + type: integer + readOnly: true title: type: string + title: Campaign Title maxLength: 255 - target_user_id: - type: integer + slug: nullable: true - target_institution_id: + title: Slug / URL Identifier + oneOf: + - type: string + pattern: ^[-\w]+$ + maxLength: 280 + - type: string + maxLength: 0 + description: + type: string + title: Campaign Description & Objectives + target_amount: + type: string + format: decimal + pattern: ^-?\d{0,12}(?:\.\d{0,2})?$ + raised_amount: + type: string + format: decimal + pattern: ^-?\d{0,12}(?:\.\d{0,2})?$ + title: Accumulated Raised Amount + currency: + type: string + title: Currency (e.g. USD, EUR, IRR, AED) + maxLength: 10 + category: + $ref: '#/components/schemas/DonationCampaignCategoryEnum' + priority: + $ref: '#/components/schemas/DonationCampaignPriorityEnum' + cover_image: + type: string + format: uri + nullable: true + title: Campaign Image + institution: type: integer nullable: true - participant_user_ids: - type: array - items: - type: integer - participant_institution_ids: - type: array - items: - type: integer - initial_message: + title: Beneficiary Institution / Mosque + institution_name: type: string - ChatRoomDetail: - type: object - properties: - id: - type: integer - readOnly: true - room_type: - allOf: - - $ref: '#/components/schemas/RoomTypeEnum' readOnly: true - title: + organization_name: type: string - readOnly: true nullable: true - title: Conversation Title - display_title: + title: Managing Foundation Name + maxLength: 255 + deadline: type: string + format: date + nullable: true + title: Campaign Deadline + progress_percentage: + type: integer readOnly: true - participant_users: - type: array - items: - $ref: '#/components/schemas/ChatUserMini' + donors_count: + type: integer readOnly: true - participant_institutions: + is_featured: + type: boolean + recent_donations: type: array items: - $ref: '#/components/schemas/ChatInstitutionMini' - readOnly: true - unread_count: - type: integer - readOnly: true - centrifugo_channel: - type: string + $ref: '#/components/schemas/DonationTransaction' readOnly: true created_at: type: string @@ -4872,143 +6765,183 @@ components: type: string format: date-time readOnly: true - title: Last Activity required: - - centrifugo_channel - created_at - - display_title + - description + - donors_count - id - - participant_institutions - - participant_users - - room_type + - institution_name + - progress_percentage + - recent_donations + - target_amount - title - - unread_count - updated_at - ChatRoomList: + DonationCampaignList: type: object properties: id: type: integer readOnly: true - room_type: - allOf: - - $ref: '#/components/schemas/RoomTypeEnum' - readOnly: true title: type: string - readOnly: true + title: Campaign Title + maxLength: 255 + slug: nullable: true - title: Conversation Title - display_title: + title: Slug / URL Identifier + oneOf: + - type: string + pattern: ^[-\w]+$ + maxLength: 280 + - type: string + maxLength: 0 + description: + type: string + title: Campaign Description & Objectives + target_amount: + type: string + format: decimal + pattern: ^-?\d{0,12}(?:\.\d{0,2})?$ + raised_amount: + type: string + format: decimal + pattern: ^-?\d{0,12}(?:\.\d{0,2})?$ + title: Accumulated Raised Amount + currency: + type: string + title: Currency (e.g. USD, EUR, IRR, AED) + maxLength: 10 + category: + $ref: '#/components/schemas/DonationCampaignCategoryEnum' + priority: + $ref: '#/components/schemas/DonationCampaignPriorityEnum' + cover_image: + type: string + format: uri + nullable: true + title: Campaign Image + institution: + type: integer + nullable: true + title: Beneficiary Institution / Mosque + institution_name: type: string readOnly: true - participant_users: - type: array - items: - $ref: '#/components/schemas/ChatUserMini' - readOnly: true - participant_institutions: - type: array - items: - $ref: '#/components/schemas/ChatInstitutionMini' - readOnly: true - last_message: - allOf: - - $ref: '#/components/schemas/ChatMessage' + organization_name: + type: string nullable: true + title: Managing Foundation Name + maxLength: 255 + deadline: + type: string + format: date + nullable: true + title: Campaign Deadline + progress_percentage: + type: integer readOnly: true - unread_count: + donors_count: type: integer readOnly: true - centrifugo_channel: + is_featured: + type: boolean + created_at: + type: string + format: date-time + readOnly: true + required: + - created_at + - description + - donors_count + - id + - institution_name + - progress_percentage + - target_amount + - title + DonationCampaignPriorityEnum: + enum: + - urgent + - high + - medium + type: string + description: |- + * `urgent` - Urgent Appeal + * `high` - High Priority + * `medium` - Medium Priority + DonationContributeRequestRequest: + type: object + properties: + amount: type: string - readOnly: true - updated_at: + format: decimal + pattern: ^-?\d{0,10}(?:\.\d{0,2})?$ + donor_name: type: string - format: date-time - readOnly: true - title: Last Activity + default: Anonymous Donor + donor_email: + nullable: true + oneOf: + - type: string + format: email + - type: string + maxLength: 0 required: - - centrifugo_channel - - display_title - - id - - last_message - - participant_institutions - - participant_users - - room_type - - title - - unread_count - - updated_at - ChatUserMini: + - amount + DonationTransaction: type: object properties: id: type: integer readOnly: true - email: + transaction_reference: type: string - format: email readOnly: true - nullable: true - title: Email Address - description: User primary email address. - fullname: + description: e.g. DON-RAZAVI-2026-XYZ123 + campaign: + type: integer + campaign_title: type: string readOnly: true - nullable: true - title: Full Name - description: Full name of the user. - avatar: + donor_name: type: string - format: uri - nullable: true - readOnly: true - user_type: + title: Donor Display Name + maxLength: 255 + amount: + type: string + format: decimal + pattern: ^-?\d{0,10}(?:\.\d{0,2})?$ + title: Donation Amount + currency: + type: string + maxLength: 10 + payment_status: allOf: - - $ref: '#/components/schemas/UserTypeEnum' + - $ref: '#/components/schemas/PaymentStatusEnum' + readOnly: true + created_at: + type: string + format: date-time readOnly: true + title: Donated At required: - - avatar - - email - - fullname + - amount + - campaign + - campaign_title + - created_at - id - - user_type - DeviceOsEnum: - enum: - - android - - apple - - web - type: string - description: |- - * `android` - Android - * `apple` - Apple iOS - * `web` - Web - DocTypeEnum: - enum: - - mou - - report - - contract - - other - type: string - description: |- - * `mou` - MOU / Bilateral Agreement - * `report` - Progress / Milestone Report - * `contract` - Contract / Agreement - * `other` - Other Document - DocumentTypeEnum: - enum: - - national_id - - institution_license - - recommendation_letter - - student_card - - other - type: string - description: |- - * `national_id` - National ID / Passport - * `institution_license` - Institution Registration / License - * `recommendation_letter` - Recommendation Letter - * `student_card` - Student / Member Card - * `other` - Other Document + - payment_status + - transaction_reference + DonationsAnalytics: + type: object + properties: + active_campaigns_count: + type: integer + total_funds_raised_usd: + type: number + format: double + required: + - active_campaigns_count + - total_funds_raised_usd EventCalendarItem: type: object properties: @@ -5027,7 +6960,7 @@ components: - type: string maxLength: 0 category: - $ref: '#/components/schemas/CategoryEnum' + $ref: '#/components/schemas/EventCategoryEnum' event_date: type: string format: date @@ -5051,6 +6984,22 @@ components: - organizer_name - start_time - title + EventCategoryEnum: + enum: + - religious + - educational + - cultural + - counseling + - conference + - workshop + type: string + description: |- + * `religious` - Religious + * `educational` - Educational + * `cultural` - Cultural + * `counseling` - Counseling + * `conference` - Conference + * `workshop` - Workshop EventCreateUpdate: type: object properties: @@ -5065,7 +7014,7 @@ components: type: string title: Event Description category: - $ref: '#/components/schemas/CategoryEnum' + $ref: '#/components/schemas/EventCategoryEnum' event_date: type: string format: date @@ -5112,7 +7061,7 @@ components: is_featured: type: boolean language: - $ref: '#/components/schemas/CMSPostLanguageEnum' + $ref: '#/components/schemas/ContentLanguageEnum' tags: {} required: - description @@ -5136,7 +7085,7 @@ components: type: integer writeOnly: true category: - $ref: '#/components/schemas/CategoryEnum' + $ref: '#/components/schemas/EventCategoryEnum' event_date: type: string format: date @@ -5183,7 +7132,7 @@ components: is_featured: type: boolean language: - $ref: '#/components/schemas/CMSPostLanguageEnum' + $ref: '#/components/schemas/ContentLanguageEnum' tags: {} required: - description @@ -5212,7 +7161,7 @@ components: type: string title: Event Description category: - $ref: '#/components/schemas/CategoryEnum' + $ref: '#/components/schemas/EventCategoryEnum' event_date: type: string format: date @@ -5262,7 +7211,7 @@ components: is_featured: type: boolean language: - $ref: '#/components/schemas/CMSPostLanguageEnum' + $ref: '#/components/schemas/ContentLanguageEnum' tags: {} organizer: allOf: @@ -5313,7 +7262,7 @@ components: - type: string maxLength: 0 category: - $ref: '#/components/schemas/CategoryEnum' + $ref: '#/components/schemas/EventCategoryEnum' event_date: type: string format: date @@ -5355,7 +7304,7 @@ components: is_featured: type: boolean language: - $ref: '#/components/schemas/CMSPostLanguageEnum' + $ref: '#/components/schemas/ContentLanguageEnum' tags: {} organizer: allOf: @@ -5502,51 +7451,279 @@ components: maxLength: 128 required: - temp_token + ExecutiveDashboard: + type: object + properties: + institutions: + $ref: '#/components/schemas/InstitutionsAnalytics' + projects: + $ref: '#/components/schemas/ProjectsAnalytics' + lms: + $ref: '#/components/schemas/LMSAnalytics' + ticketing: + $ref: '#/components/schemas/TicketingAnalytics' + donations: + $ref: '#/components/schemas/DonationsAnalytics' + diplomacy: + $ref: '#/components/schemas/DiplomacyAnalytics' + required: + - diplomacy + - donations + - institutions + - lms + - projects + - ticketing FAQItem: type: object properties: - question: + question: + type: string + default: '' + answer: + type: string + default: '' + FAQItemRequest: + type: object + properties: + question: + type: string + default: '' + answer: + type: string + default: '' + FollowResponse: + type: object + properties: + is_following: + type: boolean + follower_count: + type: integer + message: + type: string + required: + - follower_count + - is_following + - message + FollowResponseRequest: + type: object + properties: + is_following: + type: boolean + follower_count: + type: integer + message: + type: string + minLength: 1 + required: + - follower_count + - is_following + - message + FormDefinitionDetail: + type: object + properties: + id: + type: integer + readOnly: true + title: + type: string + title: Form Title + maxLength: 255 + slug: + nullable: true + title: Slug / URL Identifier + oneOf: + - type: string + pattern: ^[-\w]+$ + maxLength: 280 + - type: string + maxLength: 0 + description: + type: string + nullable: true + title: Instructions & Description + schema: + title: Form Fields Schema (JSON) + description: 'Array of field definitions: [{"id": "applicant_name", "label": + "Full Name", "type": "text", "required": true}]' + is_active: + type: boolean + is_public: + type: boolean + title: Is Publicly Accessible (No Login Required) + target_role: + type: string + nullable: true + title: Target User Role Restriction + maxLength: 50 + created_at: + type: string + format: date-time + readOnly: true + updated_at: + type: string + format: date-time + readOnly: true + required: + - created_at + - id + - title + - updated_at + FormDefinitionDetailRequest: + type: object + properties: + title: + type: string + minLength: 1 + title: Form Title + maxLength: 255 + slug: + nullable: true + title: Slug / URL Identifier + oneOf: + - type: string + pattern: ^[-\w]+$ + maxLength: 280 + - type: string + maxLength: 0 + description: + type: string + nullable: true + title: Instructions & Description + schema: + title: Form Fields Schema (JSON) + description: 'Array of field definitions: [{"id": "applicant_name", "label": + "Full Name", "type": "text", "required": true}]' + is_active: + type: boolean + is_public: + type: boolean + title: Is Publicly Accessible (No Login Required) + target_role: + type: string + nullable: true + title: Target User Role Restriction + maxLength: 50 + required: + - title + FormDefinitionList: + type: object + properties: + id: + type: integer + readOnly: true + title: + type: string + title: Form Title + maxLength: 255 + slug: + nullable: true + title: Slug / URL Identifier + oneOf: + - type: string + pattern: ^[-\w]+$ + maxLength: 280 + - type: string + maxLength: 0 + description: + type: string + nullable: true + title: Instructions & Description + is_active: + type: boolean + is_public: + type: boolean + title: Is Publicly Accessible (No Login Required) + target_role: + type: string + nullable: true + title: Target User Role Restriction + maxLength: 50 + responses_count: + type: integer + readOnly: true + created_at: + type: string + format: date-time + readOnly: true + required: + - created_at + - id + - responses_count + - title + FormResponse: + type: object + properties: + id: + type: integer + readOnly: true + form: + type: integer + form_title: type: string - default: '' - answer: + readOnly: true + user: + type: integer + readOnly: true + nullable: true + title: Submitter + submitter_name: type: string - default: '' - FAQItemRequest: - type: object - properties: - question: + readOnly: true + answers: + title: Submitted Form Answers + attachment: type: string - default: '' - answer: + format: uri + nullable: true + title: Uploaded Attachment + workflow_status: + allOf: + - $ref: '#/components/schemas/WorkflowStatusEnum' + readOnly: true + admin_notes: type: string - default: '' - FollowResponse: - type: object - properties: - is_following: - type: boolean - follower_count: + readOnly: true + nullable: true + title: Reviewer / Admin Notes + reviewed_by: type: integer - message: + readOnly: true + nullable: true + reviewer_name: + type: string + readOnly: true + created_at: + type: string + format: date-time + readOnly: true + title: Submitted At + updated_at: type: string + format: date-time + readOnly: true required: - - follower_count - - is_following - - message - FollowResponseRequest: + - admin_notes + - created_at + - form + - form_title + - id + - reviewed_by + - reviewer_name + - submitter_name + - updated_at + - user + - workflow_status + FormResponseSubmitRequest: type: object properties: - is_following: - type: boolean - follower_count: - type: integer - message: + answers: + description: Dictionary of question IDs and answers + attachment: type: string - minLength: 1 + format: binary + nullable: true required: - - follower_count - - is_following - - message + - answers GenderEnum: enum: - male @@ -6133,6 +8310,30 @@ components: required: - title - year + InstitutionsAnalytics: + type: object + properties: + total_institutions: + type: integer + verified_institutions: + type: integer + countries_count: + type: integer + by_type: + type: object + additionalProperties: + type: integer + top_countries: + type: array + items: + type: object + additionalProperties: {} + required: + - by_type + - countries_count + - top_countries + - total_institutions + - verified_institutions KanbanColumn: type: object properties: @@ -6161,108 +8362,329 @@ components: tasks: type: array items: - $ref: '#/components/schemas/KanbanTask' + $ref: '#/components/schemas/KanbanTask' + readOnly: true + required: + - id + - name + - project + - tasks + KanbanTask: + type: object + properties: + id: + type: integer + readOnly: true + project: + type: integer + readOnly: true + column: + type: integer + title: Kanban Column + column_name: + type: string + readOnly: true + column_code: + type: string + readOnly: true + title: + type: string + title: Task Title + maxLength: 255 + description: + type: string + nullable: true + title: Task Description + priority: + $ref: '#/components/schemas/TicketPriorityEnum' + priority_display: + type: string + readOnly: true + assignee: + allOf: + - $ref: '#/components/schemas/ProjectUserMini' + readOnly: true + due_date: + type: string + format: date + nullable: true + labels: + title: Task Labels + order: + type: integer + title: Display Order within Column + created_at: + type: string + format: date-time + readOnly: true + updated_at: + type: string + format: date-time + readOnly: true + required: + - assignee + - column + - column_code + - column_name + - created_at + - id + - priority_display + - project + - title + - updated_at + KanbanTaskCreateUpdateRequest: + type: object + properties: + column: + type: integer + title: Kanban Column + title: + type: string + minLength: 1 + title: Task Title + maxLength: 255 + description: + type: string + nullable: true + title: Task Description + priority: + $ref: '#/components/schemas/TicketPriorityEnum' + assignee: + type: integer + nullable: true + title: Assignee User + due_date: + type: string + format: date + nullable: true + labels: + title: Task Labels + order: + type: integer + title: Display Order within Column + required: + - column + - title + LMSAnalytics: + type: object + properties: + total_courses: + type: integer + total_enrollments: + type: integer + total_certificates_issued: + type: integer + required: + - total_certificates_issued + - total_courses + - total_enrollments + LMSCourseCategoryEnum: + enum: + - razavi_teachings + - cultural_diplomacy + - center_management + - international_media + - specialized_languages + - general + type: string + description: |- + * `razavi_teachings` - Razavi Teachings & Lifestyle + * `cultural_diplomacy` - Cultural Diplomacy & Interfaith + * `center_management` - Center & Mosque Management + * `international_media` - International Media & Content + * `specialized_languages` - Specialized Languages & Translation + * `general` - General Religious Studies + LMSCourseLevelEnum: + enum: + - introductory + - intermediate + - advanced + type: string + description: |- + * `introductory` - Introductory + * `intermediate` - Intermediate + * `advanced` - Advanced + LMSLessonTypeEnum: + enum: + - video + - reading + - quiz + - assignment + type: string + description: |- + * `video` - Video Lesson + * `reading` - Reading & Text + * `quiz` - Interactive Quiz + * `assignment` - Practical Assignment + LessonCompleteResponse: + type: object + properties: + message: + type: string + lesson_id: + type: integer + is_completed: + type: boolean + course_progress_percentage: + type: integer + certificate_code: + type: string + nullable: true + required: + - course_progress_percentage + - is_completed + - lesson_id + - message + LessonCompleteResponseRequest: + type: object + properties: + message: + type: string + minLength: 1 + lesson_id: + type: integer + is_completed: + type: boolean + course_progress_percentage: + type: integer + certificate_code: + type: string + nullable: true + minLength: 1 + required: + - course_progress_percentage + - is_completed + - lesson_id + - message + LessonDetail: + type: object + properties: + id: + type: integer + readOnly: true + title: + type: string + title: Lesson Title + maxLength: 255 + slug: + nullable: true + oneOf: + - type: string + pattern: ^[-\w]+$ + maxLength: 280 + - type: string + maxLength: 0 + lesson_type: + $ref: '#/components/schemas/LMSLessonTypeEnum' + duration_minutes: + type: integer + title: Duration (Minutes) + video_url: + nullable: true + title: Video URL (YouTube, Vimeo, HLS, MP4) + oneOf: + - type: string + format: uri + maxLength: 500 + - type: string + maxLength: 0 + content: + type: string + nullable: true + title: Lesson Content / Notes (Markdown) + slides_attachment: + type: string + format: uri + nullable: true + title: Slides / PDF Handout + order: + type: integer + title: Display Order + is_preview: + type: boolean + title: Is Free Preview (Public) + quiz_questions: + type: array + items: + $ref: '#/components/schemas/QuizQuestionStudent' + readOnly: true + is_completed: + type: boolean + readOnly: true + quiz_score: + type: number + format: float readOnly: true required: - id - - name - - project - - tasks - KanbanTask: + - is_completed + - quiz_questions + - quiz_score + - title + LessonList: type: object properties: id: type: integer readOnly: true - project: - type: integer - readOnly: true - column: - type: integer - title: Kanban Column - column_name: - type: string - readOnly: true - column_code: - type: string - readOnly: true title: type: string - title: Task Title + title: Lesson Title maxLength: 255 - description: - type: string - nullable: true - title: Task Description - priority: - $ref: '#/components/schemas/PriorityEnum' - priority_display: - type: string - readOnly: true - assignee: - allOf: - - $ref: '#/components/schemas/ProjectUserMini' - readOnly: true - due_date: - type: string - format: date + slug: nullable: true - labels: - title: Task Labels + oneOf: + - type: string + pattern: ^[-\w]+$ + maxLength: 280 + - type: string + maxLength: 0 + lesson_type: + $ref: '#/components/schemas/LMSLessonTypeEnum' + duration_minutes: + type: integer + title: Duration (Minutes) order: type: integer - title: Display Order within Column - created_at: - type: string - format: date-time - readOnly: true - updated_at: - type: string - format: date-time + title: Display Order + is_preview: + type: boolean + title: Is Free Preview (Public) + is_completed: + type: boolean readOnly: true required: - - assignee - - column - - column_code - - column_name - - created_at - id - - priority_display - - project + - is_completed - title - - updated_at - KanbanTaskCreateUpdateRequest: + LessonListRequest: type: object properties: - column: - type: integer - title: Kanban Column title: type: string minLength: 1 - title: Task Title + title: Lesson Title maxLength: 255 - description: - type: string + slug: nullable: true - title: Task Description - priority: - $ref: '#/components/schemas/PriorityEnum' - assignee: + oneOf: + - type: string + pattern: ^[-\w]+$ + maxLength: 280 + - type: string + maxLength: 0 + lesson_type: + $ref: '#/components/schemas/LMSLessonTypeEnum' + duration_minutes: type: integer - nullable: true - title: Assignee User - due_date: - type: string - format: date - nullable: true - labels: - title: Task Labels + title: Duration (Minutes) order: type: integer - title: Display Order within Column + title: Display Order + is_preview: + type: boolean + title: Is Free Preview (Public) required: - - column - title LocationHistory: type: object @@ -7003,6 +9425,75 @@ components: type: array items: $ref: '#/components/schemas/ChatRoomList' + PaginatedCourseListList: + type: object + required: + - count + - results + properties: + count: + type: integer + example: 123 + next: + type: string + nullable: true + format: uri + example: http://api.example.org/accounts/?page=4 + previous: + type: string + nullable: true + format: uri + example: http://api.example.org/accounts/?page=2 + results: + type: array + items: + $ref: '#/components/schemas/CourseList' + PaginatedDigitalCertificateList: + type: object + required: + - count + - results + properties: + count: + type: integer + example: 123 + next: + type: string + nullable: true + format: uri + example: http://api.example.org/accounts/?page=4 + previous: + type: string + nullable: true + format: uri + example: http://api.example.org/accounts/?page=2 + results: + type: array + items: + $ref: '#/components/schemas/DigitalCertificate' + PaginatedDonationCampaignListList: + type: object + required: + - count + - results + properties: + count: + type: integer + example: 123 + next: + type: string + nullable: true + format: uri + example: http://api.example.org/accounts/?page=4 + previous: + type: string + nullable: true + format: uri + example: http://api.example.org/accounts/?page=2 + results: + type: array + items: + $ref: '#/components/schemas/DonationCampaignList' PaginatedEventCalendarItemList: type: object required: @@ -7072,6 +9563,52 @@ components: type: array items: $ref: '#/components/schemas/EventRegistration' + PaginatedFormDefinitionListList: + type: object + required: + - count + - results + properties: + count: + type: integer + example: 123 + next: + type: string + nullable: true + format: uri + example: http://api.example.org/accounts/?page=4 + previous: + type: string + nullable: true + format: uri + example: http://api.example.org/accounts/?page=2 + results: + type: array + items: + $ref: '#/components/schemas/FormDefinitionList' + PaginatedFormResponseList: + type: object + required: + - count + - results + properties: + count: + type: integer + example: 123 + next: + type: string + nullable: true + format: uri + example: http://api.example.org/accounts/?page=4 + previous: + type: string + nullable: true + format: uri + example: http://api.example.org/accounts/?page=2 + results: + type: array + items: + $ref: '#/components/schemas/FormResponse' PaginatedInstitutionListList: type: object required: @@ -7324,8 +9861,54 @@ components: results: type: array items: - $ref: '#/components/schemas/PostCategory' - PaginatedPostCommentList: + $ref: '#/components/schemas/PostCategory' + PaginatedPostCommentList: + type: object + required: + - count + - results + properties: + count: + type: integer + example: 123 + next: + type: string + nullable: true + format: uri + example: http://api.example.org/accounts/?page=4 + previous: + type: string + nullable: true + format: uri + example: http://api.example.org/accounts/?page=2 + results: + type: array + items: + $ref: '#/components/schemas/PostComment' + PaginatedPostListList: + type: object + required: + - count + - results + properties: + count: + type: integer + example: 123 + next: + type: string + nullable: true + format: uri + example: http://api.example.org/accounts/?page=4 + previous: + type: string + nullable: true + format: uri + example: http://api.example.org/accounts/?page=2 + results: + type: array + items: + $ref: '#/components/schemas/PostList' + PaginatedPostTagList: type: object required: - count @@ -7347,8 +9930,8 @@ components: results: type: array items: - $ref: '#/components/schemas/PostComment' - PaginatedPostListList: + $ref: '#/components/schemas/PostTag' + PaginatedProjectDocumentList: type: object required: - count @@ -7370,8 +9953,8 @@ components: results: type: array items: - $ref: '#/components/schemas/PostList' - PaginatedPostTagList: + $ref: '#/components/schemas/ProjectDocument' + PaginatedProjectListList: type: object required: - count @@ -7393,8 +9976,8 @@ components: results: type: array items: - $ref: '#/components/schemas/PostTag' - PaginatedProjectDocumentList: + $ref: '#/components/schemas/ProjectList' + PaginatedRegionalDensityList: type: object required: - count @@ -7416,8 +9999,8 @@ components: results: type: array items: - $ref: '#/components/schemas/ProjectDocument' - PaginatedProjectListList: + $ref: '#/components/schemas/RegionalDensity' + PaginatedRepresentativeReportListList: type: object required: - count @@ -7439,8 +10022,8 @@ components: results: type: array items: - $ref: '#/components/schemas/ProjectList' - PaginatedRegionalDensityList: + $ref: '#/components/schemas/RepresentativeReportList' + PaginatedTicketListList: type: object required: - count @@ -7462,7 +10045,7 @@ components: results: type: array items: - $ref: '#/components/schemas/RegionalDensity' + $ref: '#/components/schemas/TicketList' PaginatedVerificationDocumentList: type: object required: @@ -7608,6 +10191,60 @@ components: title: Superuser status description: Designates that this user has all permissions without explicitly assigning them. + PatchedCourseCreateUpdateRequest: + type: object + properties: + title: + type: string + minLength: 1 + title: Course Title + maxLength: 255 + category: + $ref: '#/components/schemas/LMSCourseCategoryEnum' + language: + $ref: '#/components/schemas/ContentLanguageEnum' + level: + allOf: + - $ref: '#/components/schemas/LMSCourseLevelEnum' + title: Difficulty Level + cover_image: + type: string + format: binary + nullable: true + instructor_name: + type: string + minLength: 1 + maxLength: 255 + instructor_title: + type: string + nullable: true + title: Instructor Title / Specialty + maxLength: 255 + instructor_avatar: + type: string + format: binary + nullable: true + institution: + type: integer + nullable: true + title: Partner Institution / Center + organization_name: + type: string + nullable: true + title: Organization / Academy Name + maxLength: 255 + description: + type: string + minLength: 1 + title: Course Description & Syllabus Summary + duration_hours: + type: number + format: double + title: Duration (Hours) + is_published: + type: boolean + is_featured: + type: boolean PatchedEventCreateUpdateRequest: type: object properties: @@ -7624,7 +10261,7 @@ components: type: integer writeOnly: true category: - $ref: '#/components/schemas/CategoryEnum' + $ref: '#/components/schemas/EventCategoryEnum' event_date: type: string format: date @@ -7671,8 +10308,15 @@ components: is_featured: type: boolean language: - $ref: '#/components/schemas/CMSPostLanguageEnum' + $ref: '#/components/schemas/ContentLanguageEnum' tags: {} + PatchedFormResponseReviewRequest: + type: object + properties: + workflow_status: + $ref: '#/components/schemas/WorkflowStatusEnum' + admin_notes: + type: string PatchedInstitutionCreateUpdateRequest: type: object properties: @@ -7771,7 +10415,7 @@ components: nullable: true title: Task Description priority: - $ref: '#/components/schemas/PriorityEnum' + $ref: '#/components/schemas/TicketPriorityEnum' assignee: type: integer nullable: true @@ -7855,7 +10499,7 @@ components: type: integer nullable: true language: - $ref: '#/components/schemas/CMSPostLanguageEnum' + $ref: '#/components/schemas/ContentLanguageEnum' status: allOf: - $ref: '#/components/schemas/CMSPostStatusEnum' @@ -7953,6 +10597,43 @@ components: format: date nullable: true title: Actual Completion Date + PatchedRepresentativeReportReviewRequest: + type: object + properties: + status: + $ref: '#/components/schemas/DiplomacyReportStatusEnum' + feedback_notes: + type: string + PatchedTicketListRequest: + type: object + properties: + tracking_code: + type: string + description: e.g. TK-RAZAVI-2026-AB12CD + maxLength: 100 + subject: + type: string + minLength: 1 + title: Subject / Title + maxLength: 255 + category: + $ref: '#/components/schemas/TicketCategoryEnum' + priority: + $ref: '#/components/schemas/TicketPriorityEnum' + status: + $ref: '#/components/schemas/TicketStatusEnum' + institution: + type: integer + nullable: true + title: Affiliated Institution + satisfaction_rating: + type: integer + nullable: true + title: Satisfaction Rating (1-5 Stars) + closed_at: + type: string + format: date-time + nullable: true PatchedUserMeUpdateRequest: type: object properties: @@ -8038,6 +10719,16 @@ components: * `male` - Male * `female` - Female * `other` - Other + PaymentStatusEnum: + enum: + - completed + - pending + - failed + type: string + description: |- + * `completed` - Completed + * `pending` - Pending + * `failed` - Failed PostAuthorMini: type: object properties: @@ -8194,7 +10885,7 @@ components: type: integer nullable: true language: - $ref: '#/components/schemas/CMSPostLanguageEnum' + $ref: '#/components/schemas/ContentLanguageEnum' status: allOf: - $ref: '#/components/schemas/CMSPostStatusEnum' @@ -8262,7 +10953,7 @@ components: - $ref: '#/components/schemas/PostCategory' readOnly: true language: - $ref: '#/components/schemas/CMSPostLanguageEnum' + $ref: '#/components/schemas/ContentLanguageEnum' status: allOf: - $ref: '#/components/schemas/CMSPostStatusEnum' @@ -8456,7 +11147,7 @@ components: nullable: true language: allOf: - - $ref: '#/components/schemas/CMSPostLanguageEnum' + - $ref: '#/components/schemas/ContentLanguageEnum' readOnly: true status: allOf: @@ -8578,18 +11269,6 @@ components: * `article` - Analytical Article * `report` - Field & Diplomatic Report * `interview` - Expert Interview - PriorityEnum: - enum: - - low - - medium - - high - - urgent - type: string - description: |- - * `low` - Low - * `medium` - Medium - * `high` - High - * `urgent` - Urgent ProjectCreateUpdateRequest: type: object properties: @@ -9003,6 +11682,131 @@ components: - email - fullname - id + ProjectsAnalytics: + type: object + properties: + total_projects: + type: integer + active_projects: + type: integer + completed_projects: + type: integer + required: + - active_projects + - completed_projects + - total_projects + PublicCertificateVerify: + type: object + properties: + certificate_code: + type: string + uuid: + type: string + format: uuid + recipient_name: + type: string + course_title: + type: string + organization: + type: string + nullable: true + issue_date: + type: string + format: date + final_score: + type: number + format: double + is_valid: + type: boolean + required: + - certificate_code + - course_title + - final_score + - is_valid + - issue_date + - organization + - recipient_name + - uuid + QuizQuestionResultItem: + type: object + properties: + question_id: + type: integer + is_correct: + type: boolean + selected_option: + type: integer + correct_option: + type: integer + explanation: + type: string + nullable: true + required: + - correct_option + - explanation + - is_correct + - question_id + - selected_option + QuizQuestionStudent: + type: object + properties: + id: + type: integer + readOnly: true + question_text: + type: string + options: + title: Options Array + description: 'List of multiple-choice strings: ["Option A", "Option B", + "Option C", "Option D"]' + order: + type: integer + title: Display Order + required: + - id + - question_text + QuizResult: + type: object + properties: + score: + type: number + format: double + description: Percentage score achieved (0-100) + passed: + type: boolean + description: True if score >= 70% + total_questions: + type: integer + correct_count: + type: integer + question_results: + type: array + items: + $ref: '#/components/schemas/QuizQuestionResultItem' + is_lesson_completed: + type: boolean + course_progress_percentage: + type: integer + certificate_code: + type: string + nullable: true + required: + - correct_count + - course_progress_percentage + - is_lesson_completed + - passed + - question_results + - score + - total_questions + QuizSubmitRequestRequest: + type: object + properties: + answers: + type: array + items: + $ref: '#/components/schemas/SingleQuizAnswerRequest' + required: + - answers RegionInfo: type: object properties: @@ -9110,15 +11914,197 @@ components: $ref: '#/components/schemas/UserMe' access: type: string - refresh: + refresh: + type: string + message: + type: string + required: + - access + - message + - refresh + - user + RepresentativeReportCreateRequest: + type: object + properties: + title: + type: string + minLength: 1 + title: Report Title + maxLength: 255 + region_hub: + type: string + minLength: 1 + title: Regional Hub / Mission Zone + description: e.g. East Africa Regional Hub, South Asia Bureau, European + Cultural Mission + maxLength: 150 + period: + type: string + minLength: 1 + title: Reporting Period + description: e.g. Q1 2026, Muharram 1448 AH, March 2026 + maxLength: 100 + activities_summary: + type: string + minLength: 1 + title: Summary of Activities & Negotiations + centers_visited_count: + type: integer + title: Number of Mosques / Centers Visited + key_achievements: + type: string + nullable: true + title: Key Achievements & Milestones + challenges_and_needs: + type: string + nullable: true + title: Challenges, Risks & Logistical Needs + attachment: + type: string + format: binary + nullable: true + title: Official Report Document (PDF / Word) + required: + - activities_summary + - period + - region_hub + - title + RepresentativeReportDetail: + type: object + properties: + id: + type: integer + readOnly: true + title: + type: string + title: Report Title + maxLength: 255 + representative: + type: integer + readOnly: true + title: Field Representative + representative_name: + type: string + readOnly: true + region_hub: + type: string + title: Regional Hub / Mission Zone + description: e.g. East Africa Regional Hub, South Asia Bureau, European + Cultural Mission + maxLength: 150 + period: + type: string + title: Reporting Period + description: e.g. Q1 2026, Muharram 1448 AH, March 2026 + maxLength: 100 + activities_summary: + type: string + title: Summary of Activities & Negotiations + centers_visited_count: + type: integer + title: Number of Mosques / Centers Visited + key_achievements: + type: string + nullable: true + title: Key Achievements & Milestones + challenges_and_needs: + type: string + nullable: true + title: Challenges, Risks & Logistical Needs + attachment: + type: string + format: uri + nullable: true + title: Official Report Document (PDF / Word) + status: + allOf: + - $ref: '#/components/schemas/DiplomacyReportStatusEnum' + readOnly: true + title: Review Status + feedback_notes: + type: string + readOnly: true + nullable: true + title: Headquarters Feedback & Directives + reviewed_by: + type: integer + readOnly: true + nullable: true + title: Reviewed By Officer + reviewer_name: + type: string + readOnly: true + created_at: + type: string + format: date-time + readOnly: true + title: Submitted At + updated_at: + type: string + format: date-time + readOnly: true + required: + - activities_summary + - created_at + - feedback_notes + - id + - period + - region_hub + - representative + - representative_name + - reviewed_by + - reviewer_name + - status + - title + - updated_at + RepresentativeReportList: + type: object + properties: + id: + type: integer + readOnly: true + title: + type: string + title: Report Title + maxLength: 255 + representative_name: + type: string + readOnly: true + representative_email: type: string - message: + format: email + readOnly: true + region_hub: + type: string + title: Regional Hub / Mission Zone + description: e.g. East Africa Regional Hub, South Asia Bureau, European + Cultural Mission + maxLength: 150 + period: type: string + title: Reporting Period + description: e.g. Q1 2026, Muharram 1448 AH, March 2026 + maxLength: 100 + centers_visited_count: + type: integer + title: Number of Mosques / Centers Visited + status: + allOf: + - $ref: '#/components/schemas/DiplomacyReportStatusEnum' + title: Review Status + created_at: + type: string + format: date-time + readOnly: true + title: Submitted At required: - - access - - message - - refresh - - user + - created_at + - id + - period + - region_hub + - representative_email + - representative_name + - title RoleEnum: enum: - admin @@ -9160,6 +12146,16 @@ components: - body - title - user_id + SingleQuizAnswerRequest: + type: object + properties: + question_id: + type: integer + selected_option: + type: integer + required: + - question_id + - selected_option Support: type: object properties: @@ -9245,6 +12241,345 @@ components: office_hours: type: string default: '' + TicketCategoryEnum: + enum: + - cultural_affairs + - religious_inquiry + - inter_center_cooperation + - financial_legal + - technical_support + - general + type: string + description: |- + * `cultural_affairs` - Cultural & International Affairs + * `religious_inquiry` - Religious & Theological Inquiry + * `inter_center_cooperation` - Inter-Center Cooperation & Partnerships + * `financial_legal` - Financial & Legal Affairs + * `technical_support` - Technical & Portal Support + * `general` - General Inquiry + TicketCreateRequest: + type: object + properties: + subject: + type: string + minLength: 1 + title: Subject / Title + maxLength: 255 + category: + $ref: '#/components/schemas/TicketCategoryEnum' + priority: + $ref: '#/components/schemas/TicketPriorityEnum' + institution: + type: integer + nullable: true + title: Affiliated Institution + content: + type: string + writeOnly: true + minLength: 1 + description: Initial message description + attachment: + type: string + format: binary + writeOnly: true + nullable: true + required: + - content + - subject + TicketDetail: + type: object + properties: + id: + type: integer + readOnly: true + tracking_code: + type: string + description: e.g. TK-RAZAVI-2026-AB12CD + maxLength: 100 + subject: + type: string + title: Subject / Title + maxLength: 255 + category: + $ref: '#/components/schemas/TicketCategoryEnum' + priority: + $ref: '#/components/schemas/TicketPriorityEnum' + status: + $ref: '#/components/schemas/TicketStatusEnum' + submitter_name: + type: string + readOnly: true + institution: + type: integer + nullable: true + title: Affiliated Institution + institution_name: + type: string + readOnly: true + assigned_expert: + type: integer + nullable: true + title: Assigned Expert / Officer + expert_name: + type: string + readOnly: true + satisfaction_rating: + type: integer + nullable: true + title: Satisfaction Rating (1-5 Stars) + feedback_comment: + type: string + nullable: true + title: User Feedback Notes + messages: + type: array + items: + $ref: '#/components/schemas/TicketMessage' + readOnly: true + created_at: + type: string + format: date-time + readOnly: true + updated_at: + type: string + format: date-time + readOnly: true + closed_at: + type: string + format: date-time + nullable: true + required: + - created_at + - expert_name + - id + - institution_name + - messages + - subject + - submitter_name + - updated_at + TicketDetailRequest: + type: object + properties: + tracking_code: + type: string + description: e.g. TK-RAZAVI-2026-AB12CD + maxLength: 100 + subject: + type: string + minLength: 1 + title: Subject / Title + maxLength: 255 + category: + $ref: '#/components/schemas/TicketCategoryEnum' + priority: + $ref: '#/components/schemas/TicketPriorityEnum' + status: + $ref: '#/components/schemas/TicketStatusEnum' + institution: + type: integer + nullable: true + title: Affiliated Institution + assigned_expert: + type: integer + nullable: true + title: Assigned Expert / Officer + satisfaction_rating: + type: integer + nullable: true + title: Satisfaction Rating (1-5 Stars) + feedback_comment: + type: string + nullable: true + title: User Feedback Notes + closed_at: + type: string + format: date-time + nullable: true + required: + - subject + TicketList: + type: object + properties: + id: + type: integer + readOnly: true + tracking_code: + type: string + description: e.g. TK-RAZAVI-2026-AB12CD + maxLength: 100 + subject: + type: string + title: Subject / Title + maxLength: 255 + category: + $ref: '#/components/schemas/TicketCategoryEnum' + priority: + $ref: '#/components/schemas/TicketPriorityEnum' + status: + $ref: '#/components/schemas/TicketStatusEnum' + submitter_name: + type: string + readOnly: true + institution: + type: integer + nullable: true + title: Affiliated Institution + institution_name: + type: string + readOnly: true + messages_count: + type: integer + readOnly: true + satisfaction_rating: + type: integer + nullable: true + title: Satisfaction Rating (1-5 Stars) + created_at: + type: string + format: date-time + readOnly: true + updated_at: + type: string + format: date-time + readOnly: true + closed_at: + type: string + format: date-time + nullable: true + required: + - created_at + - id + - institution_name + - messages_count + - subject + - submitter_name + - updated_at + TicketMessage: + type: object + properties: + id: + type: integer + readOnly: true + sender_id: + type: integer + readOnly: true + sender_name: + type: string + readOnly: true + is_official_response: + type: boolean + readOnly: true + title: Is Official Response from Astan / Expert + department_label: + type: string + title: Department / Authority Label + maxLength: 255 + content: + type: string + title: Message Content + attachment: + type: string + format: uri + nullable: true + created_at: + type: string + format: date-time + readOnly: true + required: + - content + - created_at + - id + - is_official_response + - sender_id + - sender_name + TicketMessageCreateRequest: + type: object + properties: + content: + type: string + minLength: 1 + title: Message Content + attachment: + type: string + format: binary + nullable: true + department_label: + type: string + title: Department / Authority Label + maxLength: 255 + required: + - content + TicketMessageRequest: + type: object + properties: + department_label: + type: string + title: Department / Authority Label + maxLength: 255 + content: + type: string + minLength: 1 + title: Message Content + attachment: + type: string + format: binary + nullable: true + required: + - content + TicketPriorityEnum: + enum: + - low + - medium + - high + - urgent + type: string + description: |- + * `low` - Low + * `medium` - Medium + * `high` - High + * `urgent` - Urgent + TicketRateRequest: + type: object + properties: + rating: + type: integer + maximum: 5 + minimum: 1 + description: 1 to 5 stars + feedback: + type: string + required: + - rating + TicketStatusEnum: + enum: + - open + - in_progress + - answered + - closed + type: string + description: |- + * `open` - Open + * `in_progress` - In Progress + * `answered` - Official Response Provided + * `closed` - Closed + TicketingAnalytics: + type: object + properties: + total_tickets: + type: integer + open_tickets: + type: integer + answered_or_closed: + type: integer + average_satisfaction_rating: + type: number + format: double + nullable: true + required: + - answered_or_closed + - average_satisfaction_rating + - open_tickets + - total_tickets TokenObtainPair: type: object properties: @@ -9925,6 +13260,18 @@ components: - email - fullname - password + WorkflowStatusEnum: + enum: + - submitted + - under_review + - approved + - rejected + type: string + description: |- + * `submitted` - Submitted + * `under_review` - Under Review + * `approved` - Approved + * `rejected` - Rejected securitySchemes: cookieAuth: type: apiKey diff --git a/test_media/chat/attachments/2026/09/agenda_schedule_SERzRKL.pdf b/test_media/chat/attachments/2026/09/agenda_schedule_SERzRKL.pdf new file mode 100644 index 0000000..2207a29 --- /dev/null +++ b/test_media/chat/attachments/2026/09/agenda_schedule_SERzRKL.pdf @@ -0,0 +1 @@ +%PDF-1.4 Mock Schedule \ No newline at end of file diff --git a/test_media/chat/attachments/2026/09/agenda_schedule_elprMLK.pdf b/test_media/chat/attachments/2026/09/agenda_schedule_elprMLK.pdf new file mode 100644 index 0000000..2207a29 --- /dev/null +++ b/test_media/chat/attachments/2026/09/agenda_schedule_elprMLK.pdf @@ -0,0 +1 @@ +%PDF-1.4 Mock Schedule \ No newline at end of file diff --git a/test_media/chat/attachments/2026/09/agenda_schedule_kMmLXkB.pdf b/test_media/chat/attachments/2026/09/agenda_schedule_kMmLXkB.pdf new file mode 100644 index 0000000..2207a29 --- /dev/null +++ b/test_media/chat/attachments/2026/09/agenda_schedule_kMmLXkB.pdf @@ -0,0 +1 @@ +%PDF-1.4 Mock Schedule \ No newline at end of file diff --git a/test_media/institutions/gallery/2026/09/center_hall_PjFbSf2.jpg b/test_media/institutions/gallery/2026/09/center_hall_PjFbSf2.jpg new file mode 100644 index 0000000..8bab6ea --- /dev/null +++ b/test_media/institutions/gallery/2026/09/center_hall_PjFbSf2.jpg @@ -0,0 +1 @@ +mock_image_bytes \ No newline at end of file diff --git a/test_media/institutions/gallery/2026/09/center_hall_ZcZFmJ9.jpg b/test_media/institutions/gallery/2026/09/center_hall_ZcZFmJ9.jpg new file mode 100644 index 0000000..8bab6ea --- /dev/null +++ b/test_media/institutions/gallery/2026/09/center_hall_ZcZFmJ9.jpg @@ -0,0 +1 @@ +mock_image_bytes \ No newline at end of file diff --git a/test_media/institutions/gallery/2026/09/center_hall_fTt2o2G.jpg b/test_media/institutions/gallery/2026/09/center_hall_fTt2o2G.jpg new file mode 100644 index 0000000..8bab6ea --- /dev/null +++ b/test_media/institutions/gallery/2026/09/center_hall_fTt2o2G.jpg @@ -0,0 +1 @@ +mock_image_bytes \ No newline at end of file diff --git a/test_media/projects/docs/2026/09/bilateral_mou_2026_UvsbCfE.pdf b/test_media/projects/docs/2026/09/bilateral_mou_2026_UvsbCfE.pdf new file mode 100644 index 0000000..976a621 --- /dev/null +++ b/test_media/projects/docs/2026/09/bilateral_mou_2026_UvsbCfE.pdf @@ -0,0 +1 @@ +%PDF-1.4 Mock Bilateral MOU \ No newline at end of file diff --git a/test_media/projects/docs/2026/09/bilateral_mou_2026_pDBspqJ.pdf b/test_media/projects/docs/2026/09/bilateral_mou_2026_pDBspqJ.pdf new file mode 100644 index 0000000..976a621 --- /dev/null +++ b/test_media/projects/docs/2026/09/bilateral_mou_2026_pDBspqJ.pdf @@ -0,0 +1 @@ +%PDF-1.4 Mock Bilateral MOU \ No newline at end of file diff --git a/test_media/projects/docs/2026/09/bilateral_mou_2026_xSM8Gd0.pdf b/test_media/projects/docs/2026/09/bilateral_mou_2026_xSM8Gd0.pdf new file mode 100644 index 0000000..976a621 --- /dev/null +++ b/test_media/projects/docs/2026/09/bilateral_mou_2026_xSM8Gd0.pdf @@ -0,0 +1 @@ +%PDF-1.4 Mock Bilateral MOU \ No newline at end of file diff --git a/test_media/users/documents/2026/09/institution_license_8fLXyTj.pdf b/test_media/users/documents/2026/09/institution_license_8fLXyTj.pdf new file mode 100644 index 0000000..b08e1d2 --- /dev/null +++ b/test_media/users/documents/2026/09/institution_license_8fLXyTj.pdf @@ -0,0 +1 @@ +%PDF-1.4 Mock License Content \ No newline at end of file diff --git a/test_media/users/documents/2026/09/institution_license_FHSml8p.pdf b/test_media/users/documents/2026/09/institution_license_FHSml8p.pdf new file mode 100644 index 0000000..b08e1d2 --- /dev/null +++ b/test_media/users/documents/2026/09/institution_license_FHSml8p.pdf @@ -0,0 +1 @@ +%PDF-1.4 Mock License Content \ No newline at end of file diff --git a/test_media/users/documents/2026/09/institution_license_nE7mZTJ.pdf b/test_media/users/documents/2026/09/institution_license_nE7mZTJ.pdf new file mode 100644 index 0000000..b08e1d2 --- /dev/null +++ b/test_media/users/documents/2026/09/institution_license_nE7mZTJ.pdf @@ -0,0 +1 @@ +%PDF-1.4 Mock License Content \ No newline at end of file