Browse Source

feat: add new backend applications including tickets, lms, diplomacy, donations, dynamic forms, and analytics

main
sina_sajjadi 4 weeks ago
parent
commit
7c81b19d96
  1. 1
      apps/analytics/__init__.py
  2. 8
      apps/analytics/apps.py
  3. 48
      apps/analytics/serializers.py
  4. 1
      apps/analytics/tests/__init__.py
  5. 108
      apps/analytics/tests/test_phase9_analytics.py
  6. 6
      apps/analytics/urls.py
  7. 103
      apps/analytics/views.py
  8. 1
      apps/diplomacy/__init__.py
  9. 11
      apps/diplomacy/admin.py
  10. 8
      apps/diplomacy/apps.py
  11. 42
      apps/diplomacy/migrations/0001_initial.py
  12. 0
      apps/diplomacy/migrations/__init__.py
  13. 9
      apps/diplomacy/models/__init__.py
  14. 80
      apps/diplomacy/models/report.py
  15. 13
      apps/diplomacy/serializers/__init__.py
  16. 47
      apps/diplomacy/serializers/report_serializers.py
  17. 1
      apps/diplomacy/tests/__init__.py
  18. 69
      apps/diplomacy/tests/test_phase9_diplomacy.py
  19. 12
      apps/diplomacy/urls.py
  20. 11
      apps/diplomacy/views/__init__.py
  21. 140
      apps/diplomacy/views/report_views.py
  22. 1
      apps/donations/__init__.py
  23. 27
      apps/donations/admin.py
  24. 8
      apps/donations/apps.py
  25. 65
      apps/donations/migrations/0001_initial.py
  26. 0
      apps/donations/migrations/__init__.py
  27. 15
      apps/donations/models/__init__.py
  28. 194
      apps/donations/models/donation.py
  29. 13
      apps/donations/serializers/__init__.py
  30. 69
      apps/donations/serializers/donation_serializers.py
  31. 1
      apps/donations/tests/__init__.py
  32. 86
      apps/donations/tests/test_phase9_donations.py
  33. 12
      apps/donations/urls.py
  34. 11
      apps/donations/views/__init__.py
  35. 141
      apps/donations/views/donation_views.py
  36. 1
      apps/dynamic_forms/__init__.py
  37. 27
      apps/dynamic_forms/admin.py
  38. 8
      apps/dynamic_forms/apps.py
  39. 58
      apps/dynamic_forms/migrations/0001_initial.py
  40. 0
      apps/dynamic_forms/migrations/__init__.py
  41. 11
      apps/dynamic_forms/models/__init__.py
  42. 127
      apps/dynamic_forms/models/form.py
  43. 15
      apps/dynamic_forms/serializers/__init__.py
  44. 50
      apps/dynamic_forms/serializers/form_serializers.py
  45. 1
      apps/dynamic_forms/tests/__init__.py
  46. 94
      apps/dynamic_forms/tests/test_phase9_forms.py
  47. 19
      apps/dynamic_forms/urls.py
  48. 15
      apps/dynamic_forms/views/__init__.py
  49. 177
      apps/dynamic_forms/views/form_views.py
  50. 1
      apps/lms/__init__.py
  51. 83
      apps/lms/admin.py
  52. 8
      apps/lms/apps.py
  53. 160
      apps/lms/migrations/0001_initial.py
  54. 0
      apps/lms/migrations/__init__.py
  55. 25
      apps/lms/models/__init__.py
  56. 418
      apps/lms/models/course.py
  57. 55
      apps/lms/permissions.py
  58. 35
      apps/lms/serializers/__init__.py
  59. 257
      apps/lms/serializers/course_serializers.py
  60. 1
      apps/lms/tests/__init__.py
  61. 296
      apps/lms/tests/test_phase8_lms.py
  62. 31
      apps/lms/urls.py
  63. 23
      apps/lms/views/__init__.py
  64. 463
      apps/lms/views/course_views.py
  65. 1
      apps/tickets/__init__.py
  66. 27
      apps/tickets/admin.py
  67. 8
      apps/tickets/apps.py
  68. 60
      apps/tickets/migrations/0001_initial.py
  69. 0
      apps/tickets/migrations/__init__.py
  70. 15
      apps/tickets/models/__init__.py
  71. 149
      apps/tickets/models/ticket.py
  72. 24
      apps/tickets/permissions.py
  73. 17
      apps/tickets/serializers/__init__.py
  74. 94
      apps/tickets/serializers/ticket_serializers.py
  75. 1
      apps/tickets/tests/__init__.py
  76. 146
      apps/tickets/tests/test_phase9_tickets.py
  77. 17
      apps/tickets/urls.py
  78. 15
      apps/tickets/views/__init__.py
  79. 260
      apps/tickets/views/ticket_views.py
  80. 20
      config/settings/base.py
  81. 18
      config/urls.py
  82. 6223
      schema.yml
  83. 1
      test_media/chat/attachments/2026/09/agenda_schedule_SERzRKL.pdf
  84. 1
      test_media/chat/attachments/2026/09/agenda_schedule_elprMLK.pdf
  85. 1
      test_media/chat/attachments/2026/09/agenda_schedule_kMmLXkB.pdf
  86. 1
      test_media/institutions/gallery/2026/09/center_hall_PjFbSf2.jpg
  87. 1
      test_media/institutions/gallery/2026/09/center_hall_ZcZFmJ9.jpg
  88. 1
      test_media/institutions/gallery/2026/09/center_hall_fTt2o2G.jpg
  89. 1
      test_media/projects/docs/2026/09/bilateral_mou_2026_UvsbCfE.pdf
  90. 1
      test_media/projects/docs/2026/09/bilateral_mou_2026_pDBspqJ.pdf
  91. 1
      test_media/projects/docs/2026/09/bilateral_mou_2026_xSM8Gd0.pdf
  92. 1
      test_media/users/documents/2026/09/institution_license_8fLXyTj.pdf
  93. 1
      test_media/users/documents/2026/09/institution_license_FHSml8p.pdf
  94. 1
      test_media/users/documents/2026/09/institution_license_nE7mZTJ.pdf

1
apps/analytics/__init__.py

@ -0,0 +1 @@
# Analytics app

8
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')

48
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()

1
apps/analytics/tests/__init__.py

@ -0,0 +1 @@
# Analytics tests

108
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)

6
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'),
]

103
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)

1
apps/diplomacy/__init__.py

@ -0,0 +1 @@
# Diplomacy app

11
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')

8
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')

42
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',),
},
),
]

0
apps/diplomacy/migrations/__init__.py

9
apps/diplomacy/models/__init__.py

@ -0,0 +1,9 @@
from apps.diplomacy.models.report import (
RepresentativeReport,
ReportStatus,
)
__all__ = [
'RepresentativeReport',
'ReportStatus',
]

80
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})"

13
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',
]

47
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)

1
apps/diplomacy/tests/__init__.py

@ -0,0 +1 @@
# Diplomacy tests

69
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"])

12
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/<int:pk>/', RepresentativeReportDetailView.as_view(), name='diplomacy_report_detail'),
path('reports/<int:pk>/review/', RepresentativeReportReviewView.as_view(), name='diplomacy_report_review'),
]

11
apps/diplomacy/views/__init__.py

@ -0,0 +1,11 @@
from apps.diplomacy.views.report_views import (
RepresentativeReportListCreateView,
RepresentativeReportDetailView,
RepresentativeReportReviewView,
)
__all__ = [
'RepresentativeReportListCreateView',
'RepresentativeReportDetailView',
'RepresentativeReportReviewView',
]

140
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)

1
apps/donations/__init__.py

@ -0,0 +1 @@
# Donations app

27
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')

8
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')

65
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',),
},
),
]

0
apps/donations/migrations/__init__.py

15
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',
]

194
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)

13
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',
]

69
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)

1
apps/donations/tests/__init__.py

@ -0,0 +1 @@
# Donations tests

86
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)

12
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/<str:pk_or_slug>/', DonationCampaignDetailView.as_view(), name='donation_campaign_detail'),
path('campaigns/<str:pk_or_slug>/contribute/', DonationContributeView.as_view(), name='donation_contribute'),
]

11
apps/donations/views/__init__.py

@ -0,0 +1,11 @@
from apps.donations.views.donation_views import (
DonationCampaignListView,
DonationCampaignDetailView,
DonationContributeView,
)
__all__ = [
'DonationCampaignListView',
'DonationCampaignDetailView',
'DonationContributeView',
]

141
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)

1
apps/dynamic_forms/__init__.py

@ -0,0 +1 @@
# Dynamic forms app

27
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')

8
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')

58
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',),
},
),
]

0
apps/dynamic_forms/migrations/__init__.py

11
apps/dynamic_forms/models/__init__.py

@ -0,0 +1,11 @@
from apps.dynamic_forms.models.form import (
FormDefinition,
FormResponse,
WorkflowStatus,
)
__all__ = [
'FormDefinition',
'FormResponse',
'WorkflowStatus',
]

127
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()})"

15
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',
]

50
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)

1
apps/dynamic_forms/tests/__init__.py

@ -0,0 +1 @@
# Dynamic forms tests

94
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"])

19
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('<str:pk_or_slug>/', FormDefinitionDetailView.as_view(), name='form_detail'),
# Submissions & Workflow
path('<str:pk_or_slug>/responses/', FormResponseSubmitView.as_view(), name='form_response_submit'),
path('<str:pk_or_slug>/submissions/', FormResponseListView.as_view(), name='form_response_list'),
path('responses/<int:response_id>/review/', FormResponseReviewView.as_view(), name='form_response_review'),
]

15
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',
]

177
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)

1
apps/lms/__init__.py

@ -0,0 +1 @@
# LMS app

83
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')

8
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)')

160
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')},
},
),
]

0
apps/lms/migrations/__init__.py

25
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',
]

418
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

55
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)

35
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',
]

257
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()

1
apps/lms/tests/__init__.py

@ -0,0 +1 @@
# LMS tests

296
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)

31
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/<str:pk_or_slug>/', CourseDetailView.as_view(), name='lms_course_detail'),
# Enrollment & Progress
path('courses/<str:pk_or_slug>/enroll/', CourseEnrollView.as_view(), name='lms_course_enroll'),
path('courses/<str:pk_or_slug>/progress/', CourseProgressView.as_view(), name='lms_course_progress'),
# Lessons & Interactive Quizzes
path('lessons/<int:pk>/', LessonDetailView.as_view(), name='lms_lesson_detail'),
path('lessons/<int:pk>/complete/', LessonCompleteView.as_view(), name='lms_lesson_complete'),
path('lessons/<int:pk>/submit-quiz/', LessonQuizSubmitView.as_view(), name='lms_lesson_submit_quiz'),
# Certificates & Verification
path('my-certificates/', DigitalCertificateListView.as_view(), name='lms_my_certificates'),
path('certificates/<str:code>/', PublicCertificateVerifyView.as_view(), name='lms_certificate_verify'),
]

23
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',
]

463
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)

1
apps/tickets/__init__.py

@ -0,0 +1 @@
# Tickets app

27
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',)

8
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')

60
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',),
},
),
]

0
apps/tickets/migrations/__init__.py

15
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',
]

149
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})"

24
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

17
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',
]

94
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)

1
apps/tickets/tests/__init__.py

@ -0,0 +1 @@
# Tickets tests

146
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"])

17
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('<str:pk_or_code>/', TicketDetailView.as_view(), name='ticket_detail'),
path('<str:pk_or_code>/reply/', TicketReplyView.as_view(), name='ticket_reply'),
path('<str:pk_or_code>/close/', TicketCloseView.as_view(), name='ticket_close'),
path('<str:pk_or_code>/rate/', TicketRateView.as_view(), name='ticket_rate'),
]

15
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',
]

260
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)

20
config/settings/base.py

@ -37,6 +37,12 @@ LOCAL_APPS = [
'apps.chat.apps.ChatConfig', 'apps.chat.apps.ChatConfig',
'apps.events.apps.EventsConfig', 'apps.events.apps.EventsConfig',
'apps.meetings.apps.MeetingsConfig', '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', 'apps.api.apps.ApiConfig',
'dynamic_preferences', 'dynamic_preferences',
] ]
@ -234,11 +240,23 @@ SPECTACULAR_SETTINGS = {
'ENUM_NAME_OVERRIDES': { 'ENUM_NAME_OVERRIDES': {
'VerificationDocumentStatusEnum': 'apps.account.models.verification.VerificationDocument.Status', 'VerificationDocumentStatusEnum': 'apps.account.models.verification.VerificationDocument.Status',
'CMSPostStatusEnum': 'apps.cms.models.post.Post.Status', 'CMSPostStatusEnum': 'apps.cms.models.post.Post.Status',
'CMSPostLanguageEnum': 'apps.cms.models.post.Post.LanguageChoices',
'CMSCommentStatusEnum': 'apps.cms.models.post.PostComment.Status', 'CMSCommentStatusEnum': 'apps.cms.models.post.PostComment.Status',
'ProjectStatusEnum': 'apps.projects.models.project.Project.Status', 'ProjectStatusEnum': 'apps.projects.models.project.Project.Status',
'MeetingStatusEnum': 'apps.meetings.models.meeting.MeetingStatus', 'MeetingStatusEnum': 'apps.meetings.models.meeting.MeetingStatus',
'EventRegistrationStatusEnum': 'apps.events.models.event.RegistrationStatus', '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',
}, },
} }

18
config/urls.py

@ -52,6 +52,24 @@ api_v1_patterns = [
# Inter-Center Video Meetings & Scheduling # Inter-Center Video Meetings & Scheduling
path('meetings/', include('apps.meetings.urls')), 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('', include('apps.api.urls')),
path('account/', include('apps.account.urls')), path('account/', include('apps.account.urls')),
path('settings/', include('dynamic_preferences.urls')), path('settings/', include('dynamic_preferences.urls')),

6223
schema.yml
File diff suppressed because it is too large
View File

1
test_media/chat/attachments/2026/09/agenda_schedule_SERzRKL.pdf

@ -0,0 +1 @@
%PDF-1.4 Mock Schedule

1
test_media/chat/attachments/2026/09/agenda_schedule_elprMLK.pdf

@ -0,0 +1 @@
%PDF-1.4 Mock Schedule

1
test_media/chat/attachments/2026/09/agenda_schedule_kMmLXkB.pdf

@ -0,0 +1 @@
%PDF-1.4 Mock Schedule

1
test_media/institutions/gallery/2026/09/center_hall_PjFbSf2.jpg

@ -0,0 +1 @@
mock_image_bytes

1
test_media/institutions/gallery/2026/09/center_hall_ZcZFmJ9.jpg

@ -0,0 +1 @@
mock_image_bytes

1
test_media/institutions/gallery/2026/09/center_hall_fTt2o2G.jpg

@ -0,0 +1 @@
mock_image_bytes

1
test_media/projects/docs/2026/09/bilateral_mou_2026_UvsbCfE.pdf

@ -0,0 +1 @@
%PDF-1.4 Mock Bilateral MOU

1
test_media/projects/docs/2026/09/bilateral_mou_2026_pDBspqJ.pdf

@ -0,0 +1 @@
%PDF-1.4 Mock Bilateral MOU

1
test_media/projects/docs/2026/09/bilateral_mou_2026_xSM8Gd0.pdf

@ -0,0 +1 @@
%PDF-1.4 Mock Bilateral MOU

1
test_media/users/documents/2026/09/institution_license_8fLXyTj.pdf

@ -0,0 +1 @@
%PDF-1.4 Mock License Content

1
test_media/users/documents/2026/09/institution_license_FHSml8p.pdf

@ -0,0 +1 @@
%PDF-1.4 Mock License Content

1
test_media/users/documents/2026/09/institution_license_nE7mZTJ.pdf

@ -0,0 +1 @@
%PDF-1.4 Mock License Content
Loading…
Cancel
Save