update in forms

This commit is contained in:
ismail 2026-01-06 18:18:31 +03:00
parent d5a2cbda21
commit fe0f2c5cea
16 changed files with 2213 additions and 114 deletions

View File

@ -9,7 +9,7 @@ from .models import Complaint, ComplaintAttachment, ComplaintUpdate, Inquiry
class ComplaintAttachmentSerializer(serializers.ModelSerializer):
"""Complaint attachment serializer"""
uploaded_by_name = serializers.SerializerMethodField()
class Meta:
model = ComplaintAttachment
fields = [
@ -18,7 +18,7 @@ class ComplaintAttachmentSerializer(serializers.ModelSerializer):
'created_at', 'updated_at'
]
read_only_fields = ['id', 'file_size', 'created_at', 'updated_at']
def get_uploaded_by_name(self, obj):
"""Get uploader name"""
if obj.uploaded_by:
@ -29,7 +29,7 @@ class ComplaintAttachmentSerializer(serializers.ModelSerializer):
class ComplaintUpdateSerializer(serializers.ModelSerializer):
"""Complaint update serializer"""
created_by_name = serializers.SerializerMethodField()
class Meta:
model = ComplaintUpdate
fields = [
@ -39,7 +39,7 @@ class ComplaintUpdateSerializer(serializers.ModelSerializer):
'created_at', 'updated_at'
]
read_only_fields = ['id', 'created_at', 'updated_at']
def get_created_by_name(self, obj):
"""Get creator name"""
if obj.created_by:
@ -58,7 +58,7 @@ class ComplaintSerializer(serializers.ModelSerializer):
attachments = ComplaintAttachmentSerializer(many=True, read_only=True)
updates = ComplaintUpdateSerializer(many=True, read_only=True)
sla_status = serializers.SerializerMethodField()
class Meta:
model = Complaint
fields = [
@ -82,28 +82,85 @@ class ComplaintSerializer(serializers.ModelSerializer):
'resolved_at', 'closed_at', 'resolution_survey_sent_at',
'created_at', 'updated_at'
]
def create(self, validated_data):
"""
Create complaint with auto-setting of department from AI analysis.
If metadata contains AI analysis with department name,
automatically set the department field by querying the database.
"""
from apps.organizations.models import Department
import logging
logger = logging.getLogger(__name__)
# Extract metadata if present
metadata = validated_data.get('metadata', {})
# Check if department is not already set and AI analysis contains department info
if not validated_data.get('department') and metadata.get('ai_analysis'):
ai_analysis = metadata['ai_analysis']
department_name = ai_analysis.get('department')
# If AI identified a department, try to match it in the database
if department_name:
hospital = validated_data.get('hospital')
if hospital:
try:
# Try exact match first
department = Department.objects.filter(
hospital=hospital,
status='active',
name__iexact=department_name
).first()
# If no exact match, try partial match
if not department:
department = Department.objects.filter(
hospital=hospital,
status='active',
name__icontains=department_name
).first()
if department:
validated_data['department'] = department
logger.info(
f"Auto-set department '{department.name}' from AI analysis "
f"for complaint (matched from '{department_name}')"
)
else:
logger.warning(
f"AI suggested department '{department_name}' but no match found "
f"in hospital '{hospital.name}'"
)
except Exception as e:
logger.error(f"Error auto-setting department: {e}")
# Create the complaint
return super().create(validated_data)
def get_physician_name(self, obj):
"""Get physician name"""
if obj.physician:
return obj.physician.get_full_name()
return None
def get_assigned_to_name(self, obj):
"""Get assigned user name"""
if obj.assigned_to:
return obj.assigned_to.get_full_name()
return None
def get_sla_status(self, obj):
"""Get SLA status"""
if obj.is_overdue:
return 'overdue'
from django.utils import timezone
time_remaining = obj.due_at - timezone.now()
hours_remaining = time_remaining.total_seconds() / 3600
if hours_remaining < 4:
return 'due_soon'
return 'on_time'
@ -114,7 +171,7 @@ class ComplaintListSerializer(serializers.ModelSerializer):
patient_name = serializers.CharField(source='patient.get_full_name', read_only=True)
hospital_name = serializers.CharField(source='hospital.name', read_only=True)
sla_status = serializers.SerializerMethodField()
class Meta:
model = Complaint
fields = [
@ -122,16 +179,16 @@ class ComplaintListSerializer(serializers.ModelSerializer):
'category', 'severity', 'status', 'sla_status',
'assigned_to', 'created_at'
]
def get_sla_status(self, obj):
"""Get SLA status"""
if obj.is_overdue:
return 'overdue'
from django.utils import timezone
time_remaining = obj.due_at - timezone.now()
hours_remaining = time_remaining.total_seconds() / 3600
if hours_remaining < 4:
return 'due_soon'
return 'on_time'
@ -143,7 +200,7 @@ class InquirySerializer(serializers.ModelSerializer):
hospital_name = serializers.CharField(source='hospital.name', read_only=True)
department_name = serializers.CharField(source='department.name', read_only=True)
assigned_to_name = serializers.SerializerMethodField()
class Meta:
model = Inquiry
fields = [
@ -156,7 +213,7 @@ class InquirySerializer(serializers.ModelSerializer):
'created_at', 'updated_at'
]
read_only_fields = ['id', 'responded_at', 'created_at', 'updated_at']
def get_assigned_to_name(self, obj):
"""Get assigned user name"""
if obj.assigned_to:

View File

@ -9,6 +9,7 @@ This module contains tasks for:
- AI-powered complaint analysis
"""
import logging
from typing import Optional, Dict, Any, Tuple
from celery import shared_task
from django.db import transaction
@ -18,6 +19,119 @@ from django.utils import timezone
logger = logging.getLogger(__name__)
def match_staff_from_name(staff_name: str, hospital_id: str, department_name: Optional[str] = None) -> Tuple[Optional[str], float, str]:
"""
Match staff member from extracted name using multiple matching strategies.
Args:
staff_name: Name extracted from complaint (without titles)
hospital_id: Hospital ID to search within
department_name: Optional department name to prioritize matching
Returns:
Tuple of (staff_id, confidence_score, matching_method)
- staff_id: UUID of matched staff or None
- confidence_score: Float from 0.0 to 1.0
- matching_method: Description of how staff was matched
"""
from apps.organizations.models import Staff, Department
if not staff_name or not staff_name.strip():
return None, 0.0, "No staff name provided"
staff_name = staff_name.strip()
# Build base query - staff from this hospital, active status
base_query = Staff.objects.filter(
hospital_id=hospital_id,
status='active'
)
# If department is specified, prioritize staff in that department
dept_id = None
if department_name:
department = Department.objects.filter(
hospital_id=hospital_id,
name__iexact=department_name,
status='active'
).first()
if department:
dept_id = department.id
# Layer 1: Exact English match (full name)
# Try "first_name last_name" format
words = staff_name.split()
if len(words) >= 2:
first_name = words[0]
last_name = ' '.join(words[1:])
# Exact match in correct department
exact_query = base_query.filter(
Q(first_name__iexact=first_name) & Q(last_name__iexact=last_name)
)
if dept_id:
exact_query = exact_query.filter(department_id=dept_id)
staff = exact_query.first()
if staff:
confidence = 0.95 if dept_id else 0.90
method = f"Exact English match in {'correct' if dept_id else 'any'} department"
logger.info(f"Matched staff using exact English match: {staff.first_name} {staff.last_name} (confidence: {confidence})")
return str(staff.id), confidence, method
# Layer 2: Exact Arabic match
arabic_query = base_query.filter(
Q(first_name_ar__iexact=staff_name) | Q(last_name_ar__iexact=staff_name)
)
if dept_id:
arabic_query = arabic_query.filter(department_id=dept_id)
# Try full Arabic name match
for staff in arabic_query:
full_arabic_name = f"{staff.first_name_ar} {staff.last_name_ar}".strip()
if full_arabic_name == staff_name:
confidence = 0.95 if dept_id else 0.90
method = f"Exact Arabic match in {'correct' if dept_id else 'any'} department"
logger.info(f"Matched staff using exact Arabic match: {staff.first_name_ar} {staff.last_name_ar} (confidence: {confidence})")
return str(staff.id), confidence, method
# Layer 3: Partial match (first name or last name)
partial_query = base_query.filter(
Q(first_name__icontains=staff_name) |
Q(last_name__icontains=staff_name) |
Q(first_name_ar__icontains=staff_name) |
Q(last_name_ar__icontains=staff_name)
)
if dept_id:
partial_query = partial_query.filter(department_id=dept_id)
staff = partial_query.first()
if staff:
confidence = 0.70 if dept_id else 0.60
method = f"Partial match in {'correct' if dept_id else 'any'} department"
logger.info(f"Matched staff using partial match: {staff.first_name} {staff.last_name} (confidence: {confidence})")
return str(staff.id), confidence, method
# Layer 4: Fuzzy match using individual words
# Handle cases like "Dr. Ahmed" or "Nurse Sarah"
word_query = base_query.filter(
Q(first_name__in=words) | Q(first_name_ar__in=words)
)
if dept_id:
word_query = word_query.filter(department_id=dept_id)
staff = word_query.first()
if staff:
confidence = 0.50 if dept_id else 0.45
method = f"Word match in {'correct' if dept_id else 'any'} department"
logger.info(f"Matched staff using word match: {staff.first_name} {staff.last_name} (confidence: {confidence})")
return str(staff.id), confidence, method
# No match found
logger.warning(f"No staff match found for name: {staff_name}")
return None, 0.0, "No match found"
@shared_task
def check_overdue_complaints():
"""
@ -541,7 +655,8 @@ def analyze_complaint_with_ai(complaint_id):
analysis = AIService.analyze_complaint(
title=complaint.title,
description=complaint.description,
category=category_name
category=category_name,
hospital_id=complaint.hospital.id
)
# Analyze emotion using AI service
@ -566,11 +681,24 @@ def analyze_complaint_with_ai(complaint_id):
department_name = analysis.get('department', '')
if department_name:
from apps.organizations.models import Department
# Try exact match first (case-insensitive)
if department := Department.objects.filter(
hospital_id=complaint.hospital.id,
name=department_name
name__iexact=department_name,
status='active'
).first():
complaint.department = department
logger.info(f"Matched department exactly: {department.name}")
# If no exact match, try partial match
elif department := Department.objects.filter(
hospital_id=complaint.hospital.id,
name__icontains=department_name,
status='active'
).first():
complaint.department = department
logger.info(f"Matched department partially: {department.name} from '{department_name}'")
else:
logger.warning(f"AI suggested department '{department_name}' but no match found in hospital '{complaint.hospital.name}'")
# Update title from AI analysis (use English version)
if analysis.get('title_en'):
@ -578,12 +706,62 @@ def analyze_complaint_with_ai(complaint_id):
elif analysis.get('title'):
complaint.title = analysis['title']
# Get staff_name from analyze_complaint result (already extracted by AI)
staff_name = analysis.get('staff_name', '').strip()
matched_staff_id = None
staff_confidence = 0.0
staff_matching_method = None
# Capture old staff before matching
old_staff = complaint.staff
if staff_name:
logger.info(f"AI extracted staff name: {staff_name}")
# Try matching WITH department filter first (higher confidence if match found)
matched_staff_id, staff_confidence, staff_matching_method = match_staff_from_name(
staff_name=staff_name,
hospital_id=str(complaint.hospital.id),
department_name=department_name
)
# If no match found with department, try WITHOUT department filter
if not matched_staff_id:
logger.info(f"No match found with department filter '{department_name}', trying without department filter...")
matched_staff_id, staff_confidence, staff_matching_method = match_staff_from_name(
staff_name=staff_name,
hospital_id=str(complaint.hospital.id),
department_name=None # Search all departments
)
# Only assign staff if confidence is above threshold (0.6)
if matched_staff_id and staff_confidence >= 0.6:
from apps.organizations.models import Staff
try:
staff = Staff.objects.get(id=matched_staff_id)
complaint.staff = staff
logger.info(
f"Assigned staff {staff.first_name} {staff.last_name} "
f"to complaint {complaint_id} "
f"(confidence: {staff_confidence:.2f}, method: {staff_matching_method})"
)
except Staff.DoesNotExist:
logger.warning(f"Staff {matched_staff_id} not found in database")
else:
logger.info(
f"Staff match confidence {staff_confidence:.2f} below threshold 0.6, "
f"or no match found. Not assigning staff."
)
# Save reasoning in metadata
# Use JSON-serializable values instead of model objects
old_category_name = old_category.name_en if old_category else None
old_category_id = str(old_category.id) if old_category else None
old_department_name = old_department.name if old_department else None
old_department_id = str(old_department.id) if old_department else None
old_staff_name = f"{old_staff.first_name} {old_staff.last_name}" if old_staff else None
old_staff_id = str(old_staff.id) if old_staff else None
# Initialize metadata if needed
if not complaint.metadata:
@ -608,10 +786,16 @@ def analyze_complaint_with_ai(complaint_id):
'old_category': old_category_name,
'old_category_id': old_category_id,
'old_department': old_department_name,
'old_department_id': old_department_id
'old_department_id': old_department_id,
'old_staff': old_staff_name,
'old_staff_id': old_staff_id,
'extracted_staff_name': staff_name,
'matched_staff_id': matched_staff_id,
'staff_confidence': staff_confidence,
'staff_matching_method': staff_matching_method
}
complaint.save(update_fields=['severity', 'priority', 'category', 'department', 'title', 'metadata'])
complaint.save(update_fields=['severity', 'priority', 'category', 'department', 'staff', 'title', 'metadata'])
# Re-calculate SLA due date based on new severity
complaint.due_at = complaint.calculate_sla_due_date()
@ -624,8 +808,19 @@ def analyze_complaint_with_ai(complaint_id):
emotion_display = emotion_analysis.get('emotion', 'neutral')
emotion_intensity = emotion_analysis.get('intensity', 0.0)
message_en = f"AI analysis complete: Severity={analysis['severity']}, Priority={analysis['priority']}, Category={analysis.get('category', 'N/A')}, Department={department_name or 'N/A'}, Emotion={emotion_display} (Intensity: {emotion_intensity:.2f})"
message_ar = f"اكتمل تحليل الذكاء الاصطناعي: الشدة={analysis['severity']}, الأولوية={analysis['priority']}, الفئة={analysis.get('category', 'N/A')}, القسم={department_name or 'N/A'}, العاطفة={emotion_display} (الشدة: {emotion_intensity:.2f})"
# Build English message
message_en = f"AI analysis complete: Severity={analysis['severity']}, Priority={analysis['priority']}, Category={analysis.get('category', 'N/A')}, Department={department_name or 'N/A'}"
if matched_staff_id:
message_en += f", Staff={f'{complaint.staff.first_name} {complaint.staff.last_name}' if complaint.staff else 'N/A'} (confidence: {staff_confidence:.2f})"
message_en += f", Emotion={emotion_display} (Intensity: {emotion_intensity:.2f})"
# Build Arabic message
message_ar = f"اكتمل تحليل الذكاء الاصطناعي: الشدة={analysis['severity']}, الأولوية={analysis['priority']}, الفئة={analysis.get('category', 'N/A')}, القسم={department_name or 'N/A'}"
if matched_staff_id and complaint.staff:
staff_name_ar = complaint.staff.first_name_ar if complaint.staff.first_name_ar else complaint.staff.first_name
message_ar += f", الموظف={staff_name_ar} {complaint.staff.last_name_ar if complaint.staff.last_name_ar else complaint.staff.last_name} (الثقة: {staff_confidence:.2f})"
message_ar += f", العاطفة={emotion_display} (الشدة: {emotion_intensity:.2f})"
message = f"{message_en}\n\n{message_ar}"
ComplaintUpdate.objects.create(

View File

@ -19,6 +19,82 @@ from .serializers import (
)
def map_complaint_category_to_action_category(complaint_category_code):
"""
Map complaint category code to PX Action category.
Provides intelligent mapping from complaint categories to PX Action categories.
Returns 'other' as fallback if no match found.
"""
if not complaint_category_code:
return 'other'
mapping = {
# Clinical issues
'clinical': 'clinical_quality',
'medical': 'clinical_quality',
'diagnosis': 'clinical_quality',
'treatment': 'clinical_quality',
'medication': 'clinical_quality',
'care': 'clinical_quality',
# Safety issues
'safety': 'patient_safety',
'risk': 'patient_safety',
'incident': 'patient_safety',
'infection': 'patient_safety',
'harm': 'patient_safety',
# Service quality
'service': 'service_quality',
'communication': 'service_quality',
'wait': 'service_quality',
'response': 'service_quality',
'customer_service': 'service_quality',
'timeliness': 'service_quality',
'waiting_time': 'service_quality',
# Staff behavior
'staff': 'staff_behavior',
'behavior': 'staff_behavior',
'attitude': 'staff_behavior',
'professionalism': 'staff_behavior',
'rude': 'staff_behavior',
'respect': 'staff_behavior',
# Facility
'facility': 'facility',
'environment': 'facility',
'cleanliness': 'facility',
'equipment': 'facility',
'infrastructure': 'facility',
'parking': 'facility',
'accessibility': 'facility',
# Process
'process': 'process_improvement',
'administrative': 'process_improvement',
'billing': 'process_improvement',
'procedure': 'process_improvement',
'workflow': 'process_improvement',
'registration': 'process_improvement',
'appointment': 'process_improvement',
}
# Try exact match first
category_lower = complaint_category_code.lower()
if category_lower in mapping:
return mapping[category_lower]
# Try partial match (contains the keyword)
for keyword, action_category in mapping.items():
if keyword in category_lower:
return action_category
# Fallback to 'other'
return 'other'
class ComplaintViewSet(viewsets.ModelViewSet):
"""
ViewSet for Complaints with workflow actions.
@ -205,6 +281,122 @@ class ComplaintViewSet(viewsets.ModelViewSet):
serializer = ComplaintUpdateSerializer(update)
return Response(serializer.data, status=status.HTTP_201_CREATED)
@action(detail=True, methods=['post'])
def create_action_from_ai(self, request, pk=None):
"""Create PX Action from AI-suggested action"""
complaint = self.get_object()
# Check if complaint has suggested action
suggested_action = request.data.get('suggested_action')
if not suggested_action and complaint.metadata and 'ai_analysis' in complaint.metadata:
suggested_action = complaint.metadata['ai_analysis'].get('suggested_action_en')
if not suggested_action:
return Response(
{'error': 'No suggested action available for this complaint'},
status=status.HTTP_400_BAD_REQUEST
)
# Get category (optional - will be auto-mapped from complaint category if not provided)
category = request.data.get('category')
# If category not provided, auto-map from complaint category
if not category:
if complaint.category:
category = map_complaint_category_to_action_category(complaint.category.code)
else:
category = 'other'
# Validate category choice if manually provided
valid_categories = [
'clinical_quality', 'patient_safety', 'service_quality',
'staff_behavior', 'facility', 'process_improvement', 'other'
]
if category not in valid_categories:
return Response(
{'error': f'Invalid category. Valid options: {", ".join(valid_categories)}'},
status=status.HTTP_400_BAD_REQUEST
)
# Get optional assigned_to
assigned_to_id = request.data.get('assigned_to')
assigned_to = None
if assigned_to_id:
from apps.accounts.models import User
try:
assigned_to = User.objects.get(id=assigned_to_id)
except User.DoesNotExist:
return Response(
{'error': 'Assigned user not found'},
status=status.HTTP_404_NOT_FOUND
)
# Create PX Action
from apps.px_action_center.models import PXAction, PXActionLog
from django.contrib.contenttypes.models import ContentType
complaint_content_type = ContentType.objects.get_for_model(Complaint)
action = PXAction.objects.create(
source_type='complaint',
content_type=complaint_content_type,
object_id=complaint.id,
title=f"Action from Complaint: {complaint.title}",
description=suggested_action,
hospital=complaint.hospital,
department=complaint.department,
category=category,
priority=complaint.priority,
severity=complaint.severity,
assigned_to=assigned_to,
status='open',
metadata={
'source_complaint_id': str(complaint.id),
'source_complaint_title': complaint.title,
'ai_generated': True,
'created_from_ai_suggestion': True
}
)
# Create action log entry
PXActionLog.objects.create(
action=action,
log_type='note',
message=f"Action created from AI-suggested action for complaint: {complaint.title}",
created_by=request.user,
metadata={
'complaint_id': str(complaint.id),
'ai_generated': True
}
)
# Create complaint update
ComplaintUpdate.objects.create(
complaint=complaint,
update_type='note',
message=f"PX Action created from AI-suggested action (Action #{action.id})",
created_by=request.user,
metadata={'action_id': str(action.id)}
)
# Log audit
AuditService.log_from_request(
event_type='action_created_from_ai',
description=f"PX Action created from AI-suggested action for complaint: {complaint.title}",
request=request,
content_object=action,
metadata={
'complaint_id': str(complaint.id),
'category': category,
'ai_generated': True
}
)
return Response({
'action_id': str(action.id),
'message': 'Action created successfully from AI-suggested action'
}, status=status.HTTP_201_CREATED)
class ComplaintAttachmentViewSet(viewsets.ModelViewSet):
"""ViewSet for Complaint Attachments"""

View File

@ -280,7 +280,11 @@ class AIService:
5. If a category has no subcategories, leave the subcategory field empty
6. Select the most appropriate department from the hospital's departments (if available)
7. If no departments are available or department is unclear, leave the department field empty
8. Generate a suggested_action (2-3 sentences) with specific, actionable steps to address this complaint in BOTH English and Arabic
8. Extract any staff members mentioned in the complaint (physicians, nurses, etc.)
9. Return the staff name WITHOUT titles (Dr., Nurse, دكتور, ممرض, etc.)
10. If multiple staff are mentioned, return the PRIMARY one
11. If no staff is mentioned, leave the staff_name field empty
12. Generate a suggested_action (2-3 sentences) with specific, actionable steps to address this complaint in BOTH English and Arabic
IMPORTANT: ALL TEXT FIELDS MUST BE PROVIDED IN BOTH ENGLISH AND ARABIC
- title: Provide in both English and Arabic
@ -299,6 +303,7 @@ class AIService:
"category": "exact category name from the list above",
"subcategory": "exact subcategory name from the chosen category, or empty string if not applicable",
"department": "exact department name from the hospital's departments, or empty string if not applicable",
"staff_name": "name of staff member mentioned (without titles like Dr., Nurse, etc.), or empty string if no staff mentioned",
"suggested_action_en": "2-3 specific, actionable steps in English to address this complaint",
"suggested_action_ar": "خطوات محددة وعمليه بالعربية",
"reasoning_en": "Brief explanation in English of your classification (2-3 sentences)",
@ -361,6 +366,7 @@ class AIService:
'category': 'other',
'subcategory': '',
'department': '',
'staff_name': '',
'reasoning': 'AI analysis failed, using default values'
}
except AIServiceError as e:
@ -372,6 +378,7 @@ class AIService:
'category': 'other',
'subcategory': '',
'department': '',
'staff_name': '',
'reasoning': f'AI service unavailable: {str(e)}'
}

View File

@ -3,15 +3,34 @@ Core app URLs
"""
from django.urls import path
from .views import health_check, select_hospital, no_hospital_assigned
from .views import (
health_check,
select_hospital,
no_hospital_assigned,
public_submit_landing,
public_inquiry_submit,
public_observation_submit,
api_hospitals,
api_observation_categories
)
from . import config_views
app_name = 'core'
urlpatterns = [
# Health check
path('', health_check, name='health'),
# Hospital selection
path('select-hospital/', select_hospital, name='select_hospital'),
path('no-hospital/', no_hospital_assigned, name='no_hospital_assigned'),
# Public submission pages
path('public/submit/', public_submit_landing, name='public_submit_landing'),
path('public/inquiry/submit/', public_inquiry_submit, name='public_inquiry_submit'),
path('public/observation/submit/', public_observation_submit, name='public_observation_submit'),
path('api/hospitals/', api_hospitals, name='api_hospitals'),
path('api/observation-categories/', api_observation_categories, name='api_observation_categories'),
]
# Configuration URLs (separate app_name)

View File

@ -90,3 +90,239 @@ def no_hospital_assigned(request):
Users without a hospital assignment cannot access the system.
"""
return render(request, 'core/no_hospital_assigned.html', status=403)
# ============================================================================
# PUBLIC SUBMISSION VIEWS
# ============================================================================
@require_GET
def public_submit_landing(request):
"""
Landing page for public submissions.
Allows users to choose between Complaint, Observation, or Inquiry.
No authentication required.
"""
from apps.organizations.models import Hospital
hospitals = Hospital.objects.all().order_by('name')
context = {
'hospitals': hospitals,
}
return render(request, 'core/public_submit.html', context)
@require_POST
def public_inquiry_submit(request):
"""
Handle public inquiry submissions.
Creates an inquiry from public submission.
Returns JSON response with reference number.
"""
from apps.complaints.models import Inquiry
from apps.organizations.models import Hospital
import uuid
# Get form data
name = request.POST.get('name', '').strip()
email = request.POST.get('email', '').strip()
phone = request.POST.get('phone', '').strip()
hospital_id = request.POST.get('hospital')
category = request.POST.get('category', '').strip()
subject = request.POST.get('subject', '').strip()
message = request.POST.get('message', '').strip()
# Validation
errors = []
if not name:
errors.append('Name is required')
if not email:
errors.append('Email is required')
if not phone:
errors.append('Phone number is required')
if not hospital_id:
errors.append('Hospital selection is required')
if not subject:
errors.append('Subject is required')
if not message:
errors.append('Message is required')
if errors:
return JsonResponse({
'success': False,
'errors': errors
}, status=400)
try:
# Validate hospital
hospital = Hospital.objects.get(id=hospital_id)
# Create inquiry (using correct field names from model)
inquiry = Inquiry.objects.create(
hospital=hospital,
contact_name=name,
contact_email=email,
contact_phone=phone,
subject=subject,
message=message,
category=category,
status='open'
)
# Generate a simple reference for display
reference_number = f"INQ-{str(inquiry.id)[:8].upper()}"
# Send notification email (optional)
try:
from django.core.mail import send_mail
from django.conf import settings
send_mail(
f'New Public Inquiry - {reference_number}',
f'Inquiry from {name}\n\nSubject: {subject}\n\nMessage:\n{message}',
settings.DEFAULT_FROM_EMAIL,
[settings.DEFAULT_FROM_EMAIL], # Send to admin
fail_silently=True,
)
except Exception:
pass # Don't fail if email doesn't send
return JsonResponse({
'success': True,
'reference_number': reference_number,
'inquiry_id': str(inquiry.id)
})
except Hospital.DoesNotExist:
return JsonResponse({
'success': False,
'errors': ['Invalid hospital selected']
}, status=400)
except Exception as e:
return JsonResponse({
'success': False,
'errors': [str(e)]
}, status=500)
@require_GET
def api_hospitals(request):
"""
API endpoint to get hospitals list.
Used by public submission forms to populate hospital dropdown.
"""
from apps.organizations.models import Hospital
hospitals = Hospital.objects.all().order_by('name').values('id', 'name')
return JsonResponse({
'success': True,
'hospitals': list(hospitals)
})
@require_GET
def api_observation_categories(request):
"""
API endpoint to get observation categories list.
Used by public observation form to populate category dropdown.
"""
from apps.observations.models import ObservationCategory
categories = ObservationCategory.objects.filter(is_active=True).order_by('sort_order', 'name_en').values('id', 'name_en', 'name_ar')
return JsonResponse({
'success': True,
'categories': list(categories)
})
@require_POST
def public_observation_submit(request):
"""
Handle public observation submissions.
Creates an observation from public submission.
Returns JSON response with tracking code.
"""
from apps.observations.models import Observation, ObservationAttachment
from apps.observations.services import ObservationService
import mimetypes
# Get form data
category_id = request.POST.get('category')
severity = request.POST.get('severity', 'medium')
title = request.POST.get('title', '').strip()
description = request.POST.get('description', '').strip()
location_text = request.POST.get('location_text', '').strip()
incident_datetime = request.POST.get('incident_datetime', '')
reporter_staff_id = request.POST.get('reporter_staff_id', '').strip()
reporter_name = request.POST.get('reporter_name', '').strip()
reporter_phone = request.POST.get('reporter_phone', '').strip()
reporter_email = request.POST.get('reporter_email', '').strip()
# Validation
errors = []
if not description:
errors.append('Description is required')
if severity not in ['low', 'medium', 'high', 'critical']:
errors.append('Invalid severity selected')
if errors:
return JsonResponse({
'success': False,
'errors': errors
}, status=400)
try:
# Get client info
def get_client_ip(req):
x_forwarded_for = req.META.get('HTTP_X_FORWARDED_FOR')
if x_forwarded_for:
ip = x_forwarded_for.split(',')[0].strip()
else:
ip = req.META.get('REMOTE_ADDR')
return ip
client_ip = get_client_ip(request)
user_agent = request.META.get('HTTP_USER_AGENT', '')
# Handle file uploads
attachments = request.FILES.getlist('attachments')
# Create observation using service
observation = ObservationService.create_observation(
description=description,
severity=severity,
category_id=category_id if category_id else None,
title=title,
location_text=location_text,
incident_datetime=incident_datetime if incident_datetime else None,
reporter_staff_id=reporter_staff_id,
reporter_name=reporter_name,
reporter_phone=reporter_phone,
reporter_email=reporter_email,
client_ip=client_ip,
user_agent=user_agent,
attachments=attachments,
)
return JsonResponse({
'success': True,
'tracking_code': observation.tracking_code,
'observation_id': str(observation.id)
})
except Exception as e:
return JsonResponse({
'success': False,
'errors': [str(e)]
}, status=500)

View File

@ -0,0 +1,19 @@
# Generated by Django 5.0.14 on 2026-01-06 11:55
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('organizations', '0002_hospital_metadata'),
]
operations = [
migrations.AddField(
model_name='patient',
name='department',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='organizations.department'),
),
]

View File

@ -0,0 +1,17 @@
# Generated by Django 5.0.14 on 2026-01-06 11:56
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('organizations', '0003_patient_department'),
]
operations = [
migrations.RemoveField(
model_name='patient',
name='department',
),
]

View File

@ -11,16 +11,19 @@ from drf_spectacular.views import SpectacularAPIView, SpectacularRedocView, Spec
urlpatterns = [
# Language switching
path('i18n/', include('django.conf.urls.i18n')),
# Admin
path('admin/', admin.site.urls),
# Dashboard
path('', include('apps.dashboard.urls')),
# Health check endpoint
path('health/', include('apps.core.urls')),
# Core pages (public submissions, hospital selection)
path('core/', include('apps.core.urls')),
# UI Pages
path('complaints/', include('apps.complaints.urls')),
path('feedback/', include('apps.feedback.urls')),
@ -37,14 +40,14 @@ urlpatterns = [
path('ai-engine/', include('apps.ai_engine.urls')),
path('appreciation/', include('apps.appreciation.urls', namespace='appreciation')),
path('observations/', include('apps.observations.urls', namespace='observations')),
# API endpoints
path('api/auth/', include('apps.accounts.urls')),
path('api/physicians/', include('apps.physicians.urls')),
path('api/integrations/', include('apps.integrations.urls')),
path('api/notifications/', include('apps.notifications.urls')),
path('api/v1/appreciation/', include('apps.appreciation.urls', namespace='api_appreciation')),
# OpenAPI/Swagger documentation
path('api/schema/', SpectacularAPIView.as_view(), name='schema'),
path('api/docs/', SpectacularSwaggerView.as_view(url_name='schema'), name='swagger-ui'),

View File

@ -1588,34 +1588,36 @@ def main():
print("="*60 + "\n")
# Clear existing data first
clear_existing_data()
# clear_existing_data()
# Create base data
hospitals = create_hospitals()
# hospitals = create_hospitals()
hospitals = Hospital.objects.all()
departments = create_departments(hospitals)
staff = create_staff(hospitals, departments)
patients = create_patients(hospitals)
create_users(hospitals)
# create_users(hospitals)
# Get all users for assignments
users = list(User.objects.all())
# users = list(User.objects.all())
# Create complaint categories first
categories = create_complaint_categories(hospitals)
# categories = create_complaint_categories(hospitals)
# Create operational data
complaints = create_complaints(patients, hospitals, physicians, users)
inquiries = create_inquiries(patients, hospitals, users)
feedbacks = create_feedback(patients, hospitals, physicians, users)
create_survey_templates(hospitals)
create_journey_templates(hospitals)
projects = create_qi_projects(hospitals)
actions = create_px_actions(complaints, hospitals, users)
journey_instances = create_journey_instances(None, patients)
survey_instances = create_survey_instances(None, patients, physicians)
call_interactions = create_call_center_interactions(patients, hospitals, users)
social_mentions = create_social_mentions(hospitals)
physician_ratings = create_physician_monthly_ratings(physicians)
# complaints = create_complaints(patients, hospitals, physicians, users)
# inquiries = create_inquiries(patients, hospitals, users)
# feedbacks = create_feedback(patients, hospitals, physicians, users)
# create_survey_templates(hospitals)
# create_journey_templates(hospitals)
# projects = create_qi_projects(hospitals)
# actions = create_px_actions(complaints, hospitals, users)
# journey_instances = create_journey_instances(None, patients)
# survey_instances = create_survey_instances(None, patients, physicians)
# call_interactions = create_call_center_interactions(patients, hospitals, users)
# social_mentions = create_social_mentions(hospitals)
# physician_ratings = create_physician_monthly_ratings(physicians)
print("\n" + "="*60)
print("Data Generation Complete!")
@ -1625,21 +1627,21 @@ def main():
print(f" - {len(departments)} Departments")
print(f" - {len(staff)} Staff")
print(f" - {len(patients)} Patients")
print(f" - {len(users)} Users")
print(f" - {len(complaints)} Complaints (2 years)")
print(f" - {len(inquiries)} Inquiries (2 years)")
print(f" - {len(feedbacks)} Feedback Items")
print(f" - {len(actions)} PX Actions")
print(f" - {len(journey_instances)} Journey Instances")
print(f" - {len(survey_instances)} Survey Instances")
print(f" - {len(call_interactions)} Call Center Interactions")
print(f" - {len(social_mentions)} Social Media Mentions")
print(f" - {len(projects)} QI Projects")
print(f" - {len(staff_ratings)} Staff Monthly Ratings")
print(f" - {len(appreciations)} Appreciations (2 years)")
print(f" - {len(user_badges)} Badges Awarded")
print(f" - {len(appreciation_stats)} Appreciation Statistics")
print(f" - {len(observations)} Observations (2 years)")
# print(f" - {len(users)} Users")
# print(f" - {len(complaints)} Complaints (2 years)")
# print(f" - {len(inquiries)} Inquiries (2 years)")
# print(f" - {len(feedbacks)} Feedback Items")
# print(f" - {len(actions)} PX Actions")
# print(f" - {len(journey_instances)} Journey Instances")
# print(f" - {len(survey_instances)} Survey Instances")
# print(f" - {len(call_interactions)} Call Center Interactions")
# print(f" - {len(social_mentions)} Social Media Mentions")
# print(f" - {len(projects)} QI Projects")
# print(f" - {len(staff_ratings)} Staff Monthly Ratings")
# print(f" - {len(appreciations)} Appreciations (2 years)")
# print(f" - {len(user_badges)} Badges Awarded")
# print(f" - {len(appreciation_stats)} Appreciation Statistics")
# print(f" - {len(observations)} Observations (2 years)")
print(f"\nYou can now login with:")
print(f" Username: px_admin")
print(f" Password: admin123")

View File

@ -27,6 +27,7 @@ dependencies = [
"reportlab>=4.4.7",
"openpyxl>=3.1.5",
"litellm>=1.0.0",
"watchdog>=6.0.0",
]
[project.optional-dependencies]

View File

@ -257,6 +257,64 @@
</div>
{% endif %}
{% if complaint.department %}
<div class="row mb-3">
<div class="col-md-12">
<div class="info-label">{{ _("Department") }}</div>
<div class="info-value">
<div class="d-flex align-items-center">
<i class="bi bi-building me-2"></i>
<div>
<strong>{{ complaint.department.name_en }}</strong>
{% if complaint.department.name_ar %}
<span class="text-muted ms-2">({{ complaint.department.name_ar }})</span>
{% endif %}
</div>
{% if complaint.metadata.ai_analysis.old_department %}
<span class="badge bg-info ms-auto">
<i class="bi bi-robot me-1"></i>AI Mapped
</span>
{% endif %}
</div>
</div>
</div>
</div>
{% endif %}
{% if complaint.staff %}
<div class="row mb-3">
<div class="col-md-12">
<div class="info-label">{{ _("Staff Member") }}</div>
<div class="info-value">
<div class="d-flex align-items-center">
<i class="bi bi-person-badge me-2"></i>
<div>
<strong>{{ complaint.staff.first_name }} {{ complaint.staff.last_name }}</strong>
{% if complaint.staff.first_name_ar or complaint.staff.last_name_ar %}
<span class="text-muted ms-2">({{ complaint.staff.first_name_ar }} {{ complaint.staff.last_name_ar }})</span>
{% endif %}
{% if complaint.staff.job_title %}
<div class="text-muted">{{ complaint.staff.job_title }}</div>
{% endif %}
</div>
<span class="badge bg-success ms-auto">
<i class="bi bi-robot me-1"></i>AI Matched
</span>
</div>
{% if complaint.metadata.ai_analysis.extracted_staff_name %}
<small class="text-muted mt-1 d-block">
<i class="bi bi-lightning me-1"></i>
Extracted from complaint: "{{ complaint.metadata.ai_analysis.extracted_staff_name }}"
{% if complaint.metadata.ai_analysis.staff_confidence %}
(confidence: {{ complaint.metadata.ai_analysis.staff_confidence|floatformat:0 }}%)
{% endif %}
</small>
{% endif %}
</div>
</div>
</div>
{% endif %}
<hr>
<div class="mb-3">
@ -334,6 +392,9 @@
<i class="bi bi-lightning me-1 text-success"></i>
<small>{{ complaint.suggested_action }}</small>
</div>
<button type="button" class="btn btn-sm btn-primary mt-2" data-bs-toggle="modal" data-bs-target="#createActionModal">
<i class="bi bi-plus-circle me-1"></i> Create PX Action
</button>
</div>
{% endif %}
</div>
@ -669,7 +730,7 @@
<div class="modal-body">
<div class="alert alert-warning">
<i class="bi bi-exclamation-triangle me-2"></i>
{{ _("This will escalate the complaint to higher management")}}.
{{ _("This will escalate")}} complaint to higher management}}.
</div>
<div class="mb-3">
<label class="form-label">{% trans "Reason for Escalation" %}</label>
@ -687,4 +748,117 @@
</div>
</div>
</div>
<!-- Create PX Action Modal -->
<div class="modal fade" id="createActionModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">
<i class="bi bi-lightning-fill me-2"></i>Create PX Action from AI Suggestion
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="alert alert-info">
<i class="bi bi-info-circle me-2"></i>
This will create a PX Action based on the AI-suggested action for this complaint.
</div>
<div class="mb-3">
<label class="form-label">Auto-mapped Category</label>
<div class="input-group">
<span class="input-group-text">
<i class="bi bi-tag"></i>
</span>
<input type="text" id="autoMappedCategory" class="form-control" readonly
value="{% if complaint.category %}{{ complaint.category.name_en }}{% else %}Other{% endif %}">
</div>
<small class="text-muted">Category automatically mapped from complaint category</small>
</div>
<div class="mb-3">
<label class="form-label">Assign To (Optional)</label>
<select id="actionAssignTo" class="form-select">
<option value="">Leave unassigned</option>
{% for user_obj in assignable_users %}
<option value="{{ user_obj.id }}">{{ user_obj.get_full_name }}</option>
{% endfor %}
</select>
<small class="text-muted">If left unassigned, you can assign the action later.</small>
</div>
<div class="mb-0">
<label class="form-label">Action Description</label>
<div class="alert alert-success mb-0">
<small>{{ complaint.suggested_action }}</small>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" id="createActionBtn" class="btn btn-primary" onclick="createAction()">
<i class="bi bi-plus-circle me-1"></i>Create Action
</button>
</div>
</div>
</div>
</div>
<script>
function createAction() {
const assignTo = document.getElementById('actionAssignTo').value;
const btn = document.getElementById('createActionBtn');
const data = {};
if (assignTo) {
data.assigned_to = assignTo;
}
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Creating...';
fetch(`/api/complaints/{{ complaint.id }}/create_action_from_ai/`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': getCookie('csrftoken')
},
body: JSON.stringify(data)
})
.then(response => response.json())
.then(data => {
if (data.action_id) {
// Redirect to action detail page
window.location.href = `/actions/${data.action_id}/`;
} else {
alert('Error: ' + (data.error || 'Unknown error'));
btn.disabled = false;
btn.innerHTML = '<i class="bi bi-plus-circle me-1"></i>Create Action';
}
})
.catch(error => {
console.error('Error:', error);
alert('Failed to create action. Please try again.');
btn.disabled = false;
btn.innerHTML = '<i class="bi bi-plus-circle me-1"></i>Create Action';
});
}
function getCookie(name) {
let cookieValue = null;
if (document.cookie && document.cookie !== '') {
const cookies = document.cookie.split(';');
for (let i = 0; i < cookies.length; i++) {
const cookie = cookies[i].trim();
if (cookie.substring(0, name.length + 1) === (name + '=')) {
cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
break;
}
}
}
return cookieValue;
}
</script>
{% endblock %}

View File

@ -304,45 +304,44 @@
{% block extra_js %}
<script src="https://cdn.jsdelivr.net/npm/sweetalert2@11"></script>
<script>
$(document).ready(function() {
// Get CSRF token
function getCSRFToken() {
// Try to get from cookie first
const cookieValue = document.cookie
.split('; ')
.find(row => row.startsWith('csrftoken='))
?.split('=')[1];
// Store all categories data globally for easy access
let allCategories = [];
let currentLanguage = 'en';
if (cookieValue) {
return cookieValue;
}
// Get CSRF token
function getCSRFToken() {
// Try to get from cookie first
const cookieValue = document.cookie
.split('; ')
.find(row => row.startsWith('csrftoken='))
?.split('=')[1];
// Fallback to the hidden input
return $('[name="csrfmiddlewaretoken"]').val();
if (cookieValue) {
return cookieValue;
}
// Store all categories data globally for easy access
let allCategories = [];
let currentLanguage = 'en';
// Fallback to hidden input
return $('[name="csrfmiddlewaretoken"]').val();
}
// Get description based on current language
function getDescription(category) {
if (currentLanguage === 'ar' && category.description_ar) {
return category.description_ar;
}
return category.description_en || '';
// Get description based on current language
function getDescription(category) {
if (currentLanguage === 'ar' && category.description_ar) {
return category.description_ar;
}
return category.description_en || '';
}
// Get name based on current language
function getName(category) {
if (currentLanguage === 'ar' && category.name_ar) {
return category.name_ar;
}
return category.name_en;
// Get name based on current language
function getName(category) {
if (currentLanguage === 'ar' && category.name_ar) {
return category.name_ar;
}
return category.name_en;
}
// Load categories
function loadCategories() {
// Load categories
function loadCategories() {
$.ajax({
url: '{% url "complaints:api_load_categories" %}',
type: 'GET',
@ -369,8 +368,8 @@ $(document).ready(function() {
});
}
// Show category description
function showCategoryDescription(categoryId) {
// Show category description
function showCategoryDescription(categoryId) {
const category = allCategories.find(c => c.id === categoryId);
const descriptionDiv = $('#category_description');
const descriptionText = $('#category_description_text');
@ -383,8 +382,8 @@ $(document).ready(function() {
}
}
// Show subcategory description
function showSubcategoryDescription(subcategoryId) {
// Show subcategory description
function showSubcategoryDescription(subcategoryId) {
const subcategory = allCategories.find(c => c.id === subcategoryId);
const descriptionDiv = $('#subcategory_description');
const descriptionText = $('#subcategory_description_text');
@ -397,8 +396,8 @@ $(document).ready(function() {
}
}
// Load subcategories based on selected category
function loadSubcategories(categoryId) {
// Load subcategories based on selected category
function loadSubcategories(categoryId) {
if (!categoryId) {
$('#subcategory_container').hide();
$('#subcategory_description').hide();
@ -430,31 +429,34 @@ $(document).ready(function() {
}
}
// Initialize complaint form - called when form is loaded
function initializeComplaintForm() {
// Detect current language from HTML
const htmlLang = document.documentElement.lang;
if (htmlLang === 'ar') {
currentLanguage = 'ar';
}
// Initialize - load categories on page load
// Load categories immediately
loadCategories();
}
// Handle category change
$('#id_category').on('change', function() {
const categoryId = $(this).val();
loadSubcategories(categoryId);
showCategoryDescription(categoryId);
$('#subcategory_description').hide(); // Hide subcategory description when category changes
});
// Handle category change
$('#id_category').on('change', function() {
const categoryId = $(this).val();
loadSubcategories(categoryId);
showCategoryDescription(categoryId);
$('#subcategory_description').hide(); // Hide subcategory description when category changes
});
// Handle subcategory change
$('#id_subcategory').on('change', function() {
const subcategoryId = $(this).val();
showSubcategoryDescription(subcategoryId);
});
// Handle subcategory change
$('#id_subcategory').on('change', function() {
const subcategoryId = $(this).val();
showSubcategoryDescription(subcategoryId);
});
// Form submission
$('#public_complaint_form').on('submit', function(e) {
// Form submission
$('#public_complaint_form').on('submit', function(e) {
e.preventDefault();
const submitBtn = $('#submit_btn');
@ -508,6 +510,9 @@ $(document).ready(function() {
}
});
});
// Initialize form on page load
initializeComplaintForm();
});
</script>
{% endblock %}

View File

@ -0,0 +1,198 @@
{% load i18n %}
<!-- Inquiry Form -->
<div class="inquiry-form" id="inquiryFormContainer">
<div class="form-section">
<h3><i class="fas fa-question-circle"></i> {% trans "Inquiry Details" %}</h3>
<p class="text-muted">{% trans "Ask a question or request information. We'll get back to you within 24-48 hours." %}</p>
<!-- Contact Information -->
<div class="row mt-4">
<div class="col-md-6">
<div class="form-group">
<label for="inquiry_name">
{% trans "Name" %} <span class="required-mark">*</span>
</label>
<input type="text"
class="form-control"
id="inquiry_name"
name="name"
maxlength="200"
placeholder="{% trans 'Your full name' %}"
required>
</div>
</div>
<div class="col-md-6">
<div class="form-group">
<label for="inquiry_email">
{% trans "Email Address" %} <span class="required-mark">*</span>
</label>
<input type="email"
class="form-control"
id="inquiry_email"
name="email"
placeholder="your@email.com"
required>
</div>
</div>
</div>
<div class="row mt-3">
<div class="col-md-6">
<div class="form-group">
<label for="inquiry_phone">
{% trans "Phone Number" %} <span class="required-mark">*</span>
</label>
<input type="tel"
class="form-control"
id="inquiry_phone"
name="phone"
maxlength="20"
placeholder="{% trans 'Your phone number' %}"
required>
</div>
</div>
<div class="col-md-6">
<div class="form-group">
<label for="inquiry_hospital">
{% trans "Hospital" %} <span class="required-mark">*</span>
</label>
<select class="form-control"
id="inquiry_hospital"
name="hospital"
required>
<option value="">{% trans "Select Hospital" %}</option>
{% for hospital in hospitals %}
<option value="{{ hospital.id }}">{{ hospital.name }}</option>
{% endfor %}
</select>
</div>
</div>
</div>
<!-- Inquiry Details -->
<div class="form-group mt-4">
<label for="inquiry_category">
{% trans "Category" %} <span class="required-mark">*</span>
</label>
<select class="form-control"
id="inquiry_category"
name="category"
required>
<option value="">{% trans "Select Category" %}</option>
<option value="general">{% trans "General Inquiry" %}</option>
<option value="services">{% trans "Services Information" %}</option>
<option value="appointments">{% trans "Appointments" %}</option>
<option value="billing">{% trans "Billing & Insurance" %}</option>
<option value="medical">{% trans "Medical Records" %}</option>
<option value="other">{% trans "Other" %}</option>
</select>
</div>
<div class="form-group mt-3">
<label for="inquiry_subject">
{% trans "Subject" %} <span class="required-mark">*</span>
</label>
<input type="text"
class="form-control"
id="inquiry_subject"
name="subject"
maxlength="200"
placeholder="{% trans 'Brief summary of your inquiry' %}"
required>
</div>
<div class="form-group mt-3">
<label for="inquiry_message">
{% trans "Message" %} <span class="required-mark">*</span>
</label>
<textarea class="form-control"
id="inquiry_message"
name="message"
rows="6"
placeholder="{% trans 'Please provide details about your inquiry' %}"
required></textarea>
</div>
<!-- Submit -->
<div class="text-center mt-4">
<button type="submit" class="btn btn-primary btn-lg" id="inquirySubmitBtn">
<i class="fas fa-paper-plane"></i> {% trans "Submit Inquiry" %}
</button>
</div>
</div>
</div>
<script>
$(document).ready(function() {
// Get CSRF token
function getCSRFToken() {
const cookieValue = document.cookie
.split('; ')
.find(row => row.startsWith('csrftoken='))
?.split('=')[1];
return cookieValue || $('[name="csrfmiddlewaretoken"]').val();
}
// Inquiry form submission
$('#publicFormContainer').on('submit', '#inquiryFormContainer form', function(e) {
e.preventDefault();
const submitBtn = $('#inquirySubmitBtn');
const originalText = submitBtn.html();
submitBtn.prop('disabled', true).html(
'<span class="spinner"></span> {% trans "Submitting..." %}'
);
const formData = new FormData(this);
$.ajax({
url: '{% url "core:public_inquiry_submit" %}',
type: 'POST',
data: formData,
processData: false,
contentType: false,
headers: {
'X-CSRFToken': getCSRFToken()
},
success: function(response) {
if (response.success) {
$('#referenceNumber').text(response.reference_number);
$('#successModal').modal('show');
// Reset form
this.reset();
// Go back to landing page
$('#successModal').on('hidden.bs.modal', function() {
resetToSelection();
});
} else {
Swal.fire({
icon: 'error',
title: '{% trans "Error" %}',
text: response.message || '{% trans "Failed to submit inquiry. Please try again." %}'
});
}
},
error: function(xhr) {
let errorMessage = '{% trans "Failed to submit inquiry. Please try again." %}';
if (xhr.responseJSON && xhr.responseJSON.errors) {
errorMessage = xhr.responseJSON.errors.join('\n');
}
Swal.fire({
icon: 'error',
title: '{% trans "Error" %}',
text: errorMessage
});
},
complete: function() {
submitBtn.prop('disabled', false).html(originalText);
}
});
});
});
</script>

View File

@ -0,0 +1,948 @@
{% extends "layouts/public_base.html" %}
{% load i18n %}
{% block title %}{% trans "Submit Feedback" %}{% endblock %}
{% block extra_css %}
<style>
.public-submit-container {
max-width: 1200px;
margin: 0 auto;
padding: 2rem 0;
}
.header-section {
text-align: center;
margin-bottom: 3rem;
}
.header-section h1 {
color: #2c3e50;
font-size: 2.5rem;
font-weight: 700;
margin-bottom: 1rem;
}
.header-section p {
color: #6c757d;
font-size: 1.1rem;
}
.selection-cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 2rem;
margin-bottom: 3rem;
}
.selection-card {
background: white;
border-radius: 12px;
padding: 2.5rem;
text-align: center;
cursor: pointer;
transition: all 0.3s ease;
border: 2px solid transparent;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
.selection-card:hover {
transform: translateY(-5px);
box-shadow: 0 8px 15px rgba(0, 0, 0, 0.15);
border-color: #3498db;
}
.card-icon {
font-size: 4rem;
margin-bottom: 1.5rem;
}
.card-complaint .card-icon { color: #e74c3c; }
.card-observation .card-icon { color: #9b59b6; }
.card-inquiry .card-icon { color: #3498db; }
.card-title {
font-size: 1.5rem;
font-weight: 600;
color: #2c3e50;
margin-bottom: 1rem;
}
.card-description {
color: #6c757d;
line-height: 1.6;
}
.form-container {
display: none;
background: white;
border-radius: 12px;
padding: 2rem;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
.form-container.active {
display: block;
animation: fadeIn 0.3s ease-in;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
.back-button {
margin-bottom: 1.5rem;
}
.required-mark {
color: #dc3545;
}
.form-section {
background: white;
border-radius: 8px;
padding: 2rem;
margin-bottom: 2rem;
}
.form-section h3 {
color: #2c3e50;
margin-bottom: 1.5rem;
padding-bottom: 0.5rem;
border-bottom: 2px solid #3498db;
}
.track-links {
text-align: center;
margin-top: 3rem;
padding-top: 2rem;
border-top: 1px solid #dee2e6;
}
.track-links a {
color: #3498db;
text-decoration: none;
margin: 0 1rem;
display: inline-block;
}
.track-links a:hover {
text-decoration: underline;
}
.spinner {
display: inline-block;
width: 1rem;
height: 1rem;
border: 2px solid rgba(255,255,255,0.3);
border-radius: 50%;
border-top-color: white;
animation: spin 1s ease-in-out infinite;
margin-right: 0.5rem;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
[dir="rtl"] .spinner {
margin-right: 0;
margin-left: 0.5rem;
}
.loading-spinner {
text-align: center;
padding: 3rem;
}
.loading-spinner i {
font-size: 3rem;
color: #3498db;
animation: spin 1s linear infinite;
}
</style>
{% endblock %}
{% block content %}
<div class="public-submit-container">
<!-- Header -->
<div class="header-section">
<h1><i class="fas fa-comments"></i> {% trans "We Value Your Feedback" %}</h1>
<p>{% trans "Choose how you'd like to reach us and we'll respond promptly" %}</p>
</div>
<!-- Selection Cards -->
<div class="selection-cards" id="selectionCards">
<!-- Complaint Card -->
<div class="selection-card card-complaint" data-type="complaint">
<div class="card-icon">
<i class="fas fa-exclamation-circle"></i>
</div>
<h3 class="card-title">{% trans "Complaint" %}</h3>
<p class="card-description">
{% trans "Report issues with services, staff, or facilities. We take your concerns seriously and will investigate thoroughly." %}
</p>
</div>
<!-- Observation Card -->
<div class="selection-card card-observation" data-type="observation">
<div class="card-icon">
<i class="fas fa-eye"></i>
</div>
<h3 class="card-title">{% trans "Observation" %}</h3>
<p class="card-description">
{% trans "Report incidents or issues anonymously. Help us improve safety and quality by sharing what you've noticed." %}
</p>
</div>
<!-- Inquiry Card -->
<div class="selection-card card-inquiry" data-type="inquiry">
<div class="card-icon">
<i class="fas fa-question-circle"></i>
</div>
<h3 class="card-title">{% trans "Inquiry" %}</h3>
<p class="card-description">
{% trans "Ask questions or request information. Need help understanding services, appointments, or policies?" %}
</p>
</div>
</div>
<!-- Form Container -->
<div class="form-container" id="publicFormContainer">
<!-- Back Button -->
<div class="back-button">
<button class="btn btn-outline-secondary" onclick="resetToSelection()">
<i class="fas fa-arrow-left"></i> {% trans "Back to Selection" %}
</button>
</div>
<!-- Loading Spinner -->
<div class="loading-spinner" id="loadingSpinner" style="display: none;">
<i class="fas fa-circle-notch"></i>
<p class="mt-3">{% trans "Loading form..." %}</p>
</div>
<!-- Form Content (loaded via AJAX) -->
<div id="formContent"></div>
</div>
<!-- Track Links -->
<div class="track-links">
<p class="text-muted">{% trans "Already submitted something?" %}</p>
<a href="{% url 'complaints:public_complaint_success' 'lookup' %}">
<i class="fas fa-search me-1"></i>{% trans "Track Complaint" %}
</a>
<a href="{% url 'observations:observation_track' %}">
<i class="fas fa-search me-1"></i>{% trans "Track Observation" %}
</a>
</div>
</div>
<!-- Success Modal -->
<div class="modal fade" id="successModal" tabindex="-1" role="dialog" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered" role="document">
<div class="modal-content">
<div class="modal-body text-center" style="padding: 3rem;">
<i class="fas fa-check-circle" style="font-size: 5rem; color: #28a745; margin-bottom: 1.5rem;"></i>
<h3 style="margin-bottom: 1rem;">{% trans "Submitted Successfully!" %}</h3>
<p class="lead" style="margin-bottom: 1rem;">
{% trans "Your submission has been received and is being processed." %}
</p>
<p>
<strong>{% trans "Reference Number:" %}</strong>
<span id="referenceNumber" style="font-size: 1.5rem; color: #3498db;"></span>
</p>
<p class="text-muted">
{% trans "Please save this reference number for your records." %}
</p>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script src="https://cdn.jsdelivr.net/npm/sweetalert2@11"></script>
<script>
$(document).ready(function() {
let currentFormType = null;
// Handle card selection
$('.selection-card').on('click', function() {
const formType = $(this).data('type');
currentFormType = formType;
// Hide selection cards
$('#selectionCards').slideUp(300);
// Show form container
$('#publicFormContainer').fadeIn(300);
// Load the appropriate form
loadForm(formType);
});
// Load form via AJAX
function loadForm(formType) {
$('#loadingSpinner').show();
$('#formContent').hide();
let templateUrl = '';
let hospitals = [];
// Get hospitals for the form
$.ajax({
url: '/core/api/hospitals/',
type: 'GET',
async: false,
success: function(response) {
hospitals = response.hospitals || [];
}
});
if (formType === 'complaint') {
// Load complaint form via AJAX
$.ajax({
url: '{% url "complaints:public_complaint_submit" %}',
type: 'GET',
success: function(response) {
// Extract form from response
const formHtml = $('<div>').html(response).find('.complaint-form').html();
$('#formContent').html(formHtml);
// Initialize form functionality
if (typeof initializeComplaintForm === 'function') {
initializeComplaintForm();
}
showForm();
},
error: function() {
$('#loadingSpinner').hide();
Swal.fire({
icon: 'error',
title: '{% trans "Error" %}',
text: '{% trans "Failed to load form. Please try again." %}'
});
}
});
} else if (formType === 'observation') {
// Render observation form inline
fetchCategoriesAndRenderObservationForm();
} else if (formType === 'inquiry') {
// Render inquiry form template with context
renderInquiryForm(hospitals);
}
}
function fetchCategoriesAndRenderObservationForm() {
// Fetch observation categories
$.ajax({
url: '/core/api/observation-categories/',
type: 'GET',
success: function(response) {
renderObservationForm(response.categories || []);
},
error: function() {
$('#loadingSpinner').hide();
Swal.fire({
icon: 'error',
title: '{% trans "Error" %}',
text: '{% trans "Failed to load observation form. Please try again." %}'
});
}
});
}
function renderObservationForm(categories) {
// Build observation categories options
let categoriesOptions = '<option value="">{% trans "Select Category" %} ({% trans "optional" %})</option>';
categories.forEach(function(category) {
const lang = '{{ LANGUAGE_CODE }}';
const name = lang === 'ar' ? category.name_ar : category.name_en;
categoriesOptions += `<option value="${category.id}">${name}</option>`;
});
const formHtml = `
<form id="observationForm" enctype="multipart/form-data">
{% csrf_token %}
<div class="form-section">
<h3><i class="fas fa-eye"></i> {% trans "Report an Observation" %}</h3>
<p class="text-muted">{% trans "Help us improve by reporting issues you notice. You can submit this anonymously." %}</p>
<!-- Category -->
<div class="form-group">
<label>{% trans "Category" %} <span class="text-muted">({% trans "optional" %})</span></label>
<select class="form-control" name="category">
${categoriesOptions}
</select>
</div>
<!-- Severity -->
<div class="form-group mt-4">
<label>{% trans "Severity" %} <span class="required-mark">*</span></label>
<div class="row g-2 mt-1">
<div class="col-6 col-md-3">
<div class="severity-option severity-low p-3 text-center border rounded cursor-pointer" data-value="low">
<i class="fas fa-info-circle text-success"></i>
<div class="small mt-1">{% trans "Low" %}</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="severity-option severity-medium p-3 text-center border rounded cursor-pointer selected" data-value="medium">
<i class="fas fa-exclamation-circle text-warning"></i>
<div class="small mt-1">{% trans "Medium" %}</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="severity-option severity-high p-3 text-center border rounded cursor-pointer" data-value="high">
<i class="fas fa-exclamation-triangle text-danger"></i>
<div class="small mt-1">{% trans "High" %}</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="severity-option severity-critical p-3 text-center border rounded cursor-pointer" data-value="critical">
<i class="fas fa-exclamation-circle text-dark"></i>
<div class="small mt-1">{% trans "Critical" %}</div>
</div>
</div>
</div>
<input type="hidden" name="severity" id="severityInput" value="medium">
</div>
<!-- Title -->
<div class="form-group mt-4">
<label>{% trans "Title" %} <span class="text-muted">({% trans "optional" %})</span></label>
<input type="text" class="form-control" name="title" maxlength="200">
</div>
<!-- Description -->
<div class="form-group mt-4">
<label>{% trans "Description" %} <span class="required-mark">*</span></label>
<textarea class="form-control" name="description" rows="5" required></textarea>
<small class="text-muted">{% trans "Please describe what you observed in detail." %}</small>
</div>
<!-- Location -->
<div class="form-group mt-4">
<label>{% trans "Location" %} <span class="text-muted">({% trans "optional" %})</span></label>
<input type="text" class="form-control" name="location_text" maxlength="200">
</div>
<!-- Incident Date/Time -->
<div class="form-group mt-4">
<label>{% trans "When did this occur?" %}</label>
<input type="datetime-local" class="form-control" name="incident_datetime">
</div>
<!-- Attachments -->
<div class="form-group mt-4">
<label>{% trans "Attachments" %} <span class="text-muted">({% trans "optional" %})</span></label>
<div class="file-upload-area border-2 border-dashed rounded p-4 text-center cursor-pointer" onclick="document.getElementById('observationAttachments').click()">
<i class="fas fa-cloud-upload-alt fa-2x text-primary"></i>
<p class="mb-0 mt-2">{% trans "Click to upload files" %}</p>
<small class="text-muted">{% trans "Images, PDF, Word, Excel (max 10MB each)" %}</small>
</div>
<input type="file" id="observationAttachments" name="attachments" multiple
accept=".jpg,.jpeg,.png,.gif,.pdf,.doc,.docx,.xls,.xlsx"
style="display: none;" onchange="updateObservationFileList()">
<div id="observationFileList" class="mt-2"></div>
</div>
<!-- Reporter Information (Optional) -->
<div class="form-group mt-4">
<label>{% trans "Your Information" %} <span class="text-muted">({% trans "optional" %})</span></label>
<p class="text-muted small">{% trans "Providing your information helps us follow up if needed. Leave blank to submit anonymously." %}</p>
<div class="row">
<div class="col-md-6">
<div class="form-group">
<label>{% trans "Staff ID" %}</label>
<input type="text" class="form-control" name="reporter_staff_id">
</div>
</div>
<div class="col-md-6">
<div class="form-group">
<label>{% trans "Name" %}</label>
<input type="text" class="form-control" name="reporter_name">
</div>
</div>
</div>
<div class="row mt-3">
<div class="col-md-6">
<div class="form-group">
<label>{% trans "Phone" %}</label>
<input type="tel" class="form-control" name="reporter_phone">
</div>
</div>
<div class="col-md-6">
<div class="form-group">
<label>{% trans "Email" %}</label>
<input type="email" class="form-control" name="reporter_email">
</div>
</div>
</div>
</div>
<!-- Submit -->
<div class="text-center mt-4">
<button type="submit" class="btn btn-primary btn-lg" id="observationSubmitBtn">
<i class="fas fa-paper-plane"></i> {% trans "Submit Observation" %}
</button>
</div>
</div>
</form>
`;
$('#formContent').html(formHtml);
// Initialize severity selection
$('#formContent').on('click', '.severity-option', function() {
$('#formContent').find('.severity-option').removeClass('selected border-primary bg-light');
$(this).addClass('selected border-primary bg-light');
$('#severityInput').val($(this).data('value'));
});
// Attach form submission handler
$('#observationForm').on('submit', handleObservationSubmit);
showForm();
}
function updateObservationFileList() {
const input = document.getElementById('observationAttachments');
const fileList = document.getElementById('observationFileList');
fileList.innerHTML = '';
if (input.files.length > 0) {
const list = document.createElement('ul');
list.className = 'list-unstyled mb-0';
for (let i = 0; i < input.files.length; i++) {
const li = document.createElement('li');
li.className = 'small text-muted';
li.innerHTML = '<i class="fas fa-file me-1"></i>' + input.files[i].name;
list.appendChild(li);
}
fileList.appendChild(list);
}
}
function handleObservationSubmit(e) {
e.preventDefault();
const submitBtn = $('#observationSubmitBtn');
const originalText = submitBtn.html();
submitBtn.prop('disabled', true).html(
'<span class="spinner"></span> {% trans "Submitting..." %}'
);
const formData = new FormData(this);
$.ajax({
url: '/core/public/observation/submit/',
type: 'POST',
data: formData,
processData: false,
contentType: false,
headers: {
'X-CSRFToken': getCSRFToken()
},
success: function(response) {
if (response.success) {
$('#referenceNumber').text(response.tracking_code);
$('#successModal').modal('show');
// Reset and go back
document.getElementById('observationForm').reset();
$('#successModal').on('hidden.bs.modal', function() {
resetToSelection();
});
} else {
Swal.fire({
icon: 'error',
title: '{% trans "Error" %}',
text: response.errors ? response.errors.join('\\n') : '{% trans "Failed to submit. Please try again." %}'
});
}
},
error: function(xhr) {
let errorMessage = '{% trans "Failed to submit. Please try again." %}';
if (xhr.responseJSON && xhr.responseJSON.errors) {
errorMessage = xhr.responseJSON.errors.join('\\n');
}
Swal.fire({
icon: 'error',
title: '{% trans "Error" %}',
text: errorMessage
});
},
complete: function() {
submitBtn.prop('disabled', false).html(originalText);
}
});
}
function renderInquiryForm(hospitals) {
// Build the inquiry form HTML
let hospitalsOptions = '<option value="">{% trans "Select Hospital" %}</option>';
hospitals.forEach(function(hospital) {
hospitalsOptions += `<option value="${hospital.id}">${hospital.name}</option>`;
});
const formHtml = `
<form id="inquiryForm">
{% csrf_token %}
<div class="form-section">
<h3><i class="fas fa-question-circle"></i> {% trans "Inquiry Details" %}</h3>
<p class="text-muted">{% trans "Ask a question or request information. We'll get back to you within 24-48 hours." %}</p>
<div class="row mt-4">
<div class="col-md-6">
<div class="form-group">
<label>{% trans "Name" %} <span class="required-mark">*</span></label>
<input type="text" class="form-control" name="name" required>
</div>
</div>
<div class="col-md-6">
<div class="form-group">
<label>{% trans "Email Address" %} <span class="required-mark">*</span></label>
<input type="email" class="form-control" name="email" required>
</div>
</div>
</div>
<div class="row mt-3">
<div class="col-md-6">
<div class="form-group">
<label>{% trans "Phone Number" %} <span class="required-mark">*</span></label>
<input type="tel" class="form-control" name="phone" maxlength="20" required>
</div>
</div>
<div class="col-md-6">
<div class="form-group">
<label>{% trans "Hospital" %} <span class="required-mark">*</span></label>
<select class="form-control" name="hospital" required>
${hospitalsOptions}
</select>
</div>
</div>
</div>
<div class="form-group mt-4">
<label>{% trans "Category" %} <span class="required-mark">*</span></label>
<select class="form-control" name="category" required>
<option value="">{% trans "Select Category" %}</option>
<option value="general">{% trans "General Information" %}</option>
<option value="appointment">{% trans "Appointment" %}</option>
<option value="billing">{% trans "Billing" %}</option>
<option value="medical_records">{% trans "Medical Records" %}</option>
<option value="other">{% trans "Other" %}</option>
</select>
</div>
<div class="form-group mt-3">
<label>{% trans "Subject" %} <span class="required-mark">*</span></label>
<input type="text" class="form-control" name="subject" maxlength="200" required>
</div>
<div class="form-group mt-3">
<label>{% trans "Message" %} <span class="required-mark">*</span></label>
<textarea class="form-control" name="message" rows="6" required></textarea>
</div>
<div class="text-center mt-4">
<button type="submit" class="btn btn-primary btn-lg" id="inquirySubmitBtn">
<i class="fas fa-paper-plane"></i> {% trans "Submit Inquiry" %}
</button>
</div>
</div>
</form>
`;
$('#formContent').html(formHtml);
// Attach form submission handler
$('#inquiryForm').on('submit', handleInquirySubmit);
showForm();
}
function showForm() {
$('#loadingSpinner').hide();
$('#formContent').fadeIn(300);
}
// ============================================================================
// COMPLAINT FORM FUNCTIONS
// ============================================================================
let complaintAllCategories = [];
let complaintCurrentLanguage = 'en';
// Get complaint description based on current language
function getComplaintDescription(category) {
if (complaintCurrentLanguage === 'ar' && category.description_ar) {
return category.description_ar;
}
return category.description_en || '';
}
// Get complaint category name based on current language
function getComplaintName(category) {
if (complaintCurrentLanguage === 'ar' && category.name_ar) {
return category.name_ar;
}
return category.name_en;
}
// Load complaint categories
function loadComplaintCategories() {
$.ajax({
url: '{% url "complaints:api_load_categories" %}',
type: 'GET',
success: function(response) {
// Store all categories
complaintAllCategories = response.categories;
const categorySelect = $('#id_category');
categorySelect.find('option:not(:first)').remove();
// Only show parent categories (no parent_id)
complaintAllCategories.forEach(function(category) {
if (!category.parent_id) {
categorySelect.append($('<option>', {
value: category.id,
text: getComplaintName(category)
}));
}
});
},
error: function() {
console.error('Failed to load complaint categories');
}
});
}
// Show complaint category description
function showComplaintCategoryDescription(categoryId) {
const category = complaintAllCategories.find(c => c.id === categoryId);
const descriptionDiv = $('#category_description');
const descriptionText = $('#category_description_text');
if (category && getComplaintDescription(category)) {
descriptionText.text(getComplaintDescription(category));
descriptionDiv.show();
} else {
descriptionDiv.hide();
}
}
// Show complaint subcategory description
function showComplaintSubcategoryDescription(subcategoryId) {
const subcategory = complaintAllCategories.find(c => c.id === subcategoryId);
const descriptionDiv = $('#subcategory_description');
const descriptionText = $('#subcategory_description_text');
if (subcategory && getComplaintDescription(subcategory)) {
descriptionText.text(getComplaintDescription(subcategory));
descriptionDiv.show();
} else {
descriptionDiv.hide();
}
}
// Load complaint subcategories based on selected category
function loadComplaintSubcategories(categoryId) {
if (!categoryId) {
$('#subcategory_container').hide();
$('#subcategory_description').hide();
$('#id_subcategory').find('option:not(:first)').remove();
$('#id_subcategory').prop('required', false);
return;
}
const subcategorySelect = $('#id_subcategory');
subcategorySelect.find('option:not(:first)').remove();
// Filter subcategories for this parent category (match parent_id)
complaintAllCategories.forEach(function(category) {
if (category.parent_id == categoryId) {
subcategorySelect.append($('<option>', {
value: category.id,
text: getComplaintName(category)
}));
}
});
if (subcategorySelect.find('option').length > 1) {
$('#subcategory_container').show();
$('#id_subcategory').prop('required', true);
} else {
$('#subcategory_container').hide();
$('#subcategory_description').hide();
$('#id_subcategory').prop('required', false);
}
}
// Initialize complaint form - called when form is loaded via AJAX
function initializeComplaintForm() {
// Detect current language from HTML
const htmlLang = document.documentElement.lang;
if (htmlLang === 'ar') {
complaintCurrentLanguage = 'ar';
}
// Load categories immediately
loadComplaintCategories();
// Attach event handlers for complaint form
$('#formContent').on('change', '#id_category', function() {
const categoryId = $(this).val();
loadComplaintSubcategories(categoryId);
showComplaintCategoryDescription(categoryId);
$('#subcategory_description').hide();
});
$('#formContent').on('change', '#id_subcategory', function() {
const subcategoryId = $(this).val();
showComplaintSubcategoryDescription(subcategoryId);
});
$('#formContent').on('submit', '#public_complaint_form', function(e) {
e.preventDefault();
const submitBtn = $('#submit_btn');
const originalText = submitBtn.html();
submitBtn.prop('disabled', true).html(
'<span class="spinner"></span> {% trans "Submitting..." %}'
);
const formData = new FormData(this);
$.ajax({
url: '{% url "complaints:public_complaint_submit" %}',
type: 'POST',
data: formData,
processData: false,
contentType: false,
headers: {
'X-CSRFToken': getCSRFToken()
},
success: function(response) {
if (response.success) {
$('#referenceNumber').text(response.reference_number);
$('#successModal').modal('show');
// Reset form
document.getElementById('public_complaint_form').reset();
$('#successModal').on('hidden.bs.modal', function() {
resetToSelection();
});
} else {
Swal.fire({
icon: 'error',
title: '{% trans "Error" %}',
text: response.message || '{% trans "Failed to submit complaint. Please try again." %}'
});
}
},
error: function(xhr) {
let errorMessage = '{% trans "Failed to submit complaint. Please try again." %}';
if (xhr.responseJSON && xhr.responseJSON.errors) {
errorMessage = xhr.responseJSON.errors.join('\n');
}
Swal.fire({
icon: 'error',
title: '{% trans "Error" %}',
text: errorMessage
});
},
complete: function() {
submitBtn.prop('disabled', false).html(originalText);
}
});
});
}
function handleInquirySubmit(e) {
e.preventDefault();
const submitBtn = $('#inquirySubmitBtn');
const originalText = submitBtn.html();
submitBtn.prop('disabled', true).html(
'<span class="spinner"></span> {% trans "Submitting..." %}'
);
const formData = new FormData(this);
$.ajax({
url: '{% url "core:public_inquiry_submit" %}',
type: 'POST',
data: formData,
processData: false,
contentType: false,
headers: {
'X-CSRFToken': getCSRFToken()
},
success: function(response) {
if (response.success) {
$('#referenceNumber').text(response.reference_number);
$('#successModal').modal('show');
// Reset and go back
document.getElementById('inquiryForm').reset();
$('#successModal').on('hidden.bs.modal', function() {
resetToSelection();
});
} else {
Swal.fire({
icon: 'error',
title: '{% trans "Error" %}',
text: response.message || '{% trans "Failed to submit. Please try again." %}'
});
}
},
error: function(xhr) {
let errorMessage = '{% trans "Failed to submit. Please try again." %}';
if (xhr.responseJSON && xhr.responseJSON.errors) {
errorMessage = xhr.responseJSON.errors.join('\\n');
}
Swal.fire({
icon: 'error',
title: '{% trans "Error" %}',
text: errorMessage
});
},
complete: function() {
submitBtn.prop('disabled', false).html(originalText);
}
});
}
function getCSRFToken() {
const cookieValue = document.cookie
.split('; ')
.find(row => row.startsWith('csrftoken='))
?.split('=')[1];
return cookieValue || $('[name="csrfmiddlewaretoken"]').val();
}
// Global function to reset to selection
window.resetToSelection = function() {
$('#publicFormContainer').fadeOut(300);
setTimeout(function() {
$('#formContent').empty();
$('#selectionCards').slideDown(300);
}, 300);
currentFormType = null;
};
});
</script>
{% endblock %}

26
uv.lock generated
View File

@ -1675,6 +1675,7 @@ dependencies = [
{ name = "redis" },
{ name = "reportlab" },
{ name = "rich" },
{ name = "watchdog" },
{ name = "whitenoise" },
]
@ -1712,6 +1713,7 @@ requires-dist = [
{ name = "reportlab", specifier = ">=4.4.7" },
{ name = "rich", specifier = ">=14.2.0" },
{ name = "ruff", marker = "extra == 'dev'", specifier = ">=0.1.0" },
{ name = "watchdog", specifier = ">=6.0.0" },
{ name = "whitenoise", specifier = ">=6.6.0" },
]
provides-extras = ["dev"]
@ -2434,6 +2436,30 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/03/ff/7c0c86c43b3cbb927e0ccc0255cb4057ceba4799cd44ae95174ce8e8b5b2/vine-5.1.0-py3-none-any.whl", hash = "sha256:40fdf3c48b2cfe1c38a49e9ae2da6fda88e4794c810050a728bd7413811fb1dc", size = 9636, upload-time = "2023-11-05T08:46:51.205Z" },
]
[[package]]
name = "watchdog"
version = "6.0.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/db/7d/7f3d619e951c88ed75c6037b246ddcf2d322812ee8ea189be89511721d54/watchdog-6.0.0.tar.gz", hash = "sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282", size = 131220, upload-time = "2024-11-01T14:07:13.037Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/39/ea/3930d07dafc9e286ed356a679aa02d777c06e9bfd1164fa7c19c288a5483/watchdog-6.0.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bdd4e6f14b8b18c334febb9c4425a878a2ac20efd1e0b231978e7b150f92a948", size = 96471, upload-time = "2024-11-01T14:06:37.745Z" },
{ url = "https://files.pythonhosted.org/packages/12/87/48361531f70b1f87928b045df868a9fd4e253d9ae087fa4cf3f7113be363/watchdog-6.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c7c15dda13c4eb00d6fb6fc508b3c0ed88b9d5d374056b239c4ad1611125c860", size = 88449, upload-time = "2024-11-01T14:06:39.748Z" },
{ url = "https://files.pythonhosted.org/packages/5b/7e/8f322f5e600812e6f9a31b75d242631068ca8f4ef0582dd3ae6e72daecc8/watchdog-6.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6f10cb2d5902447c7d0da897e2c6768bca89174d0c6e1e30abec5421af97a5b0", size = 89054, upload-time = "2024-11-01T14:06:41.009Z" },
{ url = "https://files.pythonhosted.org/packages/68/98/b0345cabdce2041a01293ba483333582891a3bd5769b08eceb0d406056ef/watchdog-6.0.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:490ab2ef84f11129844c23fb14ecf30ef3d8a6abafd3754a6f75ca1e6654136c", size = 96480, upload-time = "2024-11-01T14:06:42.952Z" },
{ url = "https://files.pythonhosted.org/packages/85/83/cdf13902c626b28eedef7ec4f10745c52aad8a8fe7eb04ed7b1f111ca20e/watchdog-6.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:76aae96b00ae814b181bb25b1b98076d5fc84e8a53cd8885a318b42b6d3a5134", size = 88451, upload-time = "2024-11-01T14:06:45.084Z" },
{ url = "https://files.pythonhosted.org/packages/fe/c4/225c87bae08c8b9ec99030cd48ae9c4eca050a59bf5c2255853e18c87b50/watchdog-6.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a175f755fc2279e0b7312c0035d52e27211a5bc39719dd529625b1930917345b", size = 89057, upload-time = "2024-11-01T14:06:47.324Z" },
{ url = "https://files.pythonhosted.org/packages/a9/c7/ca4bf3e518cb57a686b2feb4f55a1892fd9a3dd13f470fca14e00f80ea36/watchdog-6.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7607498efa04a3542ae3e05e64da8202e58159aa1fa4acddf7678d34a35d4f13", size = 79079, upload-time = "2024-11-01T14:06:59.472Z" },
{ url = "https://files.pythonhosted.org/packages/5c/51/d46dc9332f9a647593c947b4b88e2381c8dfc0942d15b8edc0310fa4abb1/watchdog-6.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:9041567ee8953024c83343288ccc458fd0a2d811d6a0fd68c4c22609e3490379", size = 79078, upload-time = "2024-11-01T14:07:01.431Z" },
{ url = "https://files.pythonhosted.org/packages/d4/57/04edbf5e169cd318d5f07b4766fee38e825d64b6913ca157ca32d1a42267/watchdog-6.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:82dc3e3143c7e38ec49d61af98d6558288c415eac98486a5c581726e0737c00e", size = 79076, upload-time = "2024-11-01T14:07:02.568Z" },
{ url = "https://files.pythonhosted.org/packages/ab/cc/da8422b300e13cb187d2203f20b9253e91058aaf7db65b74142013478e66/watchdog-6.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:212ac9b8bf1161dc91bd09c048048a95ca3a4c4f5e5d4a7d1b1a7d5752a7f96f", size = 79077, upload-time = "2024-11-01T14:07:03.893Z" },
{ url = "https://files.pythonhosted.org/packages/2c/3b/b8964e04ae1a025c44ba8e4291f86e97fac443bca31de8bd98d3263d2fcf/watchdog-6.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:e3df4cbb9a450c6d49318f6d14f4bbc80d763fa587ba46ec86f99f9e6876bb26", size = 79078, upload-time = "2024-11-01T14:07:05.189Z" },
{ url = "https://files.pythonhosted.org/packages/62/ae/a696eb424bedff7407801c257d4b1afda455fe40821a2be430e173660e81/watchdog-6.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:2cce7cfc2008eb51feb6aab51251fd79b85d9894e98ba847408f662b3395ca3c", size = 79077, upload-time = "2024-11-01T14:07:06.376Z" },
{ url = "https://files.pythonhosted.org/packages/b5/e8/dbf020b4d98251a9860752a094d09a65e1b436ad181faf929983f697048f/watchdog-6.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:20ffe5b202af80ab4266dcd3e91aae72bf2da48c0d33bdb15c66658e685e94e2", size = 79078, upload-time = "2024-11-01T14:07:07.547Z" },
{ url = "https://files.pythonhosted.org/packages/07/f6/d0e5b343768e8bcb4cda79f0f2f55051bf26177ecd5651f84c07567461cf/watchdog-6.0.0-py3-none-win32.whl", hash = "sha256:07df1fdd701c5d4c8e55ef6cf55b8f0120fe1aef7ef39a1c6fc6bc2e606d517a", size = 79065, upload-time = "2024-11-01T14:07:09.525Z" },
{ url = "https://files.pythonhosted.org/packages/db/d9/c495884c6e548fce18a8f40568ff120bc3a4b7b99813081c8ac0c936fa64/watchdog-6.0.0-py3-none-win_amd64.whl", hash = "sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680", size = 79070, upload-time = "2024-11-01T14:07:10.686Z" },
{ url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067, upload-time = "2024-11-01T14:07:11.845Z" },
]
[[package]]
name = "wcwidth"
version = "0.2.14"