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