Browse Source
feat: add new backend applications including tickets, lms, diplomacy, donations, dynamic forms, and analytics
main
feat: add new backend applications including tickets, lms, diplomacy, donations, dynamic forms, and analytics
main
94 changed files with 9489 additions and 1439 deletions
-
1apps/analytics/__init__.py
-
8apps/analytics/apps.py
-
48apps/analytics/serializers.py
-
1apps/analytics/tests/__init__.py
-
108apps/analytics/tests/test_phase9_analytics.py
-
6apps/analytics/urls.py
-
103apps/analytics/views.py
-
1apps/diplomacy/__init__.py
-
11apps/diplomacy/admin.py
-
8apps/diplomacy/apps.py
-
42apps/diplomacy/migrations/0001_initial.py
-
0apps/diplomacy/migrations/__init__.py
-
9apps/diplomacy/models/__init__.py
-
80apps/diplomacy/models/report.py
-
13apps/diplomacy/serializers/__init__.py
-
47apps/diplomacy/serializers/report_serializers.py
-
1apps/diplomacy/tests/__init__.py
-
69apps/diplomacy/tests/test_phase9_diplomacy.py
-
12apps/diplomacy/urls.py
-
11apps/diplomacy/views/__init__.py
-
140apps/diplomacy/views/report_views.py
-
1apps/donations/__init__.py
-
27apps/donations/admin.py
-
8apps/donations/apps.py
-
65apps/donations/migrations/0001_initial.py
-
0apps/donations/migrations/__init__.py
-
15apps/donations/models/__init__.py
-
194apps/donations/models/donation.py
-
13apps/donations/serializers/__init__.py
-
69apps/donations/serializers/donation_serializers.py
-
1apps/donations/tests/__init__.py
-
86apps/donations/tests/test_phase9_donations.py
-
12apps/donations/urls.py
-
11apps/donations/views/__init__.py
-
141apps/donations/views/donation_views.py
-
1apps/dynamic_forms/__init__.py
-
27apps/dynamic_forms/admin.py
-
8apps/dynamic_forms/apps.py
-
58apps/dynamic_forms/migrations/0001_initial.py
-
0apps/dynamic_forms/migrations/__init__.py
-
11apps/dynamic_forms/models/__init__.py
-
127apps/dynamic_forms/models/form.py
-
15apps/dynamic_forms/serializers/__init__.py
-
50apps/dynamic_forms/serializers/form_serializers.py
-
1apps/dynamic_forms/tests/__init__.py
-
94apps/dynamic_forms/tests/test_phase9_forms.py
-
19apps/dynamic_forms/urls.py
-
15apps/dynamic_forms/views/__init__.py
-
177apps/dynamic_forms/views/form_views.py
-
1apps/lms/__init__.py
-
83apps/lms/admin.py
-
8apps/lms/apps.py
-
160apps/lms/migrations/0001_initial.py
-
0apps/lms/migrations/__init__.py
-
25apps/lms/models/__init__.py
-
418apps/lms/models/course.py
-
55apps/lms/permissions.py
-
35apps/lms/serializers/__init__.py
-
257apps/lms/serializers/course_serializers.py
-
1apps/lms/tests/__init__.py
-
296apps/lms/tests/test_phase8_lms.py
-
31apps/lms/urls.py
-
23apps/lms/views/__init__.py
-
463apps/lms/views/course_views.py
-
1apps/tickets/__init__.py
-
27apps/tickets/admin.py
-
8apps/tickets/apps.py
-
60apps/tickets/migrations/0001_initial.py
-
0apps/tickets/migrations/__init__.py
-
15apps/tickets/models/__init__.py
-
149apps/tickets/models/ticket.py
-
24apps/tickets/permissions.py
-
17apps/tickets/serializers/__init__.py
-
94apps/tickets/serializers/ticket_serializers.py
-
1apps/tickets/tests/__init__.py
-
146apps/tickets/tests/test_phase9_tickets.py
-
17apps/tickets/urls.py
-
15apps/tickets/views/__init__.py
-
260apps/tickets/views/ticket_views.py
-
20config/settings/base.py
-
18config/urls.py
-
6217schema.yml
-
1test_media/chat/attachments/2026/09/agenda_schedule_SERzRKL.pdf
-
1test_media/chat/attachments/2026/09/agenda_schedule_elprMLK.pdf
-
1test_media/chat/attachments/2026/09/agenda_schedule_kMmLXkB.pdf
-
1test_media/institutions/gallery/2026/09/center_hall_PjFbSf2.jpg
-
1test_media/institutions/gallery/2026/09/center_hall_ZcZFmJ9.jpg
-
1test_media/institutions/gallery/2026/09/center_hall_fTt2o2G.jpg
-
1test_media/projects/docs/2026/09/bilateral_mou_2026_UvsbCfE.pdf
-
1test_media/projects/docs/2026/09/bilateral_mou_2026_pDBspqJ.pdf
-
1test_media/projects/docs/2026/09/bilateral_mou_2026_xSM8Gd0.pdf
-
1test_media/users/documents/2026/09/institution_license_8fLXyTj.pdf
-
1test_media/users/documents/2026/09/institution_license_FHSml8p.pdf
-
1test_media/users/documents/2026/09/institution_license_nE7mZTJ.pdf
@ -0,0 +1 @@ |
|||
# Analytics app |
|||
@ -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') |
|||
@ -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() |
|||
@ -0,0 +1 @@ |
|||
# Analytics tests |
|||
@ -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) |
|||
@ -0,0 +1,6 @@ |
|||
from django.urls import path |
|||
from apps.analytics.views import ExecutiveDashboardView |
|||
|
|||
urlpatterns = [ |
|||
path('dashboard/', ExecutiveDashboardView.as_view(), name='analytics_dashboard'), |
|||
] |
|||
@ -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) |
|||
@ -0,0 +1 @@ |
|||
# Diplomacy app |
|||
@ -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') |
|||
@ -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') |
|||
@ -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,0 +1,9 @@ |
|||
from apps.diplomacy.models.report import ( |
|||
RepresentativeReport, |
|||
ReportStatus, |
|||
) |
|||
|
|||
__all__ = [ |
|||
'RepresentativeReport', |
|||
'ReportStatus', |
|||
] |
|||
@ -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})" |
|||
@ -0,0 +1,13 @@ |
|||
from apps.diplomacy.serializers.report_serializers import ( |
|||
RepresentativeReportListSerializer, |
|||
RepresentativeReportDetailSerializer, |
|||
RepresentativeReportCreateSerializer, |
|||
RepresentativeReportReviewSerializer, |
|||
) |
|||
|
|||
__all__ = [ |
|||
'RepresentativeReportListSerializer', |
|||
'RepresentativeReportDetailSerializer', |
|||
'RepresentativeReportCreateSerializer', |
|||
'RepresentativeReportReviewSerializer', |
|||
] |
|||
@ -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) |
|||
@ -0,0 +1 @@ |
|||
# Diplomacy tests |
|||
@ -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"]) |
|||
@ -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'), |
|||
] |
|||
@ -0,0 +1,11 @@ |
|||
from apps.diplomacy.views.report_views import ( |
|||
RepresentativeReportListCreateView, |
|||
RepresentativeReportDetailView, |
|||
RepresentativeReportReviewView, |
|||
) |
|||
|
|||
__all__ = [ |
|||
'RepresentativeReportListCreateView', |
|||
'RepresentativeReportDetailView', |
|||
'RepresentativeReportReviewView', |
|||
] |
|||
@ -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) |
|||
@ -0,0 +1 @@ |
|||
# Donations app |
|||
@ -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') |
|||
@ -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') |
|||
@ -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,0 +1,15 @@ |
|||
from apps.donations.models.donation import ( |
|||
DonationCampaign, |
|||
DonationTransaction, |
|||
CampaignCategory, |
|||
CampaignPriority, |
|||
PaymentStatus, |
|||
) |
|||
|
|||
__all__ = [ |
|||
'DonationCampaign', |
|||
'DonationTransaction', |
|||
'CampaignCategory', |
|||
'CampaignPriority', |
|||
'PaymentStatus', |
|||
] |
|||
@ -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) |
|||
@ -0,0 +1,13 @@ |
|||
from apps.donations.serializers.donation_serializers import ( |
|||
DonationCampaignListSerializer, |
|||
DonationCampaignDetailSerializer, |
|||
DonationTransactionSerializer, |
|||
DonationContributeRequestSerializer, |
|||
) |
|||
|
|||
__all__ = [ |
|||
'DonationCampaignListSerializer', |
|||
'DonationCampaignDetailSerializer', |
|||
'DonationTransactionSerializer', |
|||
'DonationContributeRequestSerializer', |
|||
] |
|||
@ -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) |
|||
@ -0,0 +1 @@ |
|||
# Donations tests |
|||
@ -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) |
|||
@ -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'), |
|||
] |
|||
@ -0,0 +1,11 @@ |
|||
from apps.donations.views.donation_views import ( |
|||
DonationCampaignListView, |
|||
DonationCampaignDetailView, |
|||
DonationContributeView, |
|||
) |
|||
|
|||
__all__ = [ |
|||
'DonationCampaignListView', |
|||
'DonationCampaignDetailView', |
|||
'DonationContributeView', |
|||
] |
|||
@ -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) |
|||
@ -0,0 +1 @@ |
|||
# Dynamic forms app |
|||
@ -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') |
|||
@ -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') |
|||
@ -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,0 +1,11 @@ |
|||
from apps.dynamic_forms.models.form import ( |
|||
FormDefinition, |
|||
FormResponse, |
|||
WorkflowStatus, |
|||
) |
|||
|
|||
__all__ = [ |
|||
'FormDefinition', |
|||
'FormResponse', |
|||
'WorkflowStatus', |
|||
] |
|||
@ -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()})" |
|||
@ -0,0 +1,15 @@ |
|||
from apps.dynamic_forms.serializers.form_serializers import ( |
|||
FormDefinitionListSerializer, |
|||
FormDefinitionDetailSerializer, |
|||
FormResponseSerializer, |
|||
FormResponseSubmitSerializer, |
|||
FormResponseReviewSerializer, |
|||
) |
|||
|
|||
__all__ = [ |
|||
'FormDefinitionListSerializer', |
|||
'FormDefinitionDetailSerializer', |
|||
'FormResponseSerializer', |
|||
'FormResponseSubmitSerializer', |
|||
'FormResponseReviewSerializer', |
|||
] |
|||
@ -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) |
|||
@ -0,0 +1 @@ |
|||
# Dynamic forms tests |
|||
@ -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"]) |
|||
@ -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'), |
|||
] |
|||
@ -0,0 +1,15 @@ |
|||
from apps.dynamic_forms.views.form_views import ( |
|||
FormDefinitionListCreateView, |
|||
FormDefinitionDetailView, |
|||
FormResponseSubmitView, |
|||
FormResponseListView, |
|||
FormResponseReviewView, |
|||
) |
|||
|
|||
__all__ = [ |
|||
'FormDefinitionListCreateView', |
|||
'FormDefinitionDetailView', |
|||
'FormResponseSubmitView', |
|||
'FormResponseListView', |
|||
'FormResponseReviewView', |
|||
] |
|||
@ -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) |
|||
@ -0,0 +1 @@ |
|||
# LMS app |
|||
@ -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') |
|||
@ -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)') |
|||
@ -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,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', |
|||
] |
|||
@ -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 |
|||
@ -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) |
|||
@ -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', |
|||
] |
|||
@ -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() |
|||
@ -0,0 +1 @@ |
|||
# LMS tests |
|||
@ -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) |
|||
@ -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'), |
|||
] |
|||
@ -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', |
|||
] |
|||
@ -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) |
|||
@ -0,0 +1 @@ |
|||
# Tickets app |
|||
@ -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',) |
|||
@ -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') |
|||
@ -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,0 +1,15 @@ |
|||
from apps.tickets.models.ticket import ( |
|||
Ticket, |
|||
TicketMessage, |
|||
TicketCategory, |
|||
TicketPriority, |
|||
TicketStatus, |
|||
) |
|||
|
|||
__all__ = [ |
|||
'Ticket', |
|||
'TicketMessage', |
|||
'TicketCategory', |
|||
'TicketPriority', |
|||
'TicketStatus', |
|||
] |
|||
@ -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})" |
|||
@ -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 |
|||
@ -0,0 +1,17 @@ |
|||
from apps.tickets.serializers.ticket_serializers import ( |
|||
TicketMessageSerializer, |
|||
TicketMessageCreateSerializer, |
|||
TicketListSerializer, |
|||
TicketDetailSerializer, |
|||
TicketCreateSerializer, |
|||
TicketRateSerializer, |
|||
) |
|||
|
|||
__all__ = [ |
|||
'TicketMessageSerializer', |
|||
'TicketMessageCreateSerializer', |
|||
'TicketListSerializer', |
|||
'TicketDetailSerializer', |
|||
'TicketCreateSerializer', |
|||
'TicketRateSerializer', |
|||
] |
|||
@ -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) |
|||
@ -0,0 +1 @@ |
|||
# Tickets tests |
|||
@ -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"]) |
|||
@ -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'), |
|||
] |
|||
@ -0,0 +1,15 @@ |
|||
from apps.tickets.views.ticket_views import ( |
|||
TicketListCreateView, |
|||
TicketDetailView, |
|||
TicketReplyView, |
|||
TicketCloseView, |
|||
TicketRateView, |
|||
) |
|||
|
|||
__all__ = [ |
|||
'TicketListCreateView', |
|||
'TicketDetailView', |
|||
'TicketReplyView', |
|||
'TicketCloseView', |
|||
'TicketRateView', |
|||
] |
|||
@ -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) |
|||
6217
schema.yml
File diff suppressed because it is too large
View File
File diff suppressed because it is too large
View File
@ -0,0 +1 @@ |
|||
%PDF-1.4 Mock Schedule |
|||
@ -0,0 +1 @@ |
|||
%PDF-1.4 Mock Schedule |
|||
@ -0,0 +1 @@ |
|||
%PDF-1.4 Mock Schedule |
|||
@ -0,0 +1 @@ |
|||
mock_image_bytes |
|||
@ -0,0 +1 @@ |
|||
mock_image_bytes |
|||
@ -0,0 +1 @@ |
|||
mock_image_bytes |
|||
@ -0,0 +1 @@ |
|||
%PDF-1.4 Mock Bilateral MOU |
|||
@ -0,0 +1 @@ |
|||
%PDF-1.4 Mock Bilateral MOU |
|||
@ -0,0 +1 @@ |
|||
%PDF-1.4 Mock Bilateral MOU |
|||
@ -0,0 +1 @@ |
|||
%PDF-1.4 Mock License Content |
|||
@ -0,0 +1 @@ |
|||
%PDF-1.4 Mock License Content |
|||
@ -0,0 +1 @@ |
|||
%PDF-1.4 Mock License Content |
|||
Write
Preview
Loading…
Cancel
Save
Reference in new issue