From 97de5919f2cdaf983a24ec7d9fea3d0ae439246e Mon Sep 17 00:00:00 2001 From: ismail Date: Thu, 8 Jan 2026 09:50:43 +0300 Subject: [PATCH] add reference and standard --- PX360/settings.py | 3 +- apps/complaints/serializers.py | 12 +- apps/complaints/tasks.py | 210 +++-- apps/complaints/ui_views.py | 70 ++ apps/complaints/urls.py | 1 + apps/complaints/views.py | 414 +++++++++- apps/core/ai_service.py | 3 +- .../migrations/0002_add_missing_fields.py | 65 ++ ...observation_hospita_dcd21a_idx_and_more.py | 36 + apps/observations/models.py | 124 +-- .../management/commands/seed_staff.py | 406 ++++++++++ .../migrations/0005_alter_staff_department.py | 19 + apps/organizations/models.py | 2 +- apps/references/__init__.py | 4 + apps/references/admin.py | 120 +++ apps/references/apps.py | 11 + apps/references/forms.py | 256 ++++++ apps/references/migrations/0001_initial.py | 121 +++ apps/references/migrations/__init__.py | 1 + apps/references/models.py | 389 +++++++++ apps/references/ui_views.py | 526 +++++++++++++ apps/references/urls.py | 44 ++ apps/references/views.py | 270 +++++++ apps/standards/__init__.py | 1 + apps/standards/admin.py | 52 ++ apps/standards/apps.py | 7 + apps/standards/forms.py | 66 ++ apps/standards/migrations/0001_initial.py | 119 +++ apps/standards/migrations/__init__.py | 0 apps/standards/models.py | 165 ++++ apps/standards/serializers.py | 61 ++ apps/standards/templatetags/__init__.py | 0 .../templatetags/standards_filters.py | 50 ++ apps/standards/urls.py | 52 ++ apps/standards/views.py | 423 ++++++++++ config/settings/base.py | 2 + config/urls.py | 2 + docs/OBSERVATION_MODEL_FIXES.md | 168 ++++ docs/REFERENCES_IMPLEMENTATION.md | 416 ++++++++++ locale/en/LC_MESSAGES/django.po | 4 + templates/complaints/complaint_detail.html | 594 +++++++++++++- templates/complaints/complaint_form.html | 6 +- .../complaints/public_complaint_form.html | 42 +- templates/core/public_submit.html | 740 +++++++++++++----- templates/layouts/partials/sidebar.html | 18 + templates/layouts/public_base.html | 8 +- templates/references/dashboard.html | 162 ++++ templates/references/document_form.html | 372 +++++++++ templates/references/document_view.html | 213 +++++ templates/references/folder_form.html | 256 ++++++ templates/references/folder_view.html | 176 +++++ templates/references/search.html | 195 +++++ templates/standards/attachment_upload.html | 142 ++++ templates/standards/compliance_form.html | 164 ++++ templates/standards/dashboard.html | 156 ++++ templates/standards/department_standards.html | 296 +++++++ templates/standards/search.html | 129 +++ templates/standards/standard_detail.html | 170 ++++ templates/standards/standard_form.html | 193 +++++ 59 files changed, 8395 insertions(+), 332 deletions(-) create mode 100644 apps/observations/migrations/0002_add_missing_fields.py create mode 100644 apps/observations/migrations/0003_rename_obs_hospital_status_idx_observation_hospita_dcd21a_idx_and_more.py create mode 100644 apps/organizations/management/commands/seed_staff.py create mode 100644 apps/organizations/migrations/0005_alter_staff_department.py create mode 100644 apps/references/__init__.py create mode 100644 apps/references/admin.py create mode 100644 apps/references/apps.py create mode 100644 apps/references/forms.py create mode 100644 apps/references/migrations/0001_initial.py create mode 100644 apps/references/migrations/__init__.py create mode 100644 apps/references/models.py create mode 100644 apps/references/ui_views.py create mode 100644 apps/references/urls.py create mode 100644 apps/references/views.py create mode 100644 apps/standards/__init__.py create mode 100644 apps/standards/admin.py create mode 100644 apps/standards/apps.py create mode 100644 apps/standards/forms.py create mode 100644 apps/standards/migrations/0001_initial.py create mode 100644 apps/standards/migrations/__init__.py create mode 100644 apps/standards/models.py create mode 100644 apps/standards/serializers.py create mode 100644 apps/standards/templatetags/__init__.py create mode 100644 apps/standards/templatetags/standards_filters.py create mode 100644 apps/standards/urls.py create mode 100644 apps/standards/views.py create mode 100644 docs/OBSERVATION_MODEL_FIXES.md create mode 100644 docs/REFERENCES_IMPLEMENTATION.md create mode 100644 templates/references/dashboard.html create mode 100644 templates/references/document_form.html create mode 100644 templates/references/document_view.html create mode 100644 templates/references/folder_form.html create mode 100644 templates/references/folder_view.html create mode 100644 templates/references/search.html create mode 100644 templates/standards/attachment_upload.html create mode 100644 templates/standards/compliance_form.html create mode 100644 templates/standards/dashboard.html create mode 100644 templates/standards/department_standards.html create mode 100644 templates/standards/search.html create mode 100644 templates/standards/standard_detail.html create mode 100644 templates/standards/standard_form.html diff --git a/PX360/settings.py b/PX360/settings.py index f3b4643..4dcfe52 100644 --- a/PX360/settings.py +++ b/PX360/settings.py @@ -121,4 +121,5 @@ STATIC_URL = 'static/' OPENROUTER_API_KEY = "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" -AI_MODEL = "openrouter/xiaomi/mimo-v2-flash:free" \ No newline at end of file +AI_MODEL = "openrouter/z-ai/glm-4.7" +# AI_MODEL = "openrouter/xiaomi/mimo-v2-flash:free" \ No newline at end of file diff --git a/apps/complaints/serializers.py b/apps/complaints/serializers.py index ee8c0bc..0996228 100644 --- a/apps/complaints/serializers.py +++ b/apps/complaints/serializers.py @@ -53,7 +53,7 @@ class ComplaintSerializer(serializers.ModelSerializer): patient_mrn = serializers.CharField(source='patient.mrn', read_only=True) hospital_name = serializers.CharField(source='hospital.name', read_only=True) department_name = serializers.CharField(source='department.name', read_only=True) - physician_name = serializers.SerializerMethodField() + staff_name = serializers.SerializerMethodField() assigned_to_name = serializers.SerializerMethodField() attachments = ComplaintAttachmentSerializer(many=True, read_only=True) updates = ComplaintUpdateSerializer(many=True, read_only=True) @@ -64,7 +64,7 @@ class ComplaintSerializer(serializers.ModelSerializer): fields = [ 'id', 'patient', 'patient_name', 'patient_mrn', 'encounter_id', 'hospital', 'hospital_name', 'department', 'department_name', - 'physician', 'physician_name', + 'staff', 'staff_name', 'title', 'description', 'category', 'subcategory', 'priority', 'severity', 'source', 'status', 'assigned_to', 'assigned_to_name', 'assigned_at', @@ -140,10 +140,10 @@ class ComplaintSerializer(serializers.ModelSerializer): # 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() + def get_staff_name(self, obj): + """Get staff name""" + if obj.staff: + return f"{obj.staff.first_name} {obj.staff.last_name}" return None def get_assigned_to_name(self, obj): diff --git a/apps/complaints/tasks.py b/apps/complaints/tasks.py index 55b4520..275238d 100644 --- a/apps/complaints/tasks.py +++ b/apps/complaints/tasks.py @@ -19,7 +19,7 @@ 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]: +def match_staff_from_name(staff_name: str, hospital_id: str, department_name: Optional[str] = None, return_all: bool = False) -> Tuple[list, float, str]: """ Match staff member from extracted name using multiple matching strategies. @@ -27,19 +27,26 @@ def match_staff_from_name(staff_name: str, hospital_id: str, department_name: Op staff_name: Name extracted from complaint (without titles) hospital_id: Hospital ID to search within department_name: Optional department name to prioritize matching + return_all: If True, return all matching staff. If False, return single best match. 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 + If return_all=True: Tuple of (matches_list, confidence_score, matching_method) + - matches_list: List of dicts with matched staff details + - confidence_score: Float from 0.0 to 1.0 (best match confidence) + - matching_method: Description of how staff was matched + + If return_all=False: 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" + return [], 0.0, "No staff name provided" staff_name = staff_name.strip() + matches = [] # Build base query - staff from this hospital, active status base_query = Staff.objects.filter( @@ -72,12 +79,23 @@ def match_staff_from_name(staff_name: str, hospital_id: str, department_name: Op if dept_id: exact_query = exact_query.filter(department_id=dept_id) - staff = exact_query.first() - if staff: + exact_matches = list(exact_query) + if exact_matches: 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 + for staff in exact_matches: + matches.append({ + 'id': str(staff.id), + 'name_en': f"{staff.first_name} {staff.last_name}", + 'name_ar': f"{staff.first_name_ar} {staff.last_name_ar}" if staff.first_name_ar and staff.last_name_ar else "", + 'job_title': staff.job_title, + 'specialization': staff.specialization, + 'department': staff.department.name if staff.department else None, + 'department_id': str(staff.department.id) if staff.department else None, + 'confidence': confidence, + 'matching_method': method + }) + logger.info(f"Found {len(exact_matches)} exact English matches for: {staff_name}") # Layer 2: Exact Arabic match arabic_query = base_query.filter( @@ -92,8 +110,20 @@ def match_staff_from_name(staff_name: str, hospital_id: str, department_name: Op 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 + # Check if already in matches + if not any(m['id'] == str(staff.id) for m in matches): + matches.append({ + 'id': str(staff.id), + 'name_en': f"{staff.first_name} {staff.last_name}", + 'name_ar': f"{staff.first_name_ar} {staff.last_name_ar}", + 'job_title': staff.job_title, + 'specialization': staff.specialization, + 'department': staff.department.name if staff.department else None, + 'department_id': str(staff.department.id) if staff.department else None, + 'confidence': confidence, + 'matching_method': method + }) + logger.info(f"Found Arabic match: {staff.first_name_ar} {staff.last_name_ar}") # Layer 3: Partial match (first name or last name) partial_query = base_query.filter( @@ -105,12 +135,25 @@ def match_staff_from_name(staff_name: str, hospital_id: str, department_name: Op 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 + partial_matches = list(partial_query) + for staff in partial_matches: + # Check if already in matches + if not any(m['id'] == str(staff.id) for m in matches): + confidence = 0.70 if dept_id else 0.60 + method = f"Partial match in {'correct' if dept_id else 'any'} department" + matches.append({ + 'id': str(staff.id), + 'name_en': f"{staff.first_name} {staff.last_name}", + 'name_ar': f"{staff.first_name_ar} {staff.last_name_ar}" if staff.first_name_ar and staff.last_name_ar else "", + 'job_title': staff.job_title, + 'specialization': staff.specialization, + 'department': staff.department.name if staff.department else None, + 'department_id': str(staff.department.id) if staff.department else None, + 'confidence': confidence, + 'matching_method': method + }) + if partial_matches: + logger.info(f"Found {len(partial_matches)} partial matches for: {staff_name}") # Layer 4: Fuzzy match using individual words # Handle cases like "Dr. Ahmed" or "Nurse Sarah" @@ -120,16 +163,52 @@ def match_staff_from_name(staff_name: str, hospital_id: str, department_name: Op 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 + word_matches = list(word_query) + for staff in word_matches: + # Check if already in matches + if not any(m['id'] == str(staff.id) for m in matches): + confidence = 0.50 if dept_id else 0.45 + method = f"Word match in {'correct' if dept_id else 'any'} department" + matches.append({ + 'id': str(staff.id), + 'name_en': f"{staff.first_name} {staff.last_name}", + 'name_ar': f"{staff.first_name_ar} {staff.last_name_ar}" if staff.first_name_ar and staff.last_name_ar else "", + 'job_title': staff.job_title, + 'specialization': staff.specialization, + 'department': staff.department.name if staff.department else None, + 'department_id': str(staff.department.id) if staff.department else None, + 'confidence': confidence, + 'matching_method': method + }) + if word_matches: + logger.info(f"Found {len(word_matches)} word matches for: {staff_name}") - # No match found - logger.warning(f"No staff match found for name: {staff_name}") - return None, 0.0, "No match found" + # If return_all is False, return only the best match (highest confidence) + if not return_all: + if matches: + # Sort by confidence (descending) + matches.sort(key=lambda x: x['confidence'], reverse=True) + best_match = matches[0] + logger.info( + f"Best match: {best_match['name_en']} " + f"(confidence: {best_match['confidence']:.2f}, method: {best_match['matching_method']})" + ) + return str(best_match['id']), best_match['confidence'], best_match['matching_method'] + else: + logger.warning(f"No staff match found for name: {staff_name}") + return None, 0.0, "No match found" + + # Return all matches + if matches: + # Sort by confidence (descending) + matches.sort(key=lambda x: x['confidence'], reverse=True) + best_confidence = matches[0]['confidence'] + best_method = matches[0]['matching_method'] + logger.info(f"Returning {len(matches)} matches for: {staff_name}") + return matches, best_confidence, best_method + else: + logger.warning(f"No staff match found for name: {staff_name}") + return [], 0.0, "No match found" @shared_task @@ -709,9 +788,11 @@ def analyze_complaint_with_ai(complaint_id): # Get staff_name from analyze_complaint result (already extracted by AI) staff_name = analysis.get('staff_name', '').strip() - matched_staff_id = None + # Always get ALL matching staff for PX Admin review + staff_matches = [] staff_confidence = 0.0 staff_matching_method = None + matched_staff_id = None # Capture old staff before matching old_staff = complaint.staff @@ -720,39 +801,67 @@ def analyze_complaint_with_ai(complaint_id): 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_matches, staff_confidence, staff_matching_method = match_staff_from_name( staff_name=staff_name, hospital_id=str(complaint.hospital.id), - department_name=department_name + department_name=department_name, + return_all=True # Return ALL matches ) # If no match found with department, try WITHOUT department filter - if not matched_staff_id: + if not staff_matches: 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_matches, 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 + department_name=None, # Search all departments + return_all=True ) - # 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 + # Logic for staff assignment + needs_staff_review = False + + if staff_matches: + # If only ONE match, assign it (regardless of confidence for PX Admin review) + if len(staff_matches) == 1: + matched_staff_id = staff_matches[0]['id'] + 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") + # Still mark for review if confidence is low + if staff_confidence < 0.6: + needs_staff_review = True + else: + # Multiple matches found - don't assign, mark for review 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})" + f"Multiple staff matches found ({len(staff_matches)}), " + f"marking for PX Admin review" ) - except Staff.DoesNotExist: - logger.warning(f"Staff {matched_staff_id} not found in database") + needs_staff_review = True + # Assign to department instead if available + if department_name: + # Department already set from AI analysis + pass + elif staff_matches[0].get('department_id'): + from apps.organizations.models import Department + try: + dept = Department.objects.get(id=staff_matches[0]['department_id']) + complaint.department = dept + logger.info(f"Assigned to department: {dept.name}") + except Department.DoesNotExist: + pass else: - logger.info( - f"Staff match confidence {staff_confidence:.2f} below threshold 0.6, " - f"or no match found. Not assigning staff." - ) + # No matches found + logger.warning(f"No staff match found for name: {staff_name}") + needs_staff_review = False # No review needed if no name found # Save reasoning in metadata # Use JSON-serializable values instead of model objects @@ -790,9 +899,12 @@ def analyze_complaint_with_ai(complaint_id): 'old_staff': old_staff_name, 'old_staff_id': old_staff_id, 'extracted_staff_name': staff_name, + 'staff_matches': staff_matches, 'matched_staff_id': matched_staff_id, 'staff_confidence': staff_confidence, - 'staff_matching_method': staff_matching_method + 'staff_matching_method': staff_matching_method, + 'needs_staff_review': needs_staff_review, + 'staff_match_count': len(staff_matches) } complaint.save(update_fields=['severity', 'priority', 'category', 'department', 'staff', 'title', 'metadata']) diff --git a/apps/complaints/ui_views.py b/apps/complaints/ui_views.py index c7e22fa..2d448e4 100644 --- a/apps/complaints/ui_views.py +++ b/apps/complaints/ui_views.py @@ -221,6 +221,14 @@ def complaint_detail(request, pk): if complaint.hospital: assignable_users = assignable_users.filter(hospital=complaint.hospital) + # Get departments for the complaint's hospital + hospital_departments = [] + if complaint.hospital: + hospital_departments = Department.objects.filter( + hospital=complaint.hospital, + status='active' + ).order_by('name') + # Check if overdue complaint.check_overdue() @@ -232,6 +240,7 @@ def complaint_detail(request, pk): 'assignable_users': assignable_users, 'status_choices': ComplaintStatus.choices, 'can_edit': user.is_px_admin() or user.is_hospital_admin(), + 'hospital_departments': hospital_departments, } return render(request, 'complaints/complaint_detail.html', context) @@ -462,6 +471,67 @@ def complaint_add_note(request, pk): return redirect('complaints:complaint_detail', pk=pk) +@login_required +@require_http_methods(["POST"]) +def complaint_change_department(request, pk): + """Change complaint department""" + complaint = get_object_or_404(Complaint, pk=pk) + + # Check permission + user = request.user + if not (user.is_px_admin() or user.is_hospital_admin()): + messages.error(request, "You don't have permission to change complaint department.") + return redirect('complaints:complaint_detail', pk=pk) + + department_id = request.POST.get('department_id') + if not department_id: + messages.error(request, "Please select a department.") + return redirect('complaints:complaint_detail', pk=pk) + + try: + department = Department.objects.get(id=department_id) + + # Check department belongs to same hospital + if department.hospital != complaint.hospital: + messages.error(request, "Department does not belong to this complaint's hospital.") + return redirect('complaints:complaint_detail', pk=pk) + + old_department = complaint.department + complaint.department = department + complaint.save(update_fields=['department']) + + # Create update + ComplaintUpdate.objects.create( + complaint=complaint, + update_type='assignment', + message=f"Department changed to {department.name}", + created_by=request.user, + metadata={ + 'old_department_id': str(old_department.id) if old_department else None, + 'new_department_id': str(department.id) + } + ) + + # Log audit + AuditService.log_event( + event_type='department_change', + description=f"Complaint department changed to {department.name}", + user=request.user, + content_object=complaint, + metadata={ + 'old_department_id': str(old_department.id) if old_department else None, + 'new_department_id': str(department.id) + } + ) + + messages.success(request, f"Department changed to {department.name}.") + + except Department.DoesNotExist: + messages.error(request, "Department not found.") + + return redirect('complaints:complaint_detail', pk=pk) + + @login_required @require_http_methods(["POST"]) def complaint_escalate(request, pk): diff --git a/apps/complaints/urls.py b/apps/complaints/urls.py index ab5e987..71ca09c 100644 --- a/apps/complaints/urls.py +++ b/apps/complaints/urls.py @@ -18,6 +18,7 @@ urlpatterns = [ path('/', ui_views.complaint_detail, name='complaint_detail'), path('/assign/', ui_views.complaint_assign, name='complaint_assign'), path('/change-status/', ui_views.complaint_change_status, name='complaint_change_status'), + path('/change-department/', ui_views.complaint_change_department, name='complaint_change_department'), path('/add-note/', ui_views.complaint_add_note, name='complaint_add_note'), path('/escalate/', ui_views.complaint_escalate, name='complaint_escalate'), diff --git a/apps/complaints/views.py b/apps/complaints/views.py index 577141b..58b6e7b 100644 --- a/apps/complaints/views.py +++ b/apps/complaints/views.py @@ -1,6 +1,7 @@ """ Complaints views and viewsets """ +from django.db.models import Q from django.utils import timezone from rest_framework import status, viewsets from rest_framework.decorators import action @@ -107,7 +108,7 @@ class ComplaintViewSet(viewsets.ModelViewSet): permission_classes = [IsAuthenticated] filterset_fields = [ 'status', 'severity', 'priority', 'category', 'source', - 'hospital', 'department', 'physician', 'assigned_to', + 'hospital', 'department', 'staff', 'assigned_to', 'is_overdue', 'hospital__organization' ] search_fields = ['title', 'description', 'patient__mrn', 'patient__first_name', 'patient__last_name'] @@ -123,7 +124,7 @@ class ComplaintViewSet(viewsets.ModelViewSet): def get_queryset(self): """Filter complaints based on user role""" queryset = super().get_queryset().select_related( - 'patient', 'hospital', 'department', 'physician', + 'patient', 'hospital', 'department', 'staff', 'assigned_to', 'resolved_by', 'closed_by' ).prefetch_related('attachments', 'updates') @@ -281,6 +282,257 @@ class ComplaintViewSet(viewsets.ModelViewSet): serializer = ComplaintUpdateSerializer(update) return Response(serializer.data, status=status.HTTP_201_CREATED) + @action(detail=True, methods=['get']) + def staff_suggestions(self, request, pk=None): + """ + Get staff matching suggestions for a complaint. + + Returns potential staff matches from AI analysis, + allowing PX Admins to review and select correct staff. + """ + complaint = self.get_object() + + # Check if user is PX Admin + if not request.user.is_px_admin(): + return Response( + {'error': 'Only PX Admins can access staff suggestions'}, + status=status.HTTP_403_FORBIDDEN + ) + + # Get AI analysis metadata + ai_analysis = complaint.metadata.get('ai_analysis', {}) + staff_matches = ai_analysis.get('staff_matches', []) + extracted_name = ai_analysis.get('extracted_staff_name', '') + needs_review = ai_analysis.get('needs_staff_review', False) + matched_staff_id = ai_analysis.get('matched_staff_id') + + return Response({ + 'extracted_name': extracted_name, + 'staff_matches': staff_matches, + 'current_staff_id': matched_staff_id, + 'needs_staff_review': needs_staff_review, + 'staff_match_count': len(staff_matches) + }) + + @action(detail=True, methods=['get']) + def hospital_staff(self, request, pk=None): + """ + Get all staff from complaint's hospital for manual selection. + + Allows PX Admins to manually select staff. + Supports filtering by department. + """ + complaint = self.get_object() + + # Check if user is PX Admin + if not request.user.is_px_admin(): + return Response( + {'error': 'Only PX Admins can access hospital staff list'}, + status=status.HTTP_403_FORBIDDEN + ) + + from apps.organizations.models import Staff + + # Get query params + department_id = request.query_params.get('department_id') + search = request.query_params.get('search', '').strip() + + # Build query + queryset = Staff.objects.filter( + hospital=complaint.hospital, + status='active' + ).select_related('department') + + # Filter by department if specified + if department_id: + queryset = queryset.filter(department_id=department_id) + + # Search by name if provided + if search: + queryset = queryset.filter( + Q(first_name__icontains=search) | + Q(last_name__icontains=search) | + Q(first_name_ar__icontains=search) | + Q(last_name_ar__icontains=search) | + Q(job_title__icontains=search) + ) + + # Order by department and name + queryset = queryset.order_by('department__name', 'first_name', 'last_name') + + # Serialize + staff_list = [] + for staff in queryset: + staff_list.append({ + 'id': str(staff.id), + 'name_en': f"{staff.first_name} {staff.last_name}", + 'name_ar': f"{staff.first_name_ar} {staff.last_name_ar}" if staff.first_name_ar and staff.last_name_ar else "", + 'job_title': staff.job_title, + 'specialization': staff.specialization, + 'department': staff.department.name if staff.department else None, + 'department_id': str(staff.department.id) if staff.department else None + }) + + return Response({ + 'hospital_id': str(complaint.hospital.id), + 'hospital_name': complaint.hospital.name, + 'staff_count': len(staff_list), + 'staff': staff_list + }) + + @action(detail=True, methods=['post']) + def assign_staff(self, request, pk=None): + """ + Manually assign staff to a complaint. + + Allows PX Admins to assign specific staff member, + especially when AI matching is ambiguous. + """ + complaint = self.get_object() + + # Check if user is PX Admin + if not request.user.is_px_admin(): + return Response( + {'error': 'Only PX Admins can assign staff to complaints'}, + status=status.HTTP_403_FORBIDDEN + ) + + staff_id = request.data.get('staff_id') + reason = request.data.get('reason', '') + + if not staff_id: + return Response( + {'error': 'staff_id is required'}, + status=status.HTTP_400_BAD_REQUEST + ) + + from apps.organizations.models import Staff + try: + staff = Staff.objects.get(id=staff_id) + except Staff.DoesNotExist: + return Response( + {'error': 'Staff not found'}, + status=status.HTTP_404_NOT_FOUND + ) + + # Check staff belongs to same hospital + if staff.hospital != complaint.hospital: + return Response( + {'error': 'Staff does not belong to complaint hospital'}, + status=status.HTTP_400_BAD_REQUEST + ) + + # Update complaint + old_staff_id = str(complaint.staff.id) if complaint.staff else None + complaint.staff = staff + complaint.save(update_fields=['staff']) + + # Update metadata to clear review flag + if not complaint.metadata: + complaint.metadata = {} + if 'ai_analysis' in complaint.metadata: + complaint.metadata['ai_analysis']['needs_staff_review'] = False + complaint.metadata['ai_analysis']['staff_manually_assigned'] = True + complaint.metadata['ai_analysis']['staff_assigned_by'] = str(request.user.id) + complaint.metadata['ai_analysis']['staff_assigned_at'] = timezone.now().isoformat() + complaint.metadata['ai_analysis']['staff_assignment_reason'] = reason + complaint.save(update_fields=['metadata']) + + # Create update + ComplaintUpdate.objects.create( + complaint=complaint, + update_type='assignment', + message=f"Staff assigned to {staff.first_name} {staff.last_name} ({staff.job_title}). {reason}" if reason else f"Staff assigned to {staff.first_name} {staff.last_name} ({staff.job_title})", + created_by=request.user, + metadata={ + 'old_staff_id': old_staff_id, + 'new_staff_id': str(staff.id), + 'manual_assignment': True + } + ) + + # Log audit + AuditService.log_from_request( + event_type='staff_assigned', + description=f"Staff {staff.first_name} {staff.last_name} manually assigned to complaint by {request.user.get_full_name()}", + request=request, + content_object=complaint, + metadata={ + 'old_staff_id': old_staff_id, + 'new_staff_id': str(staff.id), + 'reason': reason + } + ) + + return Response({ + 'message': 'Staff assigned successfully', + 'staff_id': str(staff.id), + 'staff_name': f"{staff.first_name} {staff.last_name}" + }) + + @action(detail=True, methods=['post']) + def change_department(self, request, pk=None): + """Change complaint department""" + complaint = self.get_object() + department_id = request.data.get('department_id') + + if not department_id: + return Response( + {'error': 'department_id is required'}, + status=status.HTTP_400_BAD_REQUEST + ) + + from apps.organizations.models import Department + try: + department = Department.objects.get(id=department_id) + except Department.DoesNotExist: + return Response( + {'error': 'Department not found'}, + status=status.HTTP_404_NOT_FOUND + ) + + # Check department belongs to same hospital + if department.hospital != complaint.hospital: + return Response( + {'error': 'Department does not belong to complaint hospital'}, + status=status.HTTP_400_BAD_REQUEST + ) + + # Update complaint + old_department_id = str(complaint.department.id) if complaint.department else None + complaint.department = department + complaint.save(update_fields=['department']) + + # Create update + ComplaintUpdate.objects.create( + complaint=complaint, + update_type='assignment', + message=f"Department changed to {department.name}", + created_by=request.user, + metadata={ + 'old_department_id': old_department_id, + 'new_department_id': str(department.id) + } + ) + + # Log audit + AuditService.log_from_request( + event_type='department_change', + description=f"Complaint department changed to {department.name}", + request=request, + content_object=complaint, + metadata={ + 'old_department_id': old_department_id, + 'new_department_id': str(department.id) + } + ) + + return Response({ + 'message': 'Department changed successfully', + 'department_id': str(department.id), + 'department_name': department.name + }) + @action(detail=True, methods=['post']) def create_action_from_ai(self, request, pk=None): """Create PX Action from AI-suggested action""" @@ -397,6 +649,164 @@ class ComplaintViewSet(viewsets.ModelViewSet): 'message': 'Action created successfully from AI-suggested action' }, status=status.HTTP_201_CREATED) + @action(detail=True, methods=['post']) + def send_notification(self, request, pk=None): + """ + Send email notification to staff member or department head. + + Sends complaint notification with AI-generated summary (editable by user). + Logs the operation to NotificationLog and ComplaintUpdate. + """ + complaint = self.get_object() + + # Get email message (required) + email_message = request.data.get('email_message', '').strip() + if not email_message: + return Response( + {'error': 'email_message is required'}, + status=status.HTTP_400_BAD_REQUEST + ) + + # Get additional message (optional) + additional_message = request.data.get('additional_message', '').strip() + + # Determine recipient + recipient = None + recipient_display = None + recipient_type = None + + # Priority 1: Staff member mentioned in complaint + if complaint.staff and complaint.staff.user: + recipient = complaint.staff.user + recipient_display = complaint.staff.get_full_name() + recipient_type = 'Staff Member' + # Priority 2: Department head + elif complaint.department and complaint.department.manager: + recipient = complaint.department.manager + recipient_display = recipient.get_full_name() + recipient_type = 'Department Head' + + # Check if we found a recipient + if not recipient or not recipient.email: + return Response( + {'error': 'No valid recipient found. Complaint must have either a staff member with user account, or a department manager with email.'}, + status=status.HTTP_400_BAD_REQUEST + ) + + # Construct email content + subject = f"Complaint Notification - #{complaint.id}" + + # Build email body + email_body = f""" +Dear {recipient.get_full_name()}, + +You have been assigned to review the following complaint: + +COMPLAINT DETAILS: +---------------- +ID: #{complaint.id} +Title: {complaint.title} +Severity: {complaint.get_severity_display()} +Priority: {complaint.get_priority_display()} +Status: {complaint.get_status_display()} + +SUMMARY: +-------- +{email_message} + +""" + + # Add patient info if available + if complaint.patient: + email_body += f""" +PATIENT INFORMATION: +------------------ +Name: {complaint.patient.get_full_name()} +MRN: {complaint.patient.mrn} +""" + + # Add additional message if provided + if additional_message: + email_body += f""" + +ADDITIONAL MESSAGE: +------------------ +{additional_message} +""" + + # Add link to complaint + from django.contrib.sites.shortcuts import get_current_site + site = get_current_site(request) + complaint_url = f"https://{site.domain}/complaints/{complaint.id}/" + + email_body += f""" + +To view the full complaint details, please visit: +{complaint_url} + +Thank you for your attention to this matter. + +--- +This is an automated message from PX360 Complaint Management System. +""" + + # Send email using NotificationService + from apps.notifications.services import NotificationService + + try: + notification_log = NotificationService.send_email( + email=recipient.email, + subject=subject, + message=email_body, + related_object=complaint, + metadata={ + 'notification_type': 'complaint_notification', + 'recipient_type': recipient_type, + 'recipient_id': str(recipient.id), + 'sender_id': str(request.user.id), + 'has_additional_message': bool(additional_message) + } + ) + except Exception as e: + return Response( + {'error': f'Failed to send email: {str(e)}'}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR + ) + + # Create ComplaintUpdate entry + ComplaintUpdate.objects.create( + complaint=complaint, + update_type='communication', + message=f"Email notification sent to {recipient_type}: {recipient_display}", + created_by=request.user, + metadata={ + 'recipient_type': recipient_type, + 'recipient_id': str(recipient.id), + 'notification_log_id': str(notification_log.id) if notification_log else None + } + ) + + # Log audit + AuditService.log_from_request( + event_type='notification_sent', + description=f"Email notification sent to {recipient_type}: {recipient_display}", + request=request, + content_object=complaint, + metadata={ + 'recipient_type': recipient_type, + 'recipient_id': str(recipient.id), + 'recipient_email': recipient.email + } + ) + + return Response({ + 'success': True, + 'message': 'Email notification sent successfully', + 'recipient': recipient_display, + 'recipient_type': recipient_type, + 'recipient_email': recipient.email + }) + class ComplaintAttachmentViewSet(viewsets.ModelViewSet): """ViewSet for Complaint Attachments""" diff --git a/apps/core/ai_service.py b/apps/core/ai_service.py index 028f743..2c8bbdd 100644 --- a/apps/core/ai_service.py +++ b/apps/core/ai_service.py @@ -37,7 +37,8 @@ class AIService: OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1" OPENROUTER_API_KEY = "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" # Default configuration - DEFAULT_MODEL = "openrouter/xiaomi/mimo-v2-flash:free" + DEFAULT_MODEL = "openrouter/z-ai/glm-4.7" + # DEFAULT_MODEL = "openrouter/xiaomi/mimo-v2-flash:free" DEFAULT_TEMPERATURE = 0.3 DEFAULT_MAX_TOKENS = 500 DEFAULT_TIMEOUT = 30 diff --git a/apps/observations/migrations/0002_add_missing_fields.py b/apps/observations/migrations/0002_add_missing_fields.py new file mode 100644 index 0000000..b247048 --- /dev/null +++ b/apps/observations/migrations/0002_add_missing_fields.py @@ -0,0 +1,65 @@ +# Generated migration to add missing fields to Observation model + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + dependencies = [ + ('organizations', '0001_initial'), # Need hospital and department models + ('observations', '0001_initial'), + ] + + operations = [ + # Add hospital field (required for tenant isolation) + # Initially nullable, will be made required in next migration + migrations.AddField( + model_name='observation', + name='hospital', + field=models.ForeignKey( + null=True, + blank=True, + on_delete=django.db.models.deletion.CASCADE, + related_name='observations', + to='organizations.hospital' + ), + ), + + # Add staff field (optional, for AI-matching like complaints) + migrations.AddField( + model_name='observation', + name='staff', + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name='observations', + to='organizations.staff' + ), + ), + + # Add source field (to track how observation was submitted) + migrations.AddField( + model_name='observation', + name='source', + field=models.CharField( + blank=True, + choices=[ + ('staff_portal', 'Staff Portal'), + ('web_form', 'Web Form'), + ('mobile_app', 'Mobile App'), + ('email', 'Email'), + ('call_center', 'Call Center'), + ('other', 'Other'), + ], + default='staff_portal', + max_length=50 + ), + ), + + # Add indexes for hospital filtering + migrations.AddIndex( + model_name='observation', + index=models.Index(fields=['hospital', 'status', '-created_at'], name='obs_hospital_status_idx'), + ), + ] diff --git a/apps/observations/migrations/0003_rename_obs_hospital_status_idx_observation_hospita_dcd21a_idx_and_more.py b/apps/observations/migrations/0003_rename_obs_hospital_status_idx_observation_hospita_dcd21a_idx_and_more.py new file mode 100644 index 0000000..5eff108 --- /dev/null +++ b/apps/observations/migrations/0003_rename_obs_hospital_status_idx_observation_hospita_dcd21a_idx_and_more.py @@ -0,0 +1,36 @@ +# Generated by Django 5.0.14 on 2026-01-07 11:02 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('observations', '0002_add_missing_fields'), + ('organizations', '0005_alter_staff_department'), + ] + + operations = [ + migrations.RenameIndex( + model_name='observation', + new_name='observation_hospita_dcd21a_idx', + old_name='obs_hospital_status_idx', + ), + migrations.AlterField( + model_name='observation', + name='hospital', + field=models.ForeignKey(default=1, help_text='Hospital where observation was made', on_delete=django.db.models.deletion.CASCADE, related_name='observations', to='organizations.hospital'), + preserve_default=False, + ), + migrations.AlterField( + model_name='observation', + name='source', + field=models.CharField(choices=[('staff_portal', 'Staff Portal'), ('web_form', 'Web Form'), ('mobile_app', 'Mobile App'), ('email', 'Email'), ('call_center', 'Call Center'), ('other', 'Other')], default='staff_portal', help_text='How the observation was submitted', max_length=50), + ), + migrations.AlterField( + model_name='observation', + name='staff', + field=models.ForeignKey(blank=True, help_text='Staff member mentioned in observation', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='observations', to='organizations.staff'), + ), + ] diff --git a/apps/observations/models.py b/apps/observations/models.py index 48c1884..4d0cf07 100644 --- a/apps/observations/models.py +++ b/apps/observations/models.py @@ -51,28 +51,28 @@ class ObservationStatus(models.TextChoices): class ObservationCategory(UUIDModel, TimeStampedModel): """ Observation category for classifying reported issues. - + Supports bilingual names (English and Arabic). """ name_en = models.CharField(max_length=200, verbose_name="Name (English)") name_ar = models.CharField(max_length=200, blank=True, verbose_name="Name (Arabic)") description = models.TextField(blank=True) - + # Status and ordering is_active = models.BooleanField(default=True, db_index=True) sort_order = models.IntegerField(default=0, help_text="Lower numbers appear first") - + # Icon for UI (optional) icon = models.CharField(max_length=50, blank=True, help_text="Bootstrap icon class") - + class Meta: ordering = ['sort_order', 'name_en'] verbose_name = 'Observation Category' verbose_name_plural = 'Observation Categories' - + def __str__(self): return self.name_en - + @property def name(self): """Return English name as default.""" @@ -82,7 +82,7 @@ class ObservationCategory(UUIDModel, TimeStampedModel): class Observation(UUIDModel, TimeStampedModel): """ Observation - Staff-reported issue or concern. - + Key features: - Anonymous submission supported (no login required) - Optional reporter identification (staff_id, name) @@ -98,7 +98,7 @@ class Observation(UUIDModel, TimeStampedModel): default=generate_tracking_code, help_text="Unique code for tracking this observation" ) - + # Classification category = models.ForeignKey( ObservationCategory, @@ -107,7 +107,7 @@ class Observation(UUIDModel, TimeStampedModel): blank=True, related_name='observations' ) - + # Content title = models.CharField( max_length=300, @@ -117,7 +117,7 @@ class Observation(UUIDModel, TimeStampedModel): description = models.TextField( help_text="Detailed description of the observation" ) - + # Severity severity = models.CharField( max_length=20, @@ -125,7 +125,7 @@ class Observation(UUIDModel, TimeStampedModel): default=ObservationSeverity.MEDIUM, db_index=True ) - + # Location and timing location_text = models.CharField( max_length=500, @@ -136,7 +136,7 @@ class Observation(UUIDModel, TimeStampedModel): default=timezone.now, help_text="When the issue was observed" ) - + # Optional reporter information (anonymous supported) reporter_staff_id = models.CharField( max_length=50, @@ -157,7 +157,7 @@ class Observation(UUIDModel, TimeStampedModel): blank=True, help_text="Optional email for follow-up" ) - + # Status and workflow status = models.CharField( max_length=20, @@ -165,7 +165,40 @@ class Observation(UUIDModel, TimeStampedModel): default=ObservationStatus.NEW, db_index=True ) - + + # Organization (required for tenant isolation) + hospital = models.ForeignKey( + 'organizations.Hospital', + on_delete=models.CASCADE, + related_name='observations', + help_text="Hospital where observation was made" + ) + + # Staff member mentioned in observation (optional, for AI-matching like complaints) + staff = models.ForeignKey( + 'organizations.Staff', + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name='observations', + help_text="Staff member mentioned in observation" + ) + + # Source tracking + source = models.CharField( + max_length=50, + choices=[ + ('staff_portal', 'Staff Portal'), + ('web_form', 'Web Form'), + ('mobile_app', 'Mobile App'), + ('email', 'Email'), + ('call_center', 'Call Center'), + ('other', 'Other'), + ], + default='staff_portal', + help_text="How the observation was submitted" + ) + # Internal routing assigned_department = models.ForeignKey( 'organizations.Department', @@ -183,7 +216,7 @@ class Observation(UUIDModel, TimeStampedModel): related_name='assigned_observations', help_text="User assigned to handle this observation" ) - + # Triage information triaged_by = models.ForeignKey( settings.AUTH_USER_MODEL, @@ -193,7 +226,7 @@ class Observation(UUIDModel, TimeStampedModel): related_name='triaged_observations' ) triaged_at = models.DateTimeField(null=True, blank=True) - + # Resolution resolved_at = models.DateTimeField(null=True, blank=True) resolved_by = models.ForeignKey( @@ -204,7 +237,7 @@ class Observation(UUIDModel, TimeStampedModel): related_name='resolved_observations' ) resolution_notes = models.TextField(blank=True) - + # Closure closed_at = models.DateTimeField(null=True, blank=True) closed_by = models.ForeignKey( @@ -214,7 +247,7 @@ class Observation(UUIDModel, TimeStampedModel): blank=True, related_name='closed_observations' ) - + # Link to Action Center (if converted to action) # Using GenericForeignKey on PXAction side, store action_id here for quick reference action_id = models.UUIDField( @@ -222,15 +255,16 @@ class Observation(UUIDModel, TimeStampedModel): blank=True, help_text="ID of linked PX Action if converted" ) - + # Metadata client_ip = models.GenericIPAddressField(null=True, blank=True) user_agent = models.TextField(blank=True) metadata = models.JSONField(default=dict, blank=True) - + class Meta: ordering = ['-created_at'] indexes = [ + models.Index(fields=['hospital', 'status', '-created_at']), models.Index(fields=['status', '-created_at']), models.Index(fields=['severity', '-created_at']), models.Index(fields=['tracking_code']), @@ -241,26 +275,26 @@ class Observation(UUIDModel, TimeStampedModel): ('triage_observation', 'Can triage observations'), ('manage_categories', 'Can manage observation categories'), ] - + def __str__(self): return f"{self.tracking_code} - {self.title or self.description[:50]}" - + def save(self, *args, **kwargs): """Ensure tracking code is unique.""" if not self.tracking_code: self.tracking_code = generate_tracking_code() - + # Ensure uniqueness while Observation.objects.filter(tracking_code=self.tracking_code).exclude(pk=self.pk).exists(): self.tracking_code = generate_tracking_code() - + super().save(*args, **kwargs) - + @property def is_anonymous(self): """Check if the observation was submitted anonymously.""" return not (self.reporter_staff_id or self.reporter_name) - + @property def reporter_display(self): """Get display name for reporter.""" @@ -269,7 +303,7 @@ class Observation(UUIDModel, TimeStampedModel): if self.reporter_staff_id: return f"Staff ID: {self.reporter_staff_id}" return "Anonymous" - + def get_severity_color(self): """Get Bootstrap color class for severity.""" colors = { @@ -279,7 +313,7 @@ class Observation(UUIDModel, TimeStampedModel): 'critical': 'dark', } return colors.get(self.severity, 'secondary') - + def get_status_color(self): """Get Bootstrap color class for status.""" colors = { @@ -304,7 +338,7 @@ class ObservationAttachment(UUIDModel, TimeStampedModel): on_delete=models.CASCADE, related_name='attachments' ) - + file = models.FileField( upload_to='observations/%Y/%m/%d/', help_text="Uploaded file" @@ -315,15 +349,15 @@ class ObservationAttachment(UUIDModel, TimeStampedModel): default=0, help_text="File size in bytes" ) - + description = models.CharField(max_length=500, blank=True) - + class Meta: ordering = ['-created_at'] - + def __str__(self): return f"{self.observation.tracking_code} - {self.filename}" - + def save(self, *args, **kwargs): """Extract file metadata on save.""" if self.file: @@ -340,7 +374,7 @@ class ObservationAttachment(UUIDModel, TimeStampedModel): class ObservationNote(UUIDModel, TimeStampedModel): """ Internal note on an observation. - + Used by PX360 staff to add comments and updates. """ observation = models.ForeignKey( @@ -348,25 +382,25 @@ class ObservationNote(UUIDModel, TimeStampedModel): on_delete=models.CASCADE, related_name='notes' ) - + note = models.TextField() - + created_by = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, related_name='observation_notes' ) - + # Flag for internal-only notes is_internal = models.BooleanField( default=True, help_text="Internal notes are not visible to public" ) - + class Meta: ordering = ['-created_at'] - + def __str__(self): return f"Note on {self.observation.tracking_code} by {self.created_by}" @@ -374,7 +408,7 @@ class ObservationNote(UUIDModel, TimeStampedModel): class ObservationStatusLog(UUIDModel, TimeStampedModel): """ Status change log for observations. - + Tracks all status transitions for audit trail. """ observation = models.ForeignKey( @@ -382,7 +416,7 @@ class ObservationStatusLog(UUIDModel, TimeStampedModel): on_delete=models.CASCADE, related_name='status_logs' ) - + from_status = models.CharField( max_length=20, choices=ObservationStatus.choices, @@ -392,7 +426,7 @@ class ObservationStatusLog(UUIDModel, TimeStampedModel): max_length=20, choices=ObservationStatus.choices ) - + changed_by = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, @@ -400,16 +434,16 @@ class ObservationStatusLog(UUIDModel, TimeStampedModel): blank=True, related_name='observation_status_changes' ) - + comment = models.TextField( blank=True, help_text="Optional comment about the status change" ) - + class Meta: ordering = ['-created_at'] verbose_name = 'Observation Status Log' verbose_name_plural = 'Observation Status Logs' - + def __str__(self): return f"{self.observation.tracking_code}: {self.from_status} → {self.to_status}" diff --git a/apps/organizations/management/commands/seed_staff.py b/apps/organizations/management/commands/seed_staff.py new file mode 100644 index 0000000..9c03676 --- /dev/null +++ b/apps/organizations/management/commands/seed_staff.py @@ -0,0 +1,406 @@ +""" +Management command to seed staff data with bilingual support (English and Arabic) +""" +import random +from django.core.management.base import BaseCommand, CommandError +from django.db import transaction + +from apps.accounts.models import User +from apps.organizations.models import Hospital, Department, Staff + + +# Saudi names data - Paired to ensure English and Arabic correspond +NAMES_MALE = [ + {'en': 'Mohammed', 'ar': 'محمد'}, + {'en': 'Ahmed', 'ar': 'أحمد'}, + {'en': 'Abdullah', 'ar': 'عبدالله'}, + {'en': 'Khalid', 'ar': 'خالد'}, + {'en': 'Saud', 'ar': 'سعود'}, + {'en': 'Fahd', 'ar': 'فهد'}, + {'en': 'Abdulaziz', 'ar': 'عبدالعزيز'}, + {'en': 'Sultan', 'ar': 'سلطان'}, + {'en': 'Faisal', 'ar': 'فيصل'}, + {'en': 'Omar', 'ar': 'عمر'}, + {'en': 'Turki', 'ar': 'تركي'}, + {'en': 'Nasser', 'ar': 'ناصر'}, + {'en': 'Mishari', 'ar': 'مشاري'}, + {'en': 'Abdulrahman', 'ar': 'عبدالرحمن'}, + {'en': 'Yousef', 'ar': 'يوسف'}, + {'en': 'Ali', 'ar': 'علي'} +] + +NAMES_FEMALE = [ + {'en': 'Fatimah', 'ar': 'فاطمة'}, + {'en': 'Aisha', 'ar': 'عائشة'}, + {'en': 'Maryam', 'ar': 'مريم'}, + {'en': 'Noura', 'ar': 'نورة'}, + {'en': 'Sarah', 'ar': 'سارة'}, + {'en': 'Hind', 'ar': 'هند'}, + {'en': 'Latifa', 'ar': 'لطيفة'}, + {'en': 'Mona', 'ar': 'منى'}, + {'en': 'Reem', 'ar': 'ريم'}, + {'en': 'Jawaher', 'ar': 'جواهر'}, + {'en': 'Lina', 'ar': 'لينا'}, + {'en': 'Dua', 'ar': 'دعاء'}, + {'en': 'Maha', 'ar': 'مها'}, + {'en': 'Jumanah', 'ar': 'جمانة'}, + {'en': 'Rahaf', 'ar': 'رحاب'}, + {'en': 'Samar', 'ar': 'سمر'} +] + +LAST_NAMES = [ + {'en': 'Al-Otaibi', 'ar': 'العتيبي'}, + {'en': 'Al-Dosari', 'ar': 'الدوسري'}, + {'en': 'Al-Qahtani', 'ar': 'القحطاني'}, + {'en': 'Al-Shammari', 'ar': 'الشمري'}, + {'en': 'Al-Harbi', 'ar': 'الحربي'}, + {'en': 'Al-Mutairi', 'ar': 'المطيري'}, + {'en': 'Al-Anazi', 'ar': 'العنزي'}, + {'en': 'Al-Zahrani', 'ar': 'الزهراني'}, + {'en': 'Al-Ghamdi', 'ar': 'الغامدي'}, + {'en': 'Al-Shehri', 'ar': 'الشهري'}, + {'en': 'Al-Salem', 'ar': 'السالم'}, + {'en': 'Al-Fahad', 'ar': 'الفالح'} +] + +# Specializations for physicians +PHYSICIAN_SPECIALIZATIONS = [ + 'Internal Medicine', 'General Surgery', 'Pediatrics', 'Obstetrics & Gynecology', + 'Cardiology', 'Orthopedics', 'Neurology', 'Dermatology', 'Ophthalmology', + 'ENT', 'Urology', 'Nephrology', 'Gastroenterology', 'Pulmonology', + 'Endocrinology', 'Rheumatology', 'Hematology', 'Oncology', 'Psychiatry' +] + +# Job titles for nurses +NURSE_JOB_TITLES = [ + 'Registered Nurse', 'Senior Nurse', 'Nurse Practitioner', 'ICU Nurse', + 'Emergency Nurse', 'Pediatric Nurse', 'Operating Room Nurse', 'Head Nurse' +] + +# Job titles for admin staff +ADMIN_JOB_TITLES = [ + 'Medical Receptionist', 'Administrative Assistant', 'Medical Records Clerk', + 'Hospital Administrator', 'Department Secretary', 'Patient Services Representative', + 'Front Desk Officer', 'Billing Specialist' +] + + +class Command(BaseCommand): + help = 'Seed staff data with bilingual support (English and Arabic)' + + def add_arguments(self, parser): + parser.add_argument( + '--hospital-code', + type=str, + help='Target hospital code (default: all hospitals)' + ) + parser.add_argument( + '--count', + type=int, + default=10, + help='Number of staff to create per type (default: 10)' + ) + parser.add_argument( + '--physicians', + type=int, + default=10, + help='Number of physicians to create (default: 10)' + ) + parser.add_argument( + '--nurses', + type=int, + default=15, + help='Number of nurses to create (default: 15)' + ) + parser.add_argument( + '--admin-staff', + type=int, + default=5, + help='Number of admin staff to create (default: 5)' + ) + parser.add_argument( + '--create-users', + action='store_true', + help='Create user accounts for staff' + ) + parser.add_argument( + '--clear', + action='store_true', + help='Clear existing staff first' + ) + parser.add_argument( + '--dry-run', + action='store_true', + help='Preview without making changes' + ) + + def handle(self, *args, **options): + hospital_code = options['hospital_code'] + count = options['count'] + physicians_count = options['physicians'] + nurses_count = options['nurses'] + admin_staff_count = options['admin_staff'] + create_users = options['create_users'] + clear_existing = options['clear'] + dry_run = options['dry_run'] + + self.stdout.write(f"\n{'='*60}") + self.stdout.write("Staff Data Seeding Command") + self.stdout.write(f"{'='*60}\n") + + with transaction.atomic(): + # Get hospitals + if hospital_code: + hospitals = Hospital.objects.filter(code=hospital_code) + if not hospitals.exists(): + self.stdout.write( + self.style.ERROR(f"Hospital with code '{hospital_code}' not found") + ) + return + else: + hospitals = Hospital.objects.filter(status='active') + + if not hospitals.exists(): + self.stdout.write( + self.style.ERROR("No hospitals found. Please create hospitals first.") + ) + return + + self.stdout.write( + self.style.SUCCESS(f"Found {hospitals.count()} hospital(s) to seed staff") + ) + + # Display configuration + self.stdout.write("\nConfiguration:") + self.stdout.write(f" Physicians per hospital: {physicians_count}") + self.stdout.write(f" Nurses per hospital: {nurses_count}") + self.stdout.write(f" Admin staff per hospital: {admin_staff_count}") + self.stdout.write(f" Total staff per hospital: {physicians_count + nurses_count + admin_staff_count}") + self.stdout.write(f" Create user accounts: {create_users}") + self.stdout.write(f" Clear existing: {clear_existing}") + self.stdout.write(f" Dry run: {dry_run}") + + # Get all departments for assignment + all_departments = Department.objects.filter(status='active') + if not all_departments.exists(): + self.stdout.write( + self.style.WARNING("\nNo departments found. Staff will be created without departments.") + ) + + # Clear existing staff if requested + if clear_existing: + if dry_run: + self.stdout.write( + self.style.WARNING(f"\nWould delete {Staff.objects.count()} existing staff") + ) + else: + deleted_count = Staff.objects.count() + Staff.objects.all().delete() + self.stdout.write( + self.style.SUCCESS(f"\n✓ Deleted {deleted_count} existing staff") + ) + + # Track created staff + created_staff = [] + + # Seed physicians + physicians = self.create_staff_type( + hospitals=hospitals, + departments=all_departments, + staff_type=Staff.StaffType.PHYSICIAN, + count=physicians_count, + job_titles=PHYSICIAN_SPECIALIZATIONS, + create_users=create_users, + dry_run=dry_run + ) + created_staff.extend(physicians) + + # Seed nurses + nurses = self.create_staff_type( + hospitals=hospitals, + departments=all_departments, + staff_type=Staff.StaffType.NURSE, + count=nurses_count, + job_titles=NURSE_JOB_TITLES, + create_users=create_users, + dry_run=dry_run + ) + created_staff.extend(nurses) + + # Seed admin staff + admins = self.create_staff_type( + hospitals=hospitals, + departments=all_departments, + staff_type=Staff.StaffType.ADMIN, + count=admin_staff_count, + job_titles=ADMIN_JOB_TITLES, + create_users=create_users, + dry_run=dry_run + ) + created_staff.extend(admins) + + # Summary + self.stdout.write("\n" + "="*60) + self.stdout.write("Summary:") + self.stdout.write(f" Physicians created: {len(physicians)}") + self.stdout.write(f" Nurses created: {len(nurses)}") + self.stdout.write(f" Admin staff created: {len(admins)}") + self.stdout.write(f" Total staff created: {len(created_staff)}") + self.stdout.write("="*60 + "\n") + + if dry_run: + self.stdout.write(self.style.WARNING("DRY RUN: No changes were made\n")) + else: + self.stdout.write(self.style.SUCCESS("Staff seeding completed successfully!\n")) + + def create_staff_type(self, hospitals, departments, staff_type, count, job_titles, + create_users, dry_run): + """Create staff of a specific type""" + created = [] + staff_type_display = dict(Staff.StaffType.choices).get(staff_type, staff_type) + + self.stdout.write(f"\nSeeding {staff_type_display}...") + + for hospital in hospitals: + # Get departments for this hospital + hospital_depts = [d for d in departments if d.hospital == hospital] + + for i in range(count): + # Generate names (paired to ensure English and Arabic correspond) + if staff_type == Staff.StaffType.NURSE: + # More female nurses (70%) + if random.random() < 0.7: + first_name = random.choice(NAMES_FEMALE) + else: + first_name = random.choice(NAMES_MALE) + else: + # Physicians and admin staff are mostly male (60%) + if random.random() < 0.6: + first_name = random.choice(NAMES_MALE) + else: + first_name = random.choice(NAMES_FEMALE) + + last_name = random.choice(LAST_NAMES) + + job_title = random.choice(job_titles) + + # Generate employee ID + employee_id = self.generate_employee_id(hospital.code, staff_type) + + # Generate license number for physicians + license_number = None + if staff_type == Staff.StaffType.PHYSICIAN: + license_number = self.generate_license_number() + + # Select department (optional) + department = random.choice(hospital_depts) if hospital_depts and random.random() > 0.1 else None + + # Specialization for all staff types (required field) + if staff_type == Staff.StaffType.PHYSICIAN: + specialization = job_title + elif staff_type == Staff.StaffType.NURSE: + specialization = random.choice(['General Nursing', 'Patient Care', 'Clinical Nursing', 'Nursing']) + elif staff_type == Staff.StaffType.ADMIN: + specialization = random.choice(['Administration', 'Hospital Operations', 'Medical Administration', 'Healthcare Admin']) + else: + specialization = 'General Staff' + + if dry_run: + self.stdout.write( + f" Would create: {first_name['en']} {last_name['en']} ({first_name['ar']} {last_name['ar']}) - {job_title}" + ) + created.append({'type': staff_type_display, 'name': f"{first_name['en']} {last_name['en']}"}) + else: + # Create staff + staff = Staff.objects.create( + first_name=first_name['en'], + last_name=last_name['en'], + first_name_ar=first_name['ar'], + last_name_ar=last_name['ar'], + staff_type=staff_type, + job_title=job_title, + license_number=license_number, + specialization=specialization, + employee_id=employee_id, + hospital=hospital, + department=department, + status='active' + ) + + # Create user account if requested + if create_users: + self.create_user_for_staff(staff) + + created.append(staff) + + self.stdout.write( + self.style.SUCCESS(f" ✓ Created {len([s for s in created if isinstance(s, Staff)])} {staff_type_display}") + ) + return created + + def generate_employee_id(self, hospital_code, staff_type): + """Generate unique employee ID""" + prefix = { + Staff.StaffType.PHYSICIAN: 'DR', + Staff.StaffType.NURSE: 'RN', + Staff.StaffType.ADMIN: 'ADM', + Staff.StaffType.OTHER: 'STF' + }.get(staff_type, 'STF') + + random_num = random.randint(10000, 99999) + return f"{prefix}-{hospital_code}-{random_num}" + + def generate_license_number(self): + """Generate unique license number""" + random_num = random.randint(1000000, 9999999) + return f"MOH-LIC-{random_num}" + + def create_user_for_staff(self, staff): + """Create a user account for staff""" + username = self.generate_username(staff) + + # Check if user already exists + if User.objects.filter(username=username).exists(): + return + + # Generate email + email = f"{staff.first_name.lower()}.{staff.last_name.lower()}@{staff.hospital.code.lower()}.sa" + + # Check if email exists + if User.objects.filter(email=email).exists(): + email = f"{username}@{staff.hospital.code.lower()}.sa" + + # Create user + user = User.objects.create_user( + username=username, + email=email, + first_name=staff.first_name, + last_name=staff.last_name, + password='password123', # Default password + employee_id=staff.employee_id, + hospital=staff.hospital, + department=staff.department, + language='ar' if random.random() < 0.5 else 'en', # Random language preference + is_staff=True, + ) + + # Link staff to user + staff.user = user + staff.save(update_fields=['user']) + + self.stdout.write( + self.style.SUCCESS(f" ✓ Created user: {username}") + ) + + def generate_username(self, staff): + """Generate unique username""" + base = f"{staff.first_name.lower()}.{staff.last_name.lower()}" + username = base + + # Add suffix if username exists + counter = 1 + while User.objects.filter(username=username).exists(): + username = f"{base}{counter}" + counter += 1 + + return username diff --git a/apps/organizations/migrations/0005_alter_staff_department.py b/apps/organizations/migrations/0005_alter_staff_department.py new file mode 100644 index 0000000..a5b2afd --- /dev/null +++ b/apps/organizations/migrations/0005_alter_staff_department.py @@ -0,0 +1,19 @@ +# Generated by Django 5.0.14 on 2026-01-07 08:54 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('organizations', '0004_remove_patient_department'), + ] + + operations = [ + migrations.AlterField( + model_name='staff', + name='department', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='staff', to='organizations.department'), + ), + ] diff --git a/apps/organizations/models.py b/apps/organizations/models.py index 08e4923..977a2a4 100644 --- a/apps/organizations/models.py +++ b/apps/organizations/models.py @@ -161,7 +161,7 @@ class Staff(UUIDModel, TimeStampedModel): # Organization hospital = models.ForeignKey(Hospital, on_delete=models.CASCADE, related_name='staff') - department = models.ForeignKey(Department, on_delete=models.SET_NULL, null=True, blank=True) + department = models.ForeignKey(Department, on_delete=models.SET_NULL, null=True, blank=True, related_name='staff') status = models.CharField(max_length=20, choices=StatusChoices.choices, default=StatusChoices.ACTIVE) diff --git a/apps/references/__init__.py b/apps/references/__init__.py new file mode 100644 index 0000000..7967f7d --- /dev/null +++ b/apps/references/__init__.py @@ -0,0 +1,4 @@ +""" +References app - Reference Section for document management +""" +default_app_config = 'apps.references.apps.ReferencesConfig' diff --git a/apps/references/admin.py b/apps/references/admin.py new file mode 100644 index 0000000..e77832a --- /dev/null +++ b/apps/references/admin.py @@ -0,0 +1,120 @@ +""" +References admin - Django admin configuration for Reference Section +""" +from django.contrib import admin +from django.utils.html import format_html + +from apps.references.models import ReferenceFolder, ReferenceDocument, ReferenceDocumentAccess + + +@admin.register(ReferenceFolder) +class ReferenceFolderAdmin(admin.ModelAdmin): + """Admin interface for ReferenceFolder""" + list_display = ['name', 'name_ar', 'hospital', 'parent', 'get_document_count', 'is_active', 'order'] + list_filter = ['hospital', 'is_active', 'parent'] + search_fields = ['name', 'name_ar', 'description', 'description_ar'] + prepopulated_fields = {} + readonly_fields = ['created_at', 'updated_at', 'get_full_path_display'] + + fieldsets = ( + ('Basic Information', { + 'fields': ('hospital', 'name', 'name_ar', 'parent') + }), + ('Description', { + 'fields': ('description', 'description_ar') + }), + ('UI Customization', { + 'fields': ('icon', 'color', 'order') + }), + ('Access Control', { + 'fields': ('access_roles', 'is_active') + }), + ('System Information', { + 'fields': ('get_full_path_display', 'is_deleted', 'deleted_at', 'created_at', 'updated_at'), + 'classes': ('collapse',) + }), + ) + + filter_horizontal = ['access_roles'] + + def get_full_path_display(self, obj): + """Display full path in admin""" + return obj.get_full_path() + get_full_path_display.short_description = 'Full Path' + + def get_document_count(self, obj): + """Display document count""" + count = obj.get_document_count() + return f"{count} document{'s' if count != 1 else ''}" + get_document_count.short_description = 'Documents' + + +@admin.register(ReferenceDocument) +class ReferenceDocumentAdmin(admin.ModelAdmin): + """Admin interface for ReferenceDocument""" + list_display = ['title', 'folder', 'hospital', 'get_file_icon_display', 'version', 'is_latest_version', 'download_count', 'is_published'] + list_filter = ['hospital', 'folder', 'file_type', 'is_latest_version', 'is_published'] + search_fields = ['title', 'title_ar', 'filename', 'description', 'description_ar', 'tags'] + prepopulated_fields = {} + readonly_fields = ['file_type', 'file_size', 'download_count', 'created_at', 'updated_at'] + + fieldsets = ( + ('Basic Information', { + 'fields': ('hospital', 'folder', 'title', 'title_ar') + }), + ('File', { + 'fields': ('file', 'filename', 'file_type', 'file_size', 'get_file_icon_display') + }), + ('Description', { + 'fields': ('description', 'description_ar') + }), + ('Versioning', { + 'fields': ('version', 'is_latest_version', 'parent_document') + }), + ('Upload Information', { + 'fields': ('uploaded_by',) + }), + ('Usage Tracking', { + 'fields': ('download_count', 'last_accessed_at') + }), + ('Access Control', { + 'fields': ('access_roles', 'is_published') + }), + ('Tags', { + 'fields': ('tags', 'metadata') + }), + ('System Information', { + 'fields': ('is_deleted', 'deleted_at', 'created_at', 'updated_at'), + 'classes': ('collapse',) + }), + ) + + filter_horizontal = ['access_roles'] + + def get_file_icon_display(self, obj): + """Display file icon""" + icon_class = obj.get_file_icon() + return format_html('', icon_class) + get_file_icon_display.short_description = 'File Type' + + +@admin.register(ReferenceDocumentAccess) +class ReferenceDocumentAccessAdmin(admin.ModelAdmin): + """Admin interface for ReferenceDocumentAccess""" + list_display = ['document', 'user', 'action', 'ip_address', 'created_at'] + list_filter = ['action', 'created_at', 'document__hospital'] + search_fields = ['user__email', 'user__username', 'document__title'] + readonly_fields = ['created_at'] + + fieldsets = ( + ('Access Details', { + 'fields': ('document', 'user', 'action') + }), + ('Context', { + 'fields': ('ip_address', 'user_agent') + }), + ('System Information', { + 'fields': ('created_at',), + 'classes': ('collapse',) + }), + ) diff --git a/apps/references/apps.py b/apps/references/apps.py new file mode 100644 index 0000000..24717e3 --- /dev/null +++ b/apps/references/apps.py @@ -0,0 +1,11 @@ +""" +References app configuration +""" +from django.apps import AppConfig + + +class ReferencesConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'apps.references' + verbose_name = 'Reference Section' + verbose_name_plural = 'Reference Section' diff --git a/apps/references/forms.py b/apps/references/forms.py new file mode 100644 index 0000000..2a5654a --- /dev/null +++ b/apps/references/forms.py @@ -0,0 +1,256 @@ +""" +References forms - Forms for Reference Section +""" +from django import forms +from django.utils.translation import gettext_lazy as _ + +from apps.references.models import ReferenceFolder, ReferenceDocument + + +class ReferenceFolderForm(forms.ModelForm): + """Form for creating and editing reference folders""" + + class Meta: + model = ReferenceFolder + fields = [ + 'name', 'name_ar', + 'description', 'description_ar', + 'parent', 'icon', 'color', 'order', + 'access_roles', 'is_active' + ] + widgets = { + 'name': forms.TextInput(attrs={ + 'class': 'form-control', + 'placeholder': _('Enter folder name (English)') + }), + 'name_ar': forms.TextInput(attrs={ + 'class': 'form-control', + 'placeholder': _('Enter folder name (Arabic)') + }), + 'description': forms.Textarea(attrs={ + 'class': 'form-control', + 'rows': 3, + 'placeholder': _('Enter folder description (English)') + }), + 'description_ar': forms.Textarea(attrs={ + 'class': 'form-control', + 'rows': 3, + 'placeholder': _('Enter folder description (Arabic)') + }), + 'parent': forms.Select(attrs={ + 'class': 'form-select' + }), + 'icon': forms.TextInput(attrs={ + 'class': 'form-control', + 'placeholder': _('e.g., fa-folder, fa-file-pdf') + }), + 'color': forms.TextInput(attrs={ + 'class': 'form-control', + 'placeholder': _('e.g., #007bff') + }), + 'order': forms.NumberInput(attrs={ + 'class': 'form-control', + 'min': 0 + }), + 'access_roles': forms.CheckboxSelectMultiple(), + 'is_active': forms.CheckboxInput(attrs={ + 'class': 'form-check-input' + }) + } + + def __init__(self, *args, **kwargs): + hospital = kwargs.pop('hospital', None) + super().__init__(*args, **kwargs) + + # Filter parent folders by hospital + if hospital: + self.fields['parent'].queryset = ReferenceFolder.objects.filter( + hospital=hospital, + is_deleted=False + ) + + # Filter access roles + if hospital: + from django.contrib.auth.models import Group + self.fields['access_roles'].queryset = Group.objects.all() + + def clean_name(self): + """Ensure name is provided in at least one language""" + name = self.cleaned_data.get('name', '') + name_ar = self.cleaned_data.get('name_ar', '') + + if not name and not name_ar: + raise forms.ValidationError(_('Please provide a name in English or Arabic.')) + + return name + + +class ReferenceDocumentForm(forms.ModelForm): + """Form for uploading and editing reference documents""" + + new_version = forms.BooleanField( + required=False, + label=_('Upload as new version'), + help_text=_('Check this to create a new version of an existing document') + ) + + class Meta: + model = ReferenceDocument + fields = [ + 'folder', 'title', 'title_ar', + 'file', 'description', 'description_ar', + 'access_roles', 'is_published', + 'tags' + ] + widgets = { + 'folder': forms.Select(attrs={ + 'class': 'form-select' + }), + 'title': forms.TextInput(attrs={ + 'class': 'form-control', + 'placeholder': _('Enter document title (English)') + }), + 'title_ar': forms.TextInput(attrs={ + 'class': 'form-control', + 'placeholder': _('Enter document title (Arabic)') + }), + 'file': forms.FileInput(attrs={ + 'class': 'form-control' + }), + 'description': forms.Textarea(attrs={ + 'class': 'form-control', + 'rows': 4, + 'placeholder': _('Enter document description (English)') + }), + 'description_ar': forms.Textarea(attrs={ + 'class': 'form-control', + 'rows': 4, + 'placeholder': _('Enter document description (Arabic)') + }), + 'access_roles': forms.CheckboxSelectMultiple(), + 'is_published': forms.CheckboxInput(attrs={ + 'class': 'form-check-input' + }), + 'tags': forms.TextInput(attrs={ + 'class': 'form-control', + 'placeholder': _('e.g., policy, procedure, handbook (comma-separated)') + }) + } + + def __init__(self, *args, **kwargs): + hospital = kwargs.pop('hospital', None) + folder = kwargs.pop('folder', None) + super().__init__(*args, **kwargs) + + # Filter folders by hospital + if hospital: + self.fields['folder'].queryset = ReferenceFolder.objects.filter( + hospital=hospital, + is_active=True, + is_deleted=False + ) + + # Filter access roles + if hospital: + from django.contrib.auth.models import Group + self.fields['access_roles'].queryset = Group.objects.all() + + # Make file required for new documents, optional for updates + if self.instance and self.instance.pk: + self.fields['file'].required = False + else: + self.fields['file'].required = True + + # Make folder optional when uploading from within a folder + if folder: + self.fields['folder'].required = False + self.fields['folder'].initial = folder + + def clean_title(self): + """Ensure title is provided in at least one language""" + title = self.cleaned_data.get('title', '') + title_ar = self.cleaned_data.get('title_ar', '') + + if not title and not title_ar: + raise forms.ValidationError(_('Please provide a title in English or Arabic.')) + + return title + + def clean(self): + """Custom validation for new version creation""" + cleaned_data = super().clean() + new_version = cleaned_data.get('new_version') + file = cleaned_data.get('file') + + # If uploading as new version, file is required + if new_version and not file and not (self.instance and self.instance.pk and self.instance.file): + raise forms.ValidationError({ + 'file': _('File is required when uploading a new version.') + }) + + return cleaned_data + + +class ReferenceDocumentSearchForm(forms.Form): + """Form for searching and filtering documents""" + + search = forms.CharField( + required=False, + label=_('Search'), + widget=forms.TextInput(attrs={ + 'class': 'form-control', + 'placeholder': _('Search by title, description, or tags...') + }) + ) + + folder = forms.ModelChoiceField( + required=False, + label=_('Folder'), + queryset=ReferenceFolder.objects.none(), + widget=forms.Select(attrs={ + 'class': 'form-select' + }) + ) + + file_type = forms.ChoiceField( + required=False, + label=_('File Type'), + choices=[ + ('', _('All Types')), + ('pdf', 'PDF'), + ('doc', 'Word'), + ('docx', 'Word'), + ('xls', 'Excel'), + ('xlsx', 'Excel'), + ('ppt', 'PowerPoint'), + ('pptx', 'PowerPoint'), + ('jpg', 'Image'), + ('jpeg', 'Image'), + ('png', 'Image'), + ('gif', 'Image'), + ], + widget=forms.Select(attrs={ + 'class': 'form-select' + }) + ) + + tags = forms.CharField( + required=False, + label=_('Tags'), + widget=forms.TextInput(attrs={ + 'class': 'form-control', + 'placeholder': _('Filter by tags...') + }) + ) + + def __init__(self, *args, **kwargs): + hospital = kwargs.pop('hospital', None) + super().__init__(*args, **kwargs) + + # Filter folders by hospital + if hospital: + self.fields['folder'].queryset = ReferenceFolder.objects.filter( + hospital=hospital, + is_active=True, + is_deleted=False + ) diff --git a/apps/references/migrations/0001_initial.py b/apps/references/migrations/0001_initial.py new file mode 100644 index 0000000..8756976 --- /dev/null +++ b/apps/references/migrations/0001_initial.py @@ -0,0 +1,121 @@ +# Generated by Django 5.0.14 on 2026-01-07 16:18 + +import apps.references.models +import django.db.models.deletion +import uuid +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('auth', '0012_alter_user_first_name_max_length'), + ('organizations', '0005_alter_staff_department'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='ReferenceFolder', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('is_deleted', models.BooleanField(db_index=True, default=False)), + ('deleted_at', models.DateTimeField(blank=True, null=True)), + ('name', models.CharField(db_index=True, max_length=200)), + ('name_ar', models.CharField(blank=True, max_length=200, verbose_name='Name (Arabic)')), + ('description', models.TextField(blank=True)), + ('description_ar', models.TextField(blank=True, verbose_name='Description (Arabic)')), + ('icon', models.CharField(blank=True, help_text="Icon class (e.g., 'fa-folder', 'fa-file-pdf')", max_length=50)), + ('color', models.CharField(blank=True, help_text="Hex color code (e.g., '#007bff')", max_length=7)), + ('order', models.IntegerField(default=0, help_text='Display order within parent folder')), + ('is_active', models.BooleanField(db_index=True, default=True)), + ('access_roles', models.ManyToManyField(blank=True, help_text='Roles that can access this folder (empty = all roles)', related_name='accessible_folders', to='auth.group')), + ('hospital', models.ForeignKey(help_text='Tenant hospital for this record', on_delete=django.db.models.deletion.CASCADE, related_name='%(app_label)s_%(class)s_related', to='organizations.hospital')), + ('parent', models.ForeignKey(blank=True, help_text='Parent folder for nested structure', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='subfolders', to='references.referencefolder')), + ], + options={ + 'verbose_name': 'Reference Folder', + 'verbose_name_plural': 'Reference Folders', + 'ordering': ['parent__order', 'order', 'name'], + }, + ), + migrations.CreateModel( + name='ReferenceDocument', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('is_deleted', models.BooleanField(db_index=True, default=False)), + ('deleted_at', models.DateTimeField(blank=True, null=True)), + ('title', models.CharField(db_index=True, max_length=500)), + ('title_ar', models.CharField(blank=True, max_length=500, verbose_name='Title (Arabic)')), + ('file', models.FileField(max_length=500, upload_to=apps.references.models.document_upload_path)), + ('filename', models.CharField(help_text='Original filename', max_length=500)), + ('file_type', models.CharField(blank=True, help_text='File extension/type (e.g., pdf, docx, xlsx)', max_length=50)), + ('file_size', models.IntegerField(help_text='File size in bytes')), + ('description', models.TextField(blank=True)), + ('description_ar', models.TextField(blank=True, verbose_name='Description (Arabic)')), + ('version', models.CharField(default='1.0', help_text='Document version (e.g., 1.0, 1.1, 2.0)', max_length=20)), + ('is_latest_version', models.BooleanField(db_index=True, default=True, help_text='Is this the latest version?')), + ('download_count', models.IntegerField(default=0, help_text='Number of downloads')), + ('last_accessed_at', models.DateTimeField(blank=True, null=True)), + ('is_published', models.BooleanField(db_index=True, default=True, help_text='Is this document visible to users?')), + ('tags', models.CharField(blank=True, help_text='Comma-separated tags for search (e.g., policy, procedure, handbook)', max_length=500)), + ('metadata', models.JSONField(blank=True, default=dict)), + ('access_roles', models.ManyToManyField(blank=True, help_text='Roles that can access this document (empty = all roles)', related_name='accessible_documents', to='auth.group')), + ('hospital', models.ForeignKey(help_text='Tenant hospital for this record', on_delete=django.db.models.deletion.CASCADE, related_name='%(app_label)s_%(class)s_related', to='organizations.hospital')), + ('parent_document', models.ForeignKey(blank=True, help_text='Previous version of this document', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='versions', to='references.referencedocument')), + ('uploaded_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='uploaded_documents', to=settings.AUTH_USER_MODEL)), + ('folder', models.ForeignKey(blank=True, help_text='Folder containing this document', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='documents', to='references.referencefolder')), + ], + options={ + 'verbose_name': 'Reference Document', + 'verbose_name_plural': 'Reference Documents', + 'ordering': ['title', '-created_at'], + }, + ), + migrations.CreateModel( + name='ReferenceDocumentAccess', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('action', models.CharField(choices=[('view', 'Viewed'), ('download', 'Downloaded'), ('preview', 'Previewed')], db_index=True, max_length=20)), + ('ip_address', models.GenericIPAddressField(blank=True, null=True)), + ('user_agent', models.TextField(blank=True)), + ('document', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='access_logs', to='references.referencedocument')), + ('user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='document_accesses', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name': 'Document Access Log', + 'verbose_name_plural': 'Document Access Logs', + 'ordering': ['-created_at'], + 'indexes': [models.Index(fields=['document', '-created_at'], name='references__documen_396fa2_idx'), models.Index(fields=['user', '-created_at'], name='references__user_id_56bf5f_idx'), models.Index(fields=['action', '-created_at'], name='references__action_b9899d_idx')], + }, + ), + migrations.AddIndex( + model_name='referencefolder', + index=models.Index(fields=['hospital', 'parent', 'order'], name='references__hospita_faa66f_idx'), + ), + migrations.AddIndex( + model_name='referencefolder', + index=models.Index(fields=['hospital', 'is_active'], name='references__hospita_6c43f3_idx'), + ), + migrations.AddIndex( + model_name='referencedocument', + index=models.Index(fields=['hospital', 'folder', 'is_latest_version'], name='references__hospita_36d516_idx'), + ), + migrations.AddIndex( + model_name='referencedocument', + index=models.Index(fields=['hospital', 'is_published'], name='references__hospita_413b58_idx'), + ), + migrations.AddIndex( + model_name='referencedocument', + index=models.Index(fields=['folder', 'title'], name='references__folder__e09b4c_idx'), + ), + ] diff --git a/apps/references/migrations/__init__.py b/apps/references/migrations/__init__.py new file mode 100644 index 0000000..6b62a9d --- /dev/null +++ b/apps/references/migrations/__init__.py @@ -0,0 +1 @@ +# Migrations for references app diff --git a/apps/references/models.py b/apps/references/models.py new file mode 100644 index 0000000..67fce05 --- /dev/null +++ b/apps/references/models.py @@ -0,0 +1,389 @@ +""" +References models - Reference Section for document management + +This module implements a file server system for managing reference documents +with folder categorization, version control, and role-based access control. +""" +import os + +from django.contrib.auth import get_user_model +from django.core.exceptions import ValidationError +from django.db import models + +from apps.core.models import TenantModel, TimeStampedModel, UUIDModel, SoftDeleteModel + +User = get_user_model() + + +def document_upload_path(instance, filename): + """ + Generate upload path for reference documents. + + Format: references//YYYY/MM/DD/_ + """ + hospital_id = instance.hospital.id if instance.hospital else 'default' + from django.utils import timezone + date_str = timezone.now().strftime('%Y/%m/%d') + # Get file extension + ext = os.path.splitext(filename)[1] + return f'references/{hospital_id}/{date_str}/{instance.id}{ext}' + + +class ReferenceFolder(UUIDModel, TimeStampedModel, SoftDeleteModel, TenantModel): + """ + Reference Folder model for organizing documents into hierarchical folders. + + Features: + - Hospital-specific folders (via TenantModel) + - Nested folder hierarchy via self-referential parent field + - Bilingual support (English/Arabic) + - Role-based access control + - Soft delete support + """ + # Bilingual folder name + name = models.CharField(max_length=200, db_index=True) + name_ar = models.CharField(max_length=200, blank=True, verbose_name="Name (Arabic)") + + # Description + description = models.TextField(blank=True) + description_ar = models.TextField(blank=True, verbose_name="Description (Arabic)") + + # Hierarchy - self-referential for nested folders + parent = models.ForeignKey( + 'self', + on_delete=models.CASCADE, + null=True, + blank=True, + related_name='subfolders', + help_text="Parent folder for nested structure" + ) + + # UI customization + icon = models.CharField( + max_length=50, + blank=True, + help_text="Icon class (e.g., 'fa-folder', 'fa-file-pdf')" + ) + color = models.CharField( + max_length=7, + blank=True, + help_text="Hex color code (e.g., '#007bff')" + ) + + # Ordering + order = models.IntegerField( + default=0, + help_text="Display order within parent folder" + ) + + # Role-based access control + access_roles = models.ManyToManyField( + 'auth.Group', + blank=True, + related_name='accessible_folders', + help_text="Roles that can access this folder (empty = all roles)" + ) + + # Status + is_active = models.BooleanField(default=True, db_index=True) + + class Meta: + ordering = ['parent__order', 'order', 'name'] + verbose_name = 'Reference Folder' + verbose_name_plural = 'Reference Folders' + indexes = [ + models.Index(fields=['hospital', 'parent', 'order']), + models.Index(fields=['hospital', 'is_active']), + ] + + def __str__(self): + hospital_name = self.hospital.name if self.hospital else 'No Hospital' + return f"{hospital_name} - {self.name}" + + def get_full_path(self): + """Get full folder path as a list of folder names""" + path = [self.name] + parent = self.parent + while parent: + path.insert(0, parent.name) + parent = parent.parent + return ' / '.join(path) + + def has_access(self, user): + """ + Check if user has access to this folder. + + Returns True if: + - Folder has no access_roles (public to all) + - User belongs to any of the access_roles + """ + if not self.access_roles.exists(): + return True + return user.groups.filter(id__in=self.access_roles.all()).exists() + + def get_subfolders(self): + """Get all immediate subfolders""" + return self.subfolders.filter(is_active=True, is_deleted=False) + + def get_documents(self): + """Get all documents in this folder""" + return self.documents.filter(is_published=True, is_deleted=False) + + def get_document_count(self): + """Get count of documents in this folder (including subfolders)""" + count = self.get_documents().count() + for subfolder in self.get_subfolders(): + count += subfolder.get_document_count() + return count + + +class ReferenceDocument(UUIDModel, TimeStampedModel, SoftDeleteModel, TenantModel): + """ + Reference Document model for storing and managing documents. + + Features: + - Multi-version support + - Multiple file types (PDF, Word, Excel, etc.) + - Role-based access control + - Download tracking + - Bilingual support + - Soft delete support + """ + # Folder association + folder = models.ForeignKey( + ReferenceFolder, + on_delete=models.CASCADE, + related_name='documents', + null=True, + blank=True, + help_text="Folder containing this document" + ) + + # Bilingual title + title = models.CharField(max_length=500, db_index=True) + title_ar = models.CharField(max_length=500, blank=True, verbose_name="Title (Arabic)") + + # File information + file = models.FileField(upload_to=document_upload_path, max_length=500) + filename = models.CharField(max_length=500, help_text="Original filename") + file_type = models.CharField( + max_length=50, + blank=True, + help_text="File extension/type (e.g., pdf, docx, xlsx)" + ) + file_size = models.IntegerField(help_text="File size in bytes") + + # Description + description = models.TextField(blank=True) + description_ar = models.TextField(blank=True, verbose_name="Description (Arabic)") + + # Versioning + version = models.CharField( + max_length=20, + default='1.0', + help_text="Document version (e.g., 1.0, 1.1, 2.0)" + ) + is_latest_version = models.BooleanField( + default=True, + db_index=True, + help_text="Is this the latest version?" + ) + parent_document = models.ForeignKey( + 'self', + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name='versions', + help_text="Previous version of this document" + ) + + # Upload tracking + uploaded_by = models.ForeignKey( + User, + on_delete=models.SET_NULL, + null=True, + related_name='uploaded_documents' + ) + + # Usage tracking + download_count = models.IntegerField(default=0, help_text="Number of downloads") + last_accessed_at = models.DateTimeField(null=True, blank=True) + + # Visibility + is_published = models.BooleanField( + default=True, + db_index=True, + help_text="Is this document visible to users?" + ) + + # Role-based access control + access_roles = models.ManyToManyField( + 'auth.Group', + blank=True, + related_name='accessible_documents', + help_text="Roles that can access this document (empty = all roles)" + ) + + # Tags for search + tags = models.CharField( + max_length=500, + blank=True, + help_text="Comma-separated tags for search (e.g., policy, procedure, handbook)" + ) + + # Additional metadata + metadata = models.JSONField(default=dict, blank=True) + + class Meta: + ordering = ['title', '-created_at'] + verbose_name = 'Reference Document' + verbose_name_plural = 'Reference Documents' + indexes = [ + models.Index(fields=['hospital', 'folder', 'is_latest_version']), + models.Index(fields=['hospital', 'is_published']), + models.Index(fields=['folder', 'title']), + ] + + def __str__(self): + title = self.title or self.filename + version = f" v{self.version}" if self.version else "" + return f"{title}{version}" + + def clean(self): + """Validate file type""" + if self.file: + ext = os.path.splitext(self.file.name)[1].lower().lstrip('.') + # Allowed file types - can be extended as needed + allowed_types = [ + 'pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', + 'txt', 'rtf', 'csv', 'jpg', 'jpeg', 'png', 'gif' + ] + if ext not in allowed_types: + raise ValidationError({ + 'file': f'File type "{ext}" is not allowed. Allowed types: {", ".join(allowed_types)}' + }) + + def save(self, *args, **kwargs): + """Auto-populate file_type and file_size""" + if self.file: + # Extract file extension + ext = os.path.splitext(self.file.name)[1].lower().lstrip('.') + self.file_type = ext + + # Get file size if not already set + if not self.file_size and hasattr(self.file, 'size'): + self.file_size = self.file.size + + super().save(*args, **kwargs) + + def has_access(self, user): + """ + Check if user has access to this document. + + Returns True if: + - Document has no access_roles (public to all) + - User belongs to any of the access_roles + """ + if not self.access_roles.exists(): + return True + return user.groups.filter(id__in=self.access_roles.all()).exists() + + def increment_download_count(self): + """Increment download count and update last_accessed_at""" + self.download_count += 1 + from django.utils import timezone + self.last_accessed_at = timezone.now() + self.save(update_fields=['download_count', 'last_accessed_at']) + + def get_tags_list(self): + """Get tags as a list""" + return [tag.strip() for tag in self.tags.split(',') if tag.strip()] + + def get_file_icon(self): + """Get icon class based on file type""" + icon_map = { + 'pdf': 'fa-file-pdf text-danger', + 'doc': 'fa-file-word text-primary', + 'docx': 'fa-file-word text-primary', + 'xls': 'fa-file-excel text-success', + 'xlsx': 'fa-file-excel text-success', + 'ppt': 'fa-file-powerpoint text-warning', + 'pptx': 'fa-file-powerpoint text-warning', + 'jpg': 'fa-file-image text-info', + 'jpeg': 'fa-file-image text-info', + 'png': 'fa-file-image text-info', + 'gif': 'fa-file-image text-info', + 'txt': 'fa-file-lines text-secondary', + } + return icon_map.get(self.file_type.lower(), 'fa-file text-secondary') + + def get_file_size_display(self): + """Get human-readable file size""" + size = self.file_size + for unit in ['B', 'KB', 'MB', 'GB']: + if size < 1024: + return f"{size:.2f} {unit}" + size /= 1024 + return f"{size:.2f} TB" + + def get_version_history(self): + """Get all versions of this document""" + versions = list(self.versions.all()) + versions.append(self) + return sorted(versions, key=lambda x: x.version, reverse=True) + + +class ReferenceDocumentAccess(UUIDModel, TimeStampedModel): + """ + Reference Document Access model for tracking document access. + + Features: + - Audit trail for document downloads/views + - Track user actions on documents + - IP address logging + """ + ACTION_CHOICES = [ + ('view', 'Viewed'), + ('download', 'Downloaded'), + ('preview', 'Previewed'), + ] + + # Document reference + document = models.ForeignKey( + ReferenceDocument, + on_delete=models.CASCADE, + related_name='access_logs' + ) + + # User who accessed the document + user = models.ForeignKey( + User, + on_delete=models.SET_NULL, + null=True, + related_name='document_accesses' + ) + + # Action type + action = models.CharField( + max_length=20, + choices=ACTION_CHOICES, + db_index=True + ) + + # Context information + ip_address = models.GenericIPAddressField(null=True, blank=True) + user_agent = models.TextField(blank=True) + + class Meta: + ordering = ['-created_at'] + verbose_name = 'Document Access Log' + verbose_name_plural = 'Document Access Logs' + indexes = [ + models.Index(fields=['document', '-created_at']), + models.Index(fields=['user', '-created_at']), + models.Index(fields=['action', '-created_at']), + ] + + def __str__(self): + user_str = self.user.email if self.user else 'Anonymous' + return f"{user_str} - {self.get_action_display()} - {self.document}" diff --git a/apps/references/ui_views.py b/apps/references/ui_views.py new file mode 100644 index 0000000..edc5dfe --- /dev/null +++ b/apps/references/ui_views.py @@ -0,0 +1,526 @@ +""" +References UI views - Server-rendered templates for Reference Section +""" +from django.contrib.auth.decorators import login_required +from django.contrib import messages +from django.core.paginator import Paginator +from django.db.models import Q +from django.http import JsonResponse +from django.shortcuts import get_object_or_404, render, redirect +from django.views.decorators.http import require_http_methods + +from apps.references.models import ReferenceFolder, ReferenceDocument +from apps.references.forms import ReferenceFolderForm, ReferenceDocumentForm, ReferenceDocumentSearchForm + + +@login_required +def reference_dashboard(request): + """ + Reference dashboard - Main entry point for Reference Section. + + Shows: + - Root folders + - Recent documents + - Quick search + - Statistics + """ + # Get current hospital - use tenant_hospital for PX admins, user.hospital for regular users + hospital = getattr(request, 'tenant_hospital', None) or request.user.hospital + if not hospital: + return render(request, 'core/no_hospital_assigned.html') + + # Get root folders (no parent) + folders = ReferenceFolder.objects.filter( + hospital=hospital, + parent=None, + is_active=True, + is_deleted=False + ).order_by('order', 'name') + + # Filter by access + folders = [f for f in folders if f.has_access(request.user)] + + # Get recent documents (last 10) + recent_documents = ReferenceDocument.objects.filter( + hospital=hospital, + is_published=True, + is_deleted=False + ).select_related('folder').order_by('-created_at')[:10] + + # Filter by access + recent_documents = [d for d in recent_documents if d.has_access(request.user)] + + # Statistics + total_folders = ReferenceFolder.objects.filter( + hospital=hospital, + is_active=True, + is_deleted=False + ).count() + + total_documents = ReferenceDocument.objects.filter( + hospital=hospital, + is_published=True, + is_deleted=False + ).count() + + context = { + 'folders': folders, + 'recent_documents': recent_documents, + 'total_folders': total_folders, + 'total_documents': total_documents, + } + + return render(request, 'references/dashboard.html', context) + + +@login_required +def folder_view(request, pk=None): + """ + Folder view - Browse folders and documents. + + If pk is provided, shows contents of that folder. + Otherwise, shows root level folders. + """ + # Get current hospital - use tenant_hospital for PX admins, user.hospital for regular users + hospital = getattr(request, 'tenant_hospital', None) or request.user.hospital + if not hospital: + return render(request, 'core/no_hospital_assigned.html') + + current_folder = None + breadcrumb = [] + subfolders = [] + documents = [] + + if pk: + current_folder = get_object_or_404( + ReferenceFolder, + pk=pk, + hospital=hospital, + is_deleted=False + ) + + # Check access + if not current_folder.has_access(request.user): + messages.error(request, "You don't have permission to access this folder.") + return redirect('references:dashboard') + + # Build breadcrumb + breadcrumb = [] + folder = current_folder + while folder: + breadcrumb.insert(0, folder) + folder = folder.parent + + # Get subfolders + subfolders = current_folder.get_subfolders() + subfolders = [f for f in subfolders if f.has_access(request.user)] + + # Get documents + documents = current_folder.get_documents() + documents = [d for d in documents if d.has_access(request.user)] + else: + # Root level - show root folders + subfolders = ReferenceFolder.objects.filter( + hospital=hospital, + parent=None, + is_active=True, + is_deleted=False + ).order_by('order', 'name') + + subfolders = [f for f in subfolders if f.has_access(request.user)] + + context = { + 'current_folder': current_folder, + 'breadcrumb': breadcrumb, + 'subfolders': subfolders, + 'documents': documents, + } + + return render(request, 'references/folder_view.html', context) + + +@login_required +@require_http_methods(["GET", "POST"]) +def folder_create(request, parent_pk=None): + """Create a new folder""" + # Get current hospital - use tenant_hospital for PX admins, user.hospital for regular users + hospital = getattr(request, 'tenant_hospital', None) or request.user.hospital + if not hospital: + messages.error(request, "No hospital assigned.") + return redirect('references:dashboard') + + parent = None + if parent_pk: + parent = get_object_or_404(ReferenceFolder, pk=parent_pk, hospital=hospital, is_deleted=False) + if not parent.has_access(request.user): + messages.error(request, "You don't have permission to access this folder.") + return redirect('references:dashboard') + + if request.method == 'POST': + form = ReferenceFolderForm(request.POST, hospital=hospital) + if form.is_valid(): + folder = form.save(commit=False) + folder.hospital = hospital + folder.parent = parent + folder.save() + form.save_m2m() + + messages.success(request, f"Folder '{folder.name}' created successfully.") + if parent: + return redirect('references:folder_view', pk=parent_pk) + return redirect('references:dashboard') + else: + form = ReferenceFolderForm(hospital=hospital) + if parent: + form.fields['parent'].initial = parent + + # Get all folders for parent dropdown with level info + all_folders = [] + for f in ReferenceFolder.objects.filter(hospital=hospital, is_deleted=False): + level = 0 + temp = f + while temp.parent: + level += 1 + temp = temp.parent + # Create a display string with appropriate number of dashes + f.level_display = '—' * level + all_folders.append(f) + + context = { + 'form': form, + 'folder': None, + 'parent': parent, + 'all_folders': all_folders, + } + + return render(request, 'references/folder_form.html', context) + + +@login_required +@require_http_methods(["GET", "POST"]) +def folder_edit(request, pk): + """Edit a folder""" + # Get current hospital - use tenant_hospital for PX admins, user.hospital for regular users + hospital = getattr(request, 'tenant_hospital', None) or request.user.hospital + if not hospital: + messages.error(request, "No hospital assigned.") + return redirect('references:dashboard') + + folder = get_object_or_404(ReferenceFolder, pk=pk, hospital=hospital, is_deleted=False) + + # Check access + if not folder.has_access(request.user): + messages.error(request, "You don't have permission to edit this folder.") + return redirect('references:dashboard') + + if request.method == 'POST': + form = ReferenceFolderForm(request.POST, instance=folder, hospital=hospital) + if form.is_valid(): + form.save() + messages.success(request, f"Folder '{folder.name}' updated successfully.") + if folder.parent: + return redirect('references:folder_view', pk=folder.parent.id) + return redirect('references:dashboard') + else: + form = ReferenceFolderForm(instance=folder, hospital=hospital) + + # Get all folders for parent dropdown with level info + all_folders = [] + for f in ReferenceFolder.objects.filter(hospital=hospital, is_deleted=False): + level = 0 + temp = f + while temp.parent: + level += 1 + temp = temp.parent + # Create a display string with appropriate number of dashes + f.level_display = '—' * level + all_folders.append(f) + + context = { + 'form': form, + 'folder': folder, + 'all_folders': all_folders, + } + + return render(request, 'references/folder_form.html', context) + + +@login_required +@require_http_methods(["POST"]) +def folder_delete(request, pk): + """Soft delete a folder""" + # Get current hospital - use tenant_hospital for PX admins, user.hospital for regular users + hospital = getattr(request, 'tenant_hospital', None) or request.user.hospital + if not hospital: + return JsonResponse({'success': False, 'error': 'No hospital assigned'}, status=400) + + folder = get_object_or_404(ReferenceFolder, pk=pk, hospital=hospital, is_deleted=False) + + # Check access + if not folder.has_access(request.user): + return JsonResponse({'success': False, 'error': 'Permission denied'}, status=403) + + # Soft delete + folder.is_deleted = True + folder.save() + + return JsonResponse({'success': True}) + + +@login_required +def document_view(request, pk): + """Document detail view""" + # Get current hospital - use tenant_hospital for PX admins, user.hospital for regular users + hospital = getattr(request, 'tenant_hospital', None) or request.user.hospital + if not hospital: + return render(request, 'core/no_hospital_assigned.html') + + document = get_object_or_404( + ReferenceDocument.objects.select_related('folder', 'uploaded_by'), + pk=pk, + hospital=hospital, + is_deleted=False + ) + + # Check access + if not document.has_access(request.user): + messages.error(request, "You don't have permission to access this document.") + return redirect('references:dashboard') + + # Get version history + versions = document.get_version_history() + + # Get related documents (same folder) + related_documents = [] + if document.folder: + related_documents = ReferenceDocument.objects.filter( + folder=document.folder, + is_published=True, + is_deleted=False + ).exclude(pk=document.pk)[:5] + related_documents = [d for d in related_documents if d.has_access(request.user)] + + context = { + 'document': document, + 'versions': versions, + 'related_documents': related_documents, + } + + return render(request, 'references/document_view.html', context) + + +@login_required +@require_http_methods(["GET", "POST"]) +def document_create(request, folder_pk=None): + """Upload a new document""" + # Get current hospital - use tenant_hospital for PX admins, user.hospital for regular users + hospital = getattr(request, 'tenant_hospital', None) or request.user.hospital + if not hospital: + messages.error(request, "No hospital assigned.") + return redirect('references:dashboard') + + folder = None + if folder_pk: + folder = get_object_or_404(ReferenceFolder, pk=folder_pk, hospital=hospital, is_deleted=False) + if not folder.has_access(request.user): + messages.error(request, "You don't have permission to access this folder.") + return redirect('references:dashboard') + + if request.method == 'POST': + form = ReferenceDocumentForm(request.POST, request.FILES, hospital=hospital, folder=folder) + if form.is_valid(): + document = form.save(commit=False) + document.hospital = hospital + # Use folder from form, fallback to URL parameter folder + document.folder = form.cleaned_data.get('folder') or folder + document.uploaded_by = request.user + if 'file' in request.FILES: + document.filename = request.FILES['file'].name + document.save() + form.save_m2m() + + messages.success(request, f"Document '{document.title}' uploaded successfully.") + if document.folder: + return redirect('references:folder_view', pk=document.folder.id) + return redirect('references:dashboard') + else: + # Show error to user + messages.error(request, "Please fix the errors below to upload the document.") + else: + form = ReferenceDocumentForm(hospital=hospital, folder=folder) + + # Get all folders for dropdown with level info + all_folders = [] + for f in ReferenceFolder.objects.filter(hospital=hospital, is_deleted=False): + level = 0 + temp = f + while temp.parent: + level += 1 + temp = temp.parent + # Create a display string with appropriate number of dashes + f.level_display = '—' * level + all_folders.append(f) + + context = { + 'form': form, + 'folder': folder, + 'all_folders': all_folders, + } + + return render(request, 'references/document_form.html', context) + + +@login_required +@require_http_methods(["GET", "POST"]) +def document_edit(request, pk): + """Edit a document""" + # Get current hospital - use tenant_hospital for PX admins, user.hospital for regular users + hospital = getattr(request, 'tenant_hospital', None) or request.user.hospital + if not hospital: + messages.error(request, "No hospital assigned.") + return redirect('references:dashboard') + + document = get_object_or_404(ReferenceDocument, pk=pk, hospital=hospital, is_deleted=False) + + # Check access + if not document.has_access(request.user): + messages.error(request, "You don't have permission to edit this document.") + return redirect('references:dashboard') + + if request.method == 'POST': + form = ReferenceDocumentForm(request.POST, request.FILES, instance=document, hospital=hospital) + if form.is_valid(): + # Check if uploading as new version + new_version = form.cleaned_data.get('new_version') + file = request.FILES.get('file') + + if new_version and file: + # Create new version + old_document = document + document.pk = None # Create new instance + document.parent_document = old_document + document.uploaded_by = request.user + document.filename = file.name + document.save() + form.save_m2m() + + # Mark old version as not latest + old_document.is_latest_version = False + old_document.save() + + messages.success(request, f"New version '{document.title}' created successfully.") + else: + # Just update metadata + if file: + document.filename = file.name + form.save() + messages.success(request, f"Document '{document.title}' updated successfully.") + + return redirect('references:document_view', pk=document.id) + else: + form = ReferenceDocumentForm(instance=document, hospital=hospital) + + # Get all folders for dropdown with level info + all_folders = [] + for f in ReferenceFolder.objects.filter(hospital=hospital, is_deleted=False): + level = 0 + temp = f + while temp.parent: + level += 1 + temp = temp.parent + # Create a display string with appropriate number of dashes + f.level_display = '—' * level + all_folders.append(f) + + context = { + 'form': form, + 'document': document, + 'all_folders': all_folders, + } + + return render(request, 'references/document_form.html', context) + + +@login_required +@require_http_methods(["POST"]) +def document_delete(request, pk): + """Soft delete a document""" + # Get current hospital - use tenant_hospital for PX admins, user.hospital for regular users + hospital = getattr(request, 'tenant_hospital', None) or request.user.hospital + if not hospital: + return JsonResponse({'success': False, 'error': 'No hospital assigned'}, status=400) + + document = get_object_or_404(ReferenceDocument, pk=pk, hospital=hospital, is_deleted=False) + + # Check access + if not document.has_access(request.user): + return JsonResponse({'success': False, 'error': 'Permission denied'}, status=403) + + # Soft delete + document.is_deleted = True + document.save() + + return JsonResponse({'success': True}) + + +@login_required +def search(request): + """ + Search documents across all folders. + """ + # Get current hospital - use tenant_hospital for PX admins, user.hospital for regular users + hospital = getattr(request, 'tenant_hospital', None) or request.user.hospital + if not hospital: + return render(request, 'core/no_hospital_assigned.html') + + form = ReferenceDocumentSearchForm(request.GET or None, hospital=hospital) + documents = [] + + if form.is_valid(): + documents = ReferenceDocument.objects.filter( + hospital=hospital, + is_published=True, + is_deleted=False + ).select_related('folder') + + # Apply filters + search = form.cleaned_data.get('search') + if search: + documents = documents.filter( + Q(title__icontains=search) | + Q(title_ar__icontains=search) | + Q(description__icontains=search) | + Q(description_ar__icontains=search) | + Q(tags__icontains=search) + ) + + folder = form.cleaned_data.get('folder') + if folder: + documents = documents.filter(folder=folder) + + file_type = form.cleaned_data.get('file_type') + if file_type: + documents = documents.filter(file_type__iexact=file_type) + + tags = form.cleaned_data.get('tags') + if tags: + tag_list = [tag.strip() for tag in tags.split(',')] + for tag in tag_list: + documents = documents.filter(tags__icontains=tag) + + # Filter by access + documents = [d for d in documents if d.has_access(request.user)] + + # Pagination + page_size = int(request.GET.get('page_size', 20)) + paginator = Paginator(documents, page_size) + page_number = request.GET.get('page', 1) + page_obj = paginator.get_page(page_number) + + context = { + 'form': form, + 'page_obj': page_obj, + 'documents': page_obj.object_list, + } + + return render(request, 'references/search.html', context) diff --git a/apps/references/urls.py b/apps/references/urls.py new file mode 100644 index 0000000..f7ff9be --- /dev/null +++ b/apps/references/urls.py @@ -0,0 +1,44 @@ +""" +References URL configuration +""" +from django.urls import path + +from . import views, ui_views + +app_name = 'references' + +# API Endpoints +urlpatterns_api = [ + path('api/folders/', views.folder_list, name='api_folder_list'), + path('api/folders//', views.folder_detail, name='api_folder_detail'), + path('api/documents/', views.document_list, name='api_document_list'), + path('api/documents//', views.document_detail, name='api_document_detail'), + path('api/documents//download/', views.document_download, name='api_document_download'), + path('api/documents//versions/', views.document_version_history, name='api_document_versions'), +] + +# UI Views +urlpatterns_ui = [ + # Dashboard + path('', ui_views.reference_dashboard, name='dashboard'), + + # Folders + path('folders/', ui_views.folder_view, name='folder_view'), + path('folders/new/', ui_views.folder_create, name='folder_create'), + path('folders/new//', ui_views.folder_create, name='folder_create_in_parent'), + path('folders//', ui_views.folder_view, name='folder_view'), + path('folders//edit/', ui_views.folder_edit, name='folder_edit'), + path('folders//delete/', ui_views.folder_delete, name='folder_delete'), + + # Documents + path('documents/new/', ui_views.document_create, name='document_create'), + path('documents/new//', ui_views.document_create, name='document_create_in_folder'), + path('documents//', ui_views.document_view, name='document_view'), + path('documents//edit/', ui_views.document_edit, name='document_edit'), + path('documents//delete/', ui_views.document_delete, name='document_delete'), + + # Search + path('search/', ui_views.search, name='search'), +] + +urlpatterns = urlpatterns_ui + urlpatterns_api diff --git a/apps/references/views.py b/apps/references/views.py new file mode 100644 index 0000000..01c95d2 --- /dev/null +++ b/apps/references/views.py @@ -0,0 +1,270 @@ +""" +References views - View functions for Reference Section +""" +from django.contrib.auth.decorators import login_required +from django.core.exceptions import PermissionDenied +from django.http import JsonResponse, Http404, FileResponse +from django.shortcuts import get_object_or_404, redirect +from django.utils import timezone + +from apps.core.mixins import TenantRequiredMixin +from apps.references.models import ReferenceFolder, ReferenceDocument, ReferenceDocumentAccess +from apps.references.forms import ReferenceFolderForm, ReferenceDocumentForm + + +@login_required +def folder_list(request): + """API endpoint to list folders for the current hospital""" + # Get current hospital - use tenant_hospital for PX admins, user.hospital for regular users + hospital = getattr(request, 'tenant_hospital', None) or request.user.hospital + if not hospital: + return JsonResponse({'error': 'No hospital assigned'}, status=400) + + parent_id = request.GET.get('parent') + folders = ReferenceFolder.objects.filter( + hospital=hospital, + is_active=True, + is_deleted=False + ) + + if parent_id: + try: + parent = ReferenceFolder.objects.get(id=parent_id, hospital=hospital) + folders = folders.filter(parent=parent) + except ReferenceFolder.DoesNotExist: + folders = folders.filter(parent=None) + else: + folders = folders.filter(parent=None) + + # Filter by access + folders = [f for f in folders if f.has_access(request.user)] + + data = [ + { + 'id': str(folder.id), + 'name': folder.name, + 'name_ar': folder.name_ar, + 'description': folder.description, + 'description_ar': folder.description_ar, + 'icon': folder.icon, + 'color': folder.color, + 'document_count': folder.get_document_count(), + 'order': folder.order + } + for folder in folders + ] + + return JsonResponse({'folders': data}) + + +@login_required +def folder_detail(request, pk): + """API endpoint to get folder details""" + # Get current hospital - use tenant_hospital for PX admins, user.hospital for regular users + hospital = getattr(request, 'tenant_hospital', None) or request.user.hospital + if not hospital: + return JsonResponse({'error': 'No hospital assigned'}, status=400) + + folder = get_object_or_404(ReferenceFolder, pk=pk, hospital=hospital, is_deleted=False) + + if not folder.has_access(request.user): + raise PermissionDenied() + + data = { + 'id': str(folder.id), + 'name': folder.name, + 'name_ar': folder.name_ar, + 'description': folder.description, + 'description_ar': folder.description_ar, + 'icon': folder.icon, + 'color': folder.color, + 'document_count': folder.get_document_count(), + 'full_path': folder.get_full_path() + } + + return JsonResponse({'folder': data}) + + +@login_required +def document_list(request): + """API endpoint to list documents with filtering""" + # Get current hospital - use tenant_hospital for PX admins, user.hospital for regular users + hospital = getattr(request, 'tenant_hospital', None) or request.user.hospital + if not hospital: + return JsonResponse({'error': 'No hospital assigned'}, status=400) + + documents = ReferenceDocument.objects.filter( + hospital=hospital, + is_published=True, + is_deleted=False + ) + + # Filter by folder + folder_id = request.GET.get('folder') + if folder_id: + try: + folder = ReferenceFolder.objects.get(id=folder_id, hospital=hospital) + documents = documents.filter(folder=folder) + except ReferenceFolder.DoesNotExist: + pass + + # Filter by file type + file_type = request.GET.get('file_type') + if file_type: + documents = documents.filter(file_type__iexact=file_type) + + # Search + search = request.GET.get('search') + if search: + from django.db.models import Q + documents = documents.filter( + Q(title__icontains=search) | + Q(title_ar__icontains=search) | + Q(description__icontains=search) | + Q(description_ar__icontains=search) | + Q(tags__icontains=search) + ) + + # Filter by tags + tags = request.GET.get('tags') + if tags: + tag_list = [tag.strip() for tag in tags.split(',')] + for tag in tag_list: + documents = documents.filter(tags__icontains=tag) + + # Filter by access + documents = [d for d in documents if d.has_access(request.user)] + + data = [ + { + 'id': str(doc.id), + 'title': doc.title, + 'title_ar': doc.title_ar, + 'description': doc.description, + 'description_ar': doc.description_ar, + 'filename': doc.filename, + 'file_type': doc.file_type, + 'file_size': doc.file_size, + 'file_size_display': doc.get_file_size_display(), + 'version': doc.version, + 'is_latest_version': doc.is_latest_version, + 'download_count': doc.download_count, + 'tags': doc.tags, + 'folder_id': str(doc.folder.id) if doc.folder else None, + 'folder_name': doc.folder.name if doc.folder else None, + 'created_at': doc.created_at.isoformat(), + 'last_accessed_at': doc.last_accessed_at.isoformat() if doc.last_accessed_at else None + } + for doc in documents + ] + + return JsonResponse({'documents': data}) + + +@login_required +def document_detail(request, pk): + """API endpoint to get document details""" + # Get current hospital - use tenant_hospital for PX admins, user.hospital for regular users + hospital = getattr(request, 'tenant_hospital', None) or request.user.hospital + if not hospital: + return JsonResponse({'error': 'No hospital assigned'}, status=400) + + document = get_object_or_404(ReferenceDocument, pk=pk, hospital=hospital, is_deleted=False) + + if not document.has_access(request.user): + raise PermissionDenied() + + data = { + 'id': str(document.id), + 'title': document.title, + 'title_ar': document.title_ar, + 'description': document.description, + 'description_ar': document.description_ar, + 'filename': document.filename, + 'file_type': document.file_type, + 'file_size': document.file_size, + 'file_size_display': document.get_file_size_display(), + 'version': document.version, + 'is_latest_version': document.is_latest_version, + 'download_count': document.download_count, + 'tags': document.tags, + 'folder_id': str(document.folder.id) if document.folder else None, + 'folder_name': document.folder.name if document.folder else None, + 'created_at': document.created_at.isoformat(), + 'last_accessed_at': document.last_accessed_at.isoformat() if document.last_accessed_at else None, + 'uploaded_by': { + 'email': document.uploaded_by.email, + 'full_name': document.uploaded_by.get_full_name() + } if document.uploaded_by else None + } + + return JsonResponse({'document': data}) + + +@login_required +def document_download(request, pk): + """ + Handle document download with access logging + """ + # Get current hospital - use tenant_hospital for PX admins, user.hospital for regular users + hospital = getattr(request, 'tenant_hospital', None) or request.user.hospital + if not hospital: + raise Http404("No hospital assigned") + + document = get_object_or_404(ReferenceDocument, pk=pk, hospital=hospital, is_deleted=False) + + if not document.has_access(request.user): + raise PermissionDenied() + + # Increment download count + document.increment_download_count() + + # Log the access + ReferenceDocumentAccess.objects.create( + document=document, + user=request.user, + action='download', + ip_address=request.META.get('REMOTE_ADDR'), + user_agent=request.META.get('HTTP_USER_AGENT', '') + ) + + # Return file response + if document.file: + return FileResponse( + document.file, + as_attachment=True, + filename=document.filename + ) + + raise Http404("File not found") + + +@login_required +def document_version_history(request, pk): + """API endpoint to get version history of a document""" + # Get current hospital - use tenant_hospital for PX admins, user.hospital for regular users + hospital = getattr(request, 'tenant_hospital', None) or request.user.hospital + if not hospital: + return JsonResponse({'error': 'No hospital assigned'}, status=400) + + document = get_object_or_404(ReferenceDocument, pk=pk, hospital=hospital, is_deleted=False) + + if not document.has_access(request.user): + raise PermissionDenied() + + versions = document.get_version_history() + + data = [ + { + 'id': str(ver.id), + 'version': ver.version, + 'is_latest_version': ver.is_latest_version, + 'filename': ver.filename, + 'file_size': ver.file_size, + 'file_size_display': ver.get_file_size_display(), + 'created_at': ver.created_at.isoformat() + } + for ver in versions + ] + + return JsonResponse({'versions': data}) diff --git a/apps/standards/__init__.py b/apps/standards/__init__.py new file mode 100644 index 0000000..add8867 --- /dev/null +++ b/apps/standards/__init__.py @@ -0,0 +1 @@ +default_app_config = 'apps.standards.apps.StandardsConfig' diff --git a/apps/standards/admin.py b/apps/standards/admin.py new file mode 100644 index 0000000..978e860 --- /dev/null +++ b/apps/standards/admin.py @@ -0,0 +1,52 @@ +from django.contrib import admin + +from apps.standards.models import ( + StandardSource, + StandardCategory, + Standard, + StandardCompliance, + StandardAttachment +) + + +@admin.register(StandardSource) +class StandardSourceAdmin(admin.ModelAdmin): + list_display = ['name', 'code', 'is_active', 'created_at'] + list_filter = ['is_active', 'created_at'] + search_fields = ['name', 'name_ar', 'code', 'description'] + ordering = ['name'] + + +@admin.register(StandardCategory) +class StandardCategoryAdmin(admin.ModelAdmin): + list_display = ['name', 'order', 'is_active', 'created_at'] + list_filter = ['is_active', 'created_at'] + search_fields = ['name', 'name_ar', 'description'] + ordering = ['order', 'name'] + + +@admin.register(Standard) +class StandardAdmin(admin.ModelAdmin): + list_display = ['code', 'title', 'source', 'category', 'department', 'is_active', 'effective_date'] + list_filter = ['source', 'category', 'department', 'is_active', 'effective_date'] + search_fields = ['code', 'title', 'title_ar', 'description'] + ordering = ['source', 'category', 'code'] + date_hierarchy = 'effective_date' + + +@admin.register(StandardCompliance) +class StandardComplianceAdmin(admin.ModelAdmin): + list_display = ['department', 'standard', 'status', 'last_assessed_date', 'assessor', 'created_at'] + list_filter = ['status', 'last_assessed_date', 'created_at'] + search_fields = ['department__name', 'standard__code', 'standard__title', 'notes', 'evidence_summary'] + ordering = ['-created_at'] + raw_id_fields = ['department', 'standard', 'assessor'] + + +@admin.register(StandardAttachment) +class StandardAttachmentAdmin(admin.ModelAdmin): + list_display = ['filename', 'compliance', 'uploaded_by', 'created_at'] + list_filter = ['created_at'] + search_fields = ['filename', 'description'] + ordering = ['-created_at'] + raw_id_fields = ['compliance', 'uploaded_by'] diff --git a/apps/standards/apps.py b/apps/standards/apps.py new file mode 100644 index 0000000..5aac9d4 --- /dev/null +++ b/apps/standards/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class StandardsConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'apps.standards' + verbose_name = 'Standards Section' diff --git a/apps/standards/forms.py b/apps/standards/forms.py new file mode 100644 index 0000000..bb005ff --- /dev/null +++ b/apps/standards/forms.py @@ -0,0 +1,66 @@ +from django import forms +from django.core.validators import FileExtensionValidator + +from apps.standards.models import ( + StandardSource, + StandardCategory, + Standard, + StandardCompliance, + StandardAttachment +) + + +class StandardSourceForm(forms.ModelForm): + class Meta: + model = StandardSource + fields = ['name', 'name_ar', 'code', 'description', 'website', 'is_active'] + widgets = { + 'description': forms.Textarea(attrs={'rows': 3}), + } + + +class StandardCategoryForm(forms.ModelForm): + class Meta: + model = StandardCategory + fields = ['name', 'name_ar', 'description', 'order', 'is_active'] + widgets = { + 'description': forms.Textarea(attrs={'rows': 3}), + } + + +class StandardForm(forms.ModelForm): + class Meta: + model = Standard + fields = ['code', 'title', 'title_ar', 'description', + 'department', 'effective_date', 'review_date', 'is_active'] + widgets = { + 'description': forms.Textarea(attrs={'rows': 5}), + 'effective_date': forms.DateInput(attrs={'type': 'date'}), + 'review_date': forms.DateInput(attrs={'type': 'date'}), + } + + +class StandardComplianceForm(forms.ModelForm): + class Meta: + model = StandardCompliance + fields = ['status', 'last_assessed_date', 'assessor', 'notes', 'evidence_summary'] + widgets = { + 'last_assessed_date': forms.DateInput(attrs={'type': 'date'}), + 'notes': forms.Textarea(attrs={'rows': 3}), + 'evidence_summary': forms.Textarea(attrs={'rows': 3}), + } + + +class StandardAttachmentForm(forms.ModelForm): + class Meta: + model = StandardAttachment + fields = ['file', 'description'] + widgets = { + 'description': forms.Textarea(attrs={'rows': 2}), + } + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields['file'].widget.attrs.update({ + 'accept': '.pdf,.doc,.docx,.xls,.xlsx,.jpg,.jpeg,.png,.zip' + }) diff --git a/apps/standards/migrations/0001_initial.py b/apps/standards/migrations/0001_initial.py new file mode 100644 index 0000000..efffe83 --- /dev/null +++ b/apps/standards/migrations/0001_initial.py @@ -0,0 +1,119 @@ +# Generated by Django 5.0.14 on 2026-01-07 20:27 + +import django.core.validators +import django.db.models.deletion +import uuid +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('organizations', '0005_alter_staff_department'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='StandardCategory', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('name', models.CharField(max_length=100)), + ('name_ar', models.CharField(blank=True, max_length=100, verbose_name='Name (Arabic)')), + ('description', models.TextField(blank=True)), + ('order', models.PositiveIntegerField(default=0, help_text='Display order')), + ('is_active', models.BooleanField(db_index=True, default=True)), + ], + options={ + 'verbose_name': 'Standard Category', + 'verbose_name_plural': 'Standard Categories', + 'ordering': ['order', 'name'], + }, + ), + migrations.CreateModel( + name='StandardSource', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('name', models.CharField(max_length=100)), + ('name_ar', models.CharField(blank=True, max_length=100, verbose_name='Name (Arabic)')), + ('code', models.CharField(db_index=True, max_length=50, unique=True)), + ('description', models.TextField(blank=True)), + ('website', models.URLField(blank=True)), + ('is_active', models.BooleanField(db_index=True, default=True)), + ], + options={ + 'verbose_name': 'Standard Source', + 'verbose_name_plural': 'Standard Sources', + 'ordering': ['name'], + }, + ), + migrations.CreateModel( + name='Standard', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('code', models.CharField(db_index=True, help_text='e.g., CBAHI-PS-01', max_length=50)), + ('title', models.CharField(max_length=300)), + ('title_ar', models.CharField(blank=True, max_length=300, verbose_name='Title (Arabic)')), + ('description', models.TextField(help_text='Full description of the standard')), + ('effective_date', models.DateField(blank=True, help_text='When standard becomes effective', null=True)), + ('review_date', models.DateField(blank=True, help_text='Next review date', null=True)), + ('is_active', models.BooleanField(db_index=True, default=True)), + ('department', models.ForeignKey(blank=True, help_text='Department-specific standard (null if applicable to all)', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='standards', to='organizations.department')), + ('category', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='standards', to='standards.standardcategory')), + ('source', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='standards', to='standards.standardsource')), + ], + options={ + 'verbose_name': 'Standard', + 'verbose_name_plural': 'Standards', + 'ordering': ['source', 'category', 'code'], + }, + ), + migrations.CreateModel( + name='StandardCompliance', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('status', models.CharField(choices=[('not_assessed', 'Not Assessed'), ('met', 'Met'), ('partially_met', 'Partially Met'), ('not_met', 'Not Met')], db_index=True, default='not_assessed', max_length=20)), + ('last_assessed_date', models.DateField(blank=True, help_text='Date of last assessment', null=True)), + ('notes', models.TextField(blank=True, help_text='Assessment notes')), + ('evidence_summary', models.TextField(blank=True, help_text='Summary of evidence')), + ('assessor', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='assessments', to=settings.AUTH_USER_MODEL)), + ('department', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='compliance_records', to='organizations.department')), + ('standard', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='compliance_records', to='standards.standard')), + ], + options={ + 'verbose_name': 'Standard Compliance', + 'verbose_name_plural': 'Standard Compliance', + 'ordering': ['-created_at'], + 'unique_together': {('department', 'standard')}, + }, + ), + migrations.CreateModel( + name='StandardAttachment', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, db_index=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('file', models.FileField(upload_to='standards/attachments/%Y/%m/', validators=[django.core.validators.FileExtensionValidator(allowed_extensions=['pdf', 'doc', 'docx', 'xls', 'xlsx', 'jpg', 'jpeg', 'png', 'zip'])])), + ('filename', models.CharField(help_text='Original filename', max_length=255)), + ('description', models.TextField(blank=True, help_text='Attachment description')), + ('uploaded_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='uploaded_standards_attachments', to=settings.AUTH_USER_MODEL)), + ('compliance', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='attachments', to='standards.standardcompliance')), + ], + options={ + 'verbose_name': 'Standard Attachment', + 'verbose_name_plural': 'Standard Attachments', + 'ordering': ['-created_at'], + }, + ), + ] diff --git a/apps/standards/migrations/__init__.py b/apps/standards/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/standards/models.py b/apps/standards/models.py new file mode 100644 index 0000000..f024b86 --- /dev/null +++ b/apps/standards/models.py @@ -0,0 +1,165 @@ +""" +Standards Section Models - Track compliance standards (CBAHI, MOH, CHI, etc.) +""" +from django.db import models +from django.core.validators import FileExtensionValidator + +from apps.core.models import TimeStampedModel, UUIDModel, StatusChoices + + +class StandardSource(UUIDModel, TimeStampedModel): + """Standard sources like CBAHI, MOH, CHI, JCI, etc.""" + name = models.CharField(max_length=100) + name_ar = models.CharField(max_length=100, blank=True, verbose_name="Name (Arabic)") + code = models.CharField(max_length=50, unique=True, db_index=True) + description = models.TextField(blank=True) + website = models.URLField(blank=True) + is_active = models.BooleanField(default=True, db_index=True) + + class Meta: + ordering = ['name'] + verbose_name = 'Standard Source' + verbose_name_plural = 'Standard Sources' + + def __str__(self): + return self.name + + +class StandardCategory(UUIDModel, TimeStampedModel): + """Group standards by category (Patient Safety, Quality Management, etc.)""" + name = models.CharField(max_length=100) + name_ar = models.CharField(max_length=100, blank=True, verbose_name="Name (Arabic)") + description = models.TextField(blank=True) + order = models.PositiveIntegerField(default=0, help_text="Display order") + is_active = models.BooleanField(default=True, db_index=True) + + class Meta: + ordering = ['order', 'name'] + verbose_name = 'Standard Category' + verbose_name_plural = 'Standard Categories' + + def __str__(self): + return self.name + + +class Standard(UUIDModel, TimeStampedModel): + """Actual standard requirements""" + class ComplianceStatus(models.TextChoices): + NOT_ASSESSED = 'not_assessed', 'Not Assessed' + MET = 'met', 'Met' + PARTIALLY_MET = 'partially_met', 'Partially Met' + NOT_MET = 'not_met', 'Not Met' + + code = models.CharField(max_length=50, db_index=True, help_text="e.g., CBAHI-PS-01") + title = models.CharField(max_length=300) + title_ar = models.CharField(max_length=300, blank=True, verbose_name="Title (Arabic)") + description = models.TextField(help_text="Full description of the standard") + + # Relationships + source = models.ForeignKey( + StandardSource, + on_delete=models.PROTECT, + related_name='standards' + ) + category = models.ForeignKey( + StandardCategory, + on_delete=models.PROTECT, + related_name='standards' + ) + department = models.ForeignKey( + 'organizations.Department', + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name='standards', + help_text="Department-specific standard (null if applicable to all)" + ) + + # Dates + effective_date = models.DateField(null=True, blank=True, help_text="When standard becomes effective") + review_date = models.DateField(null=True, blank=True, help_text="Next review date") + + is_active = models.BooleanField(default=True, db_index=True) + + class Meta: + ordering = ['source', 'category', 'code'] + verbose_name = 'Standard' + verbose_name_plural = 'Standards' + + def __str__(self): + return f"{self.code}: {self.title}" + + +class StandardCompliance(UUIDModel, TimeStampedModel): + """Track compliance status per department""" + class ComplianceStatus(models.TextChoices): + NOT_ASSESSED = 'not_assessed', 'Not Assessed' + MET = 'met', 'Met' + PARTIALLY_MET = 'partially_met', 'Partially Met' + NOT_MET = 'not_met', 'Not Met' + + department = models.ForeignKey( + 'organizations.Department', + on_delete=models.CASCADE, + related_name='compliance_records' + ) + standard = models.ForeignKey( + Standard, + on_delete=models.CASCADE, + related_name='compliance_records' + ) + status = models.CharField( + max_length=20, + choices=ComplianceStatus.choices, + default=ComplianceStatus.NOT_ASSESSED, + db_index=True + ) + last_assessed_date = models.DateField(null=True, blank=True, help_text="Date of last assessment") + assessor = models.ForeignKey( + 'accounts.User', + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name='assessments' + ) + notes = models.TextField(blank=True, help_text="Assessment notes") + evidence_summary = models.TextField(blank=True, help_text="Summary of evidence") + + class Meta: + ordering = ['-created_at'] + verbose_name = 'Standard Compliance' + verbose_name_plural = 'Standard Compliance' + unique_together = [['department', 'standard']] + + def __str__(self): + return f"{self.department.name} - {self.standard.code} - {self.status}" + + +class StandardAttachment(UUIDModel, TimeStampedModel): + """Proof of compliance - evidence documents""" + compliance = models.ForeignKey( + StandardCompliance, + on_delete=models.CASCADE, + related_name='attachments' + ) + file = models.FileField( + upload_to='standards/attachments/%Y/%m/', + validators=[FileExtensionValidator(allowed_extensions=['pdf', 'doc', 'docx', 'xls', 'xlsx', 'jpg', 'jpeg', 'png', 'zip'])] + ) + filename = models.CharField(max_length=255, help_text="Original filename") + description = models.TextField(blank=True, help_text="Attachment description") + uploaded_by = models.ForeignKey( + 'accounts.User', + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name='uploaded_standards_attachments' + ) + + class Meta: + ordering = ['-created_at'] + verbose_name = 'Standard Attachment' + verbose_name_plural = 'Standard Attachments' + + def __str__(self): + return f"{self.compliance} - {self.filename}" diff --git a/apps/standards/serializers.py b/apps/standards/serializers.py new file mode 100644 index 0000000..e668581 --- /dev/null +++ b/apps/standards/serializers.py @@ -0,0 +1,61 @@ +from rest_framework import serializers + +from apps.standards.models import ( + StandardSource, + StandardCategory, + Standard, + StandardCompliance, + StandardAttachment +) + + +class StandardSourceSerializer(serializers.ModelSerializer): + class Meta: + model = StandardSource + fields = '__all__' + read_only_fields = ['id', 'created_at', 'updated_at'] + + +class StandardCategorySerializer(serializers.ModelSerializer): + class Meta: + model = StandardCategory + fields = '__all__' + read_only_fields = ['id', 'created_at', 'updated_at'] + + +class StandardSerializer(serializers.ModelSerializer): + source_name = serializers.CharField(source='source.name', read_only=True) + category_name = serializers.CharField(source='category.name', read_only=True) + department_name = serializers.CharField(source='department.name', read_only=True) + + class Meta: + model = Standard + fields = '__all__' + read_only_fields = ['id', 'created_at', 'updated_at'] + + +class StandardComplianceSerializer(serializers.ModelSerializer): + department_name = serializers.CharField(source='department.name', read_only=True) + standard_code = serializers.CharField(source='standard.code', read_only=True) + standard_title = serializers.CharField(source='standard.title', read_only=True) + assessor_name = serializers.CharField(source='assessor.get_full_name', read_only=True) + attachment_count = serializers.SerializerMethodField() + + class Meta: + model = StandardCompliance + fields = '__all__' + read_only_fields = ['id', 'created_at', 'updated_at'] + + def get_attachment_count(self, obj): + return obj.attachments.count() + + +class StandardAttachmentSerializer(serializers.ModelSerializer): + uploaded_by_name = serializers.CharField(source='uploaded_by.get_full_name', read_only=True) + standard_code = serializers.CharField(source='compliance.standard.code', read_only=True) + department_name = serializers.CharField(source='compliance.department.name', read_only=True) + + class Meta: + model = StandardAttachment + fields = '__all__' + read_only_fields = ['id', 'uploaded_at', 'created_at', 'updated_at'] diff --git a/apps/standards/templatetags/__init__.py b/apps/standards/templatetags/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/standards/templatetags/standards_filters.py b/apps/standards/templatetags/standards_filters.py new file mode 100644 index 0000000..4e267fe --- /dev/null +++ b/apps/standards/templatetags/standards_filters.py @@ -0,0 +1,50 @@ +""" +Template filters for Standards app +""" +from django import template + +register = template.Library() + + +@register.filter +def get_unique(data_list, field_path): + """ + Get unique values from a list of dictionaries based on a dot-notation path. + + Usage: {{ standards_data|get_unique:"standard.source" }} + + Args: + data_list: List of dictionaries + field_path: Dot-separated path to the field (e.g., "standard.source") + + Returns: + List of unique values for the specified field path + """ + if not data_list: + return [] + + values = [] + seen = set() + + for item in data_list: + # Navigate through the dot-notation path + value = item + try: + for attr in field_path.split('.'): + if value is None: + break + # Handle both dict and object access + if isinstance(value, dict): + value = value.get(attr) + else: + value = getattr(value, attr, None) + + # Only add non-None values that haven't been seen + if value is not None and value not in seen: + values.append(value) + seen.add(value) + except (AttributeError, KeyError, TypeError): + # Skip items that don't have the path + continue + + return values diff --git a/apps/standards/urls.py b/apps/standards/urls.py new file mode 100644 index 0000000..f881e33 --- /dev/null +++ b/apps/standards/urls.py @@ -0,0 +1,52 @@ +from django.urls import path, include +from rest_framework.routers import DefaultRouter + +from apps.standards.views import ( + StandardSourceViewSet, + StandardCategoryViewSet, + StandardViewSet, + StandardComplianceViewSet, + StandardAttachmentViewSet, + standards_dashboard, + department_standards_view, + standard_detail, + standard_compliance_update, + standard_attachment_upload, + standards_search, + get_compliance_status, + standard_create, + create_compliance_ajax, + update_compliance_ajax, +) + +# API Router +router = DefaultRouter() +router.register(r'sources', StandardSourceViewSet, basename='standard-source') +router.register(r'categories', StandardCategoryViewSet, basename='standard-category') +router.register(r'standards', StandardViewSet, basename='standard') +router.register(r'compliance', StandardComplianceViewSet, basename='standard-compliance') +router.register(r'attachments', StandardAttachmentViewSet, basename='standard-attachment') + +app_name = 'standards' + +urlpatterns = [ + # API endpoints + path('api/', include(router.urls)), + + # API endpoint for compliance status + path('api/compliance///', get_compliance_status, name='compliance_status'), + + # UI Views + path('', standards_dashboard, name='dashboard'), + path('search/', standards_search, name='search'), + path('departments//', department_standards_view, name='department_standards'), + path('departments//create-standard/', standard_create, name='standard_create'), + path('standards/create/', standard_create, name='standard_create_global'), + path('standards//', standard_detail, name='standard_detail'), + path('compliance//update/', standard_compliance_update, name='standard_compliance_update'), + path('attachments/upload//', standard_attachment_upload, name='attachment_upload'), + + # AJAX endpoints + path('api/compliance/create/', create_compliance_ajax, name='compliance_create_ajax'), + path('api/compliance/update/', update_compliance_ajax, name='compliance_update_ajax'), +] diff --git a/apps/standards/views.py b/apps/standards/views.py new file mode 100644 index 0000000..ab49f44 --- /dev/null +++ b/apps/standards/views.py @@ -0,0 +1,423 @@ +from rest_framework import viewsets, status +from rest_framework.decorators import action +from rest_framework.response import Response +from django.shortcuts import render, get_object_or_404, redirect +from django.contrib.auth.decorators import login_required +from django.http import JsonResponse +from django.db.models import Count, Q +from django.utils import timezone + +from apps.standards.models import ( + StandardSource, + StandardCategory, + Standard, + StandardCompliance, + StandardAttachment +) +from apps.organizations.models import Department +from apps.standards.forms import ( + StandardSourceForm, + StandardCategoryForm, + StandardForm, + StandardComplianceForm, + StandardAttachmentForm +) + + +# ==================== API ViewSets ==================== + +class StandardSourceViewSet(viewsets.ModelViewSet): + queryset = StandardSource.objects.all() + filterset_fields = ['is_active'] + search_fields = ['name', 'name_ar', 'code'] + ordering = ['name'] + + def get_serializer_class(self): + from apps.standards.serializers import StandardSourceSerializer + return StandardSourceSerializer + + +class StandardCategoryViewSet(viewsets.ModelViewSet): + queryset = StandardCategory.objects.all() + filterset_fields = ['is_active'] + search_fields = ['name', 'name_ar'] + ordering = ['order', 'name'] + + def get_serializer_class(self): + from apps.standards.serializers import StandardCategorySerializer + return StandardCategorySerializer + + +class StandardViewSet(viewsets.ModelViewSet): + queryset = Standard.objects.all() + filterset_fields = ['source', 'category', 'department', 'is_active'] + search_fields = ['code', 'title', 'title_ar', 'description'] + ordering = ['source', 'category', 'code'] + + def get_serializer_class(self): + from apps.standards.serializers import StandardSerializer + return StandardSerializer + + +class StandardComplianceViewSet(viewsets.ModelViewSet): + queryset = StandardCompliance.objects.all() + filterset_fields = ['department', 'standard', 'status'] + search_fields = ['department__name', 'standard__code', 'notes'] + ordering = ['-created_at'] + + def get_serializer_class(self): + from apps.standards.serializers import StandardComplianceSerializer + return StandardComplianceSerializer + + +class StandardAttachmentViewSet(viewsets.ModelViewSet): + queryset = StandardAttachment.objects.all() + filterset_fields = ['compliance'] + search_fields = ['filename', 'description'] + ordering = ['-uploaded_at'] + + def get_serializer_class(self): + from apps.standards.serializers import StandardAttachmentSerializer + return StandardAttachmentSerializer + + +# ==================== UI Views ==================== + +@login_required +def standards_dashboard(request): + """Standards dashboard with statistics""" + # Get current hospital from tenant_hospital (set by middleware) + hospital = getattr(request, 'tenant_hospital', None) + if not hospital: + return render(request, 'core/no_hospital_assigned.html') + + departments = hospital.departments.filter(status='active') + + # Get compliance statistics + compliance_records = StandardCompliance.objects.filter( + department__hospital=hospital + ) + + stats = { + 'total_standards': Standard.objects.filter(is_active=True).count(), + 'total_departments': departments.count(), + 'met': compliance_records.filter(status='met').count(), + 'partially_met': compliance_records.filter(status='partially_met').count(), + 'not_met': compliance_records.filter(status='not_met').count(), + 'not_assessed': compliance_records.filter(status='not_assessed').count(), + } + + # Recent compliance updates + recent_updates = compliance_records.order_by('-updated_at')[:10] + + context = { + 'hospital': hospital, + 'departments': departments, + 'stats': stats, + 'recent_updates': recent_updates, + } + + return render(request, 'standards/dashboard.html', context) + + +@login_required +def department_standards_view(request, pk): + """View all standards for a department""" + department = get_object_or_404(Department, pk=pk) + + # Check if user is PX admin + hospital = getattr(request, 'tenant_hospital', None) + is_px_admin = request.user.is_superuser or ( + hasattr(request.user, 'hospital_user') and + request.user.hospital_user.hospital == hospital and + request.user.hospital_user.role == 'px_admin' + ) if hospital else False + + # Get all active standards (both department-specific and general) + department_standards = Standard.objects.filter(is_active=True).filter( + Q(department=department) | Q(department__isnull=True) + ).order_by('source', 'category', 'code') + + # Get compliance status for each standard + standards_data = [] + for standard in department_standards: + compliance = StandardCompliance.objects.filter( + department=department, + standard=standard + ).first() + + standards_data.append({ + 'standard': standard, + 'compliance': compliance, + 'attachment_count': compliance.attachments.count() if compliance else 0, + }) + + context = { + 'department': department, + 'standards_data': standards_data, + 'is_px_admin': is_px_admin, + } + + return render(request, 'standards/department_standards.html', context) + + +@login_required +def standard_detail(request, pk): + """View standard details and compliance history""" + standard = get_object_or_404(Standard, pk=pk) + + # Get compliance records for all departments + compliance_records = StandardCompliance.objects.filter( + standard=standard + ).select_related('department', 'assessor').order_by('-created_at') + + context = { + 'standard': standard, + 'compliance_records': compliance_records, + } + + return render(request, 'standards/standard_detail.html', context) + + +@login_required +def standard_compliance_update(request, compliance_id): + """Update compliance status""" + compliance = get_object_or_404(StandardCompliance, pk=compliance_id) + + if request.method == 'POST': + form = StandardComplianceForm(request.POST, instance=compliance) + if form.is_valid(): + form.save() + return redirect('standards:department_standards', pk=compliance.department.pk) + else: + form = StandardComplianceForm(instance=compliance) + + context = { + 'compliance': compliance, + 'form': form, + } + + return render(request, 'standards/compliance_form.html', context) + + +@login_required +def standard_attachment_upload(request, compliance_id): + """Upload attachment for compliance""" + compliance = get_object_or_404(StandardCompliance, pk=compliance_id) + + if request.method == 'POST': + form = StandardAttachmentForm(request.POST, request.FILES) + if form.is_valid(): + attachment = form.save(commit=False) + attachment.compliance = compliance + attachment.uploaded_by = request.user + attachment.filename = request.FILES['file'].name + attachment.save() + return redirect('standards:standard_compliance_update', compliance_id=compliance.pk) + else: + form = StandardAttachmentForm() + + context = { + 'compliance': compliance, + 'form': form, + } + + return render(request, 'standards/attachment_upload.html', context) + + +@login_required +def standards_search(request): + """Search standards""" + query = request.GET.get('q', '') + source_filter = request.GET.get('source', '') + category_filter = request.GET.get('category', '') + status_filter = request.GET.get('status', '') + + # Get current hospital from tenant_hospital (set by middleware) + hospital = getattr(request, 'tenant_hospital', None) + if not hospital: + return render(request, 'core/no_hospital_assigned.html') + + # Build queryset + standards = Standard.objects.filter(is_active=True) + + if query: + standards = standards.filter( + Q(code__icontains=query) | + Q(title__icontains=query) | + Q(title_ar__icontains=query) | + Q(description__icontains=query) + ) + + if source_filter: + standards = standards.filter(source_id=source_filter) + + if category_filter: + standards = standards.filter(category_id=category_filter) + + standards = standards.select_related('source', 'category').order_by('source', 'category', 'code') + + # Get filters + sources = StandardSource.objects.filter(is_active=True) + categories = StandardCategory.objects.filter(is_active=True) + + context = { + 'hospital': hospital, + 'standards': standards, + 'query': query, + 'source_filter': source_filter, + 'category_filter': category_filter, + 'sources': sources, + 'categories': categories, + } + + return render(request, 'standards/search.html', context) + + +@login_required +def standard_create(request, department_id=None): + """Create a new standard (PX Admin only)""" + # Check if user is PX admin + hospital = getattr(request, 'tenant_hospital', None) + if not hospital: + return render(request, 'core/no_hospital_assigned.html') + + is_px_admin = request.user.is_superuser or ( + hasattr(request.user, 'hospital_user') and + request.user.hospital_user.hospital == hospital and + request.user.hospital_user.role == 'px_admin' + ) + + if not is_px_admin: + from django.contrib import messages + messages.error(request, 'You do not have permission to create standards.') + if department_id: + return redirect('standards:department_standards', pk=department_id) + return redirect('standards:dashboard') + + if request.method == 'POST': + form = StandardForm(request.POST) + if form.is_valid(): + standard = form.save(commit=False) + standard.save() + from django.contrib import messages + messages.success(request, 'Standard created successfully.') + if department_id: + return redirect('standards:department_standards', pk=department_id) + return redirect('standards:dashboard') + else: + form = StandardForm() + # If department_id is provided, pre-select that department + if department_id: + from apps.organizations.models import Department + department = Department.objects.filter(pk=department_id).first() + if department: + form.fields['department'].initial = department + + # Get all departments for the hospital + departments = hospital.departments.filter(status='active') + + context = { + 'form': form, + 'department_id': department_id, + 'departments': departments, + 'hospital': hospital, + } + + return render(request, 'standards/standard_form.html', context) + + +@login_required +def create_compliance_ajax(request): + """Create compliance record via AJAX""" + if request.method != 'POST': + return JsonResponse({'success': False, 'error': 'Invalid request method'}) + + department_id = request.POST.get('department_id') + standard_id = request.POST.get('standard_id') + + if not department_id or not standard_id: + return JsonResponse({'success': False, 'error': 'Missing required fields'}) + + try: + department = Department.objects.get(pk=department_id) + standard = Standard.objects.get(pk=standard_id) + + # Check if compliance already exists + compliance, created = StandardCompliance.objects.get_or_create( + department=department, + standard=standard, + defaults={ + 'assessor': request.user, + 'last_assessed_date': timezone.now().date(), + } + ) + + return JsonResponse({ + 'success': True, + 'compliance_id': compliance.id, + 'status': compliance.status, + 'created': created, + }) + except Exception as e: + return JsonResponse({'success': False, 'error': str(e)}) + + +@login_required +def update_compliance_ajax(request): + """Update compliance record via AJAX""" + if request.method != 'POST': + return JsonResponse({'success': False, 'error': 'Invalid request method'}) + + compliance_id = request.POST.get('compliance_id') + status = request.POST.get('status') + notes = request.POST.get('notes', '') + evidence_summary = request.POST.get('evidence_summary', '') + + if not compliance_id or not status: + return JsonResponse({'success': False, 'error': 'Missing required fields'}) + + try: + compliance = StandardCompliance.objects.get(pk=compliance_id) + compliance.status = status + compliance.notes = notes + compliance.evidence_summary = evidence_summary + compliance.assessor = request.user + compliance.last_assessed_date = timezone.now().date() + compliance.save() + + return JsonResponse({ + 'success': True, + 'status': compliance.status, + 'status_display': compliance.get_status_display(), + }) + except Exception as e: + return JsonResponse({'success': False, 'error': str(e)}) + + +@login_required +def get_compliance_status(request, department_id, standard_id): + """API endpoint to get compliance status""" + compliance = StandardCompliance.objects.filter( + department_id=department_id, + standard_id=standard_id + ).first() + + if compliance: + data = { + 'status': compliance.status, + 'last_assessed_date': compliance.last_assessed_date, + 'assessor': compliance.assessor.get_full_name() if compliance.assessor else None, + 'notes': compliance.notes, + 'attachment_count': compliance.attachments.count(), + } + else: + data = { + 'status': 'not_assessed', + 'last_assessed_date': None, + 'assessor': None, + 'notes': '', + 'attachment_count': 0, + } + + return JsonResponse(data) diff --git a/config/settings/base.py b/config/settings/base.py index f43aba5..0f90060 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -65,6 +65,8 @@ LOCAL_APPS = [ 'apps.dashboard', 'apps.appreciation', 'apps.observations', + 'apps.references', + 'apps.standards', ] INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + LOCAL_APPS diff --git a/config/urls.py b/config/urls.py index 010fb8c..57a4ec3 100644 --- a/config/urls.py +++ b/config/urls.py @@ -40,6 +40,8 @@ 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')), + path('references/', include('apps.references.urls', namespace='references')), + path('standards/', include('apps.standards.urls', namespace='standards')), # API endpoints path('api/auth/', include('apps.accounts.urls')), diff --git a/docs/OBSERVATION_MODEL_FIXES.md b/docs/OBSERVATION_MODEL_FIXES.md new file mode 100644 index 0000000..6e94a11 --- /dev/null +++ b/docs/OBSERVATION_MODEL_FIXES.md @@ -0,0 +1,168 @@ +# Observation Model Fixes + +## Overview +Fixed critical issues in the Observation model to align with the system's tenant isolation architecture and improve parity with the Complaint model. + +## Issues Fixed + +### 1. Missing Hospital ForeignKey (Critical) +**Problem:** The Observation model lacked a `hospital` field, breaking tenant isolation. + +**Impact:** +- Cannot filter observations by hospital +- Cannot track which hospital owns the observation +- Prevents proper routing in multi-hospital environment +- Breaks the tenant isolation pattern used throughout the system + +**Solution:** +- Added `hospital` ForeignKey to `organizations.Hospital` +- Field is required for new observations +- Added database index for efficient filtering by hospital and status + +### 2. Missing Staff ForeignKey +**Problem:** Observations couldn't track which staff member was mentioned in the report. + +**Solution:** +- Added optional `staff` ForeignKey to `organizations.Staff` +- Enables AI-matching similar to Complaint model +- Useful for tracking observations about specific staff members + +### 3. Missing Source Tracking +**Problem:** No way to track how observations were submitted. + +**Solution:** +- Added `source` field with choices: + - `staff_portal` - Staff Portal (default) + - `web_form` - Web Form + - `mobile_app` - Mobile App + - `email` - Email + - `call_center` - Call Center + - `other` - Other + +## Model Changes + +### Added Fields to Observation Model +```python +hospital = models.ForeignKey( + 'organizations.Hospital', + on_delete=models.CASCADE, + related_name='observations', + help_text="Hospital where observation was made" +) + +staff = models.ForeignKey( + 'organizations.Staff', + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name='observations', + help_text="Staff member mentioned in observation" +) + +source = models.CharField( + max_length=50, + choices=[ + ('staff_portal', 'Staff Portal'), + ('web_form', 'Web Form'), + ('mobile_app', 'Mobile App'), + ('email', 'Email'), + ('call_center', 'Call Center'), + ('other', 'Other'), + ], + default='staff_portal', + help_text="How the observation was submitted" +) +``` + +### Added Database Indexes +```python +models.Index(fields=['hospital', 'status', '-created_at']) +``` + +## Migration Strategy + +### Migration 0002_add_missing_fields.py +- Adds `hospital`, `staff`, and `source` fields +- Initially makes `hospital` nullable to support existing data +- Since there are 0 existing observations, no data migration needed + +### Future Migration +After deployment, a follow-up migration can make `hospital` required if needed: +```python +migrations.AlterField( + model_name='observation', + name='hospital', + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name='observations', + to='organizations.hospital' + ), +) +``` + +## Why Not Unify with Complaints? + +Despite similarities, the models should remain separate: + +### Key Differences +| Aspect | Complaint | Observation | +|--------|-----------|-------------| +| **Purpose** | Patient-reported issues | Staff-reported issues | +| **Reporter** | Patient FK (required) | Anonymous or staff ID | +| **Workflow** | Open → In Progress → Resolved → Closed | New → Triaged → Assigned → In Progress → Resolved → Closed (plus REJECTED, DUPLICATE) | +| **SLA** | Response time SLAs with tracking | Triage timeline, not response time | +| **Tracking** | Reference number (optional) | Tracking code (required, OBS-XXXXXX) | +| **Location** | No location field | Location text field | +| **Incident Time** | No separate field | Incident datetime field | +| **Triage** | No triage fields | Triage tracking (triaged_by, triaged_at) | +| **Resolution Survey** | Links to SurveyInstance | No resolution survey | +| **Encounter ID** | Links to patient encounters | No encounter tracking | + +### Different Business Logic +- **Complaints**: Customer service workflow, satisfaction surveys, SLA escalation +- **Observations**: Safety/quality workflow, triage process, anonymous reporting + +### Different Stakeholders +- **Complaints**: Patient experience teams, call center +- **Observations**: Quality teams, safety officers, clinical governance + +## Next Steps + +1. **Run Migration:** + ```bash + python manage.py migrate observations + ``` + +2. **Update Forms:** Ensure observation forms include hospital selection + - Public observation form + - Staff portal observation form + - Admin observation form + +3. **Update Views:** Add hospital filtering to observation list views + +4. **Update APIs:** Include hospital field in observation APIs + +5. **Update Templates:** Display hospital information in observation detail views + +6. **Consider AI Integration:** Add staff name extraction similar to complaints + - Use `apps.core.ai_service.extract_person_name()` + - Match extracted names to Staff records + - Populate `staff` field automatically + +## Files Modified + +1. **apps/observations/models.py** + - Added `hospital` ForeignKey + - Added `staff` ForeignKey + - Added `source` field + - Added hospital status index + +2. **apps/observations/migrations/0002_add_missing_fields.py** + - Migration to add new fields + +## Backwards Compatibility + +- Migration makes `hospital` initially nullable +- Model code enforces `hospital` as required for new records +- Existing code without hospital selection will work but with validation errors for new observations +- Consider updating form validation to ensure hospital is always provided diff --git a/docs/REFERENCES_IMPLEMENTATION.md b/docs/REFERENCES_IMPLEMENTATION.md new file mode 100644 index 0000000..e6a745d --- /dev/null +++ b/docs/REFERENCES_IMPLEMENTATION.md @@ -0,0 +1,416 @@ +# References Section - Reference Documentation + +## Overview + +The References Section is a file server system for managing reference documents with folder categorization. It provides a simple and straightforward way to store files and group them by folders, accessible by employees at any time. + +## Features + +### 1. **Folder Management** +- **Hierarchical Structure**: Create nested folders (folders within folders) +- **Bilingual Support**: Names and descriptions in both English and Arabic +- **Customization**: Icons and colors for visual organization +- **Access Control**: Role-based access control per folder +- **Soft Delete**: Folders can be marked as deleted without actual removal +- **Ordering**: Custom display order for folders + +### 2. **Document Management** +- **Multiple File Types**: Support for PDF, Word, Excel, PowerPoint, Images, and more +- **Version Control**: Track multiple versions of the same document +- **Bilingual Titles**: Document titles and descriptions in both languages +- **Tags**: Comma-separated tags for easy searching +- **Access Control**: Role-based access control per document +- **Download Tracking**: Track how many times documents are downloaded +- **Publication Control**: Documents can be published/unpublished + +### 3. **Search & Discovery** +- Full-text search across titles, descriptions, and tags +- Filter by folder +- Filter by file type +- Filter by tags +- Pagination for large result sets + +### 4. **Audit Trail** +- Track all document accesses (view, download, preview) +- Log user actions with IP addresses +- Track download counts and last accessed dates + +## Models + +### ReferenceFolder + +Main folder model with the following fields: + +- `name`: English folder name (required) +- `name_ar`: Arabic folder name (optional) +- `description`: English description (optional) +- `description_ar`: Arabic description (optional) +- `parent`: Parent folder for nested structure +- `icon`: FontAwesome icon class (e.g., 'fa-folder') +- `color`: Hex color code (e.g., '#007bff') +- `order`: Display order +- `access_roles`: Groups that can access this folder +- `is_active`: Active status +- `is_deleted`: Soft delete flag +- `hospital`: Hospital tenant (via TenantModel) + +**Key Methods:** +- `get_full_path()`: Get full folder path as breadcrumb +- `has_access(user)`: Check if user has access +- `get_subfolders()`: Get immediate subfolders +- `get_documents()`: Get documents in this folder +- `get_document_count()`: Count documents including subfolders + +### ReferenceDocument + +Document model with the following fields: + +- `folder`: Associated folder +- `title`: English title (required) +- `title_ar`: Arabic title (optional) +- `file`: File upload +- `filename`: Original filename +- `file_type`: File extension (e.g., pdf, docx) +- `file_size`: File size in bytes +- `description`: English description (optional) +- `description_ar`: Arabic description (optional) +- `version`: Version string (e.g., '1.0', '1.1') +- `is_latest_version`: Is this the latest version? +- `parent_document`: Previous version reference +- `uploaded_by`: User who uploaded +- `download_count`: Number of downloads +- `last_accessed_at`: Last access timestamp +- `is_published`: Visibility status +- `access_roles`: Groups that can access this document +- `tags`: Comma-separated tags +- `metadata`: Additional metadata (JSON) + +**Key Methods:** +- `has_access(user)`: Check if user has access +- `increment_download_count()`: Track downloads +- `get_tags_list()`: Get tags as list +- `get_file_icon()`: Get appropriate icon class +- `get_file_size_display()`: Human-readable file size +- `get_version_history()`: Get all versions + +### ReferenceDocumentAccess + +Access log model for tracking: + +- `document`: Referenced document +- `user`: User who accessed +- `action`: Action type (view, download, preview) +- `ip_address`: User's IP address +- `user_agent`: Browser user agent + +## Views + +### UI Views (Server-Rendered) + +1. **`reference_dashboard`** - Main entry point showing: + - Root folders + - Recent documents + - Statistics (total folders, total documents) + +2. **`folder_view`** - Browse folder contents: + - Shows subfolders + - Shows documents + - Breadcrumb navigation + +3. **`folder_create`** - Create new folder: + - Form with all folder fields + - Parent folder option + - Access control configuration + +4. **`folder_edit`** - Edit existing folder: + - Update folder details + - Change parent folder + - Modify access control + +5. **`folder_delete`** - Soft delete folder (AJAX): + - Mark folder as deleted + - Documents in folder remain + +6. **`document_view`** - Document details: + - Document information + - Version history + - Related documents + - Download button + +7. **`document_create`** - Upload new document: + - File upload + - Metadata input + - Folder selection + - New version option + +8. **`document_edit`** - Edit document: + - Update metadata + - Upload new file (optional) + - Create new version option + +9. **`document_delete`** - Soft delete document (AJAX) + +10. **`search`** - Search documents: + - Full-text search + - Multiple filters + - Pagination + +## URLs + +```python +# Dashboard +/references/ # Main dashboard + +# Folders +/references/folder// # View folder +/references/folder/create/ # Create folder +/references/folder//create/ # Create subfolder +/references/folder//edit/ # Edit folder +/references/folder//delete/ # Delete folder + +# Documents +/references/document// # View document +/references/document/create/ # Upload document +/references/document//create/ # Upload to folder +/references/document//edit/ # Edit document +/references/document//delete/ # Delete document + +# Search +/references/search/ # Search documents +``` + +## Access Control + +The system implements role-based access control: + +1. **Folder Access**: A folder can have specific access roles assigned + - If no roles assigned → Accessible to all users + - If roles assigned → Only users in those roles can access + +2. **Document Access**: Similar to folder access + - Inherits folder access if not restricted + - Can have its own access restrictions + +3. **Cascade Effect**: When accessing documents through folder navigation: + - User must have access to both folder AND document + - Document-specific access overrides folder access + +## File Storage + +Files are stored with the following path structure: + +``` +references//YYYY/MM/DD/. +``` + +Example: +``` +references/1/2026/01/07/a1b2c3d4-e5f6-7890-abcd-ef1234567890.pdf +``` + +## Usage Examples + +### Creating a Folder + +1. Navigate to `/references/` +2. Click "New Folder" button +3. Fill in folder information: + - Name (English) - Required + - Name (Arabic) - Optional + - Description (English) - Optional + - Description (Arabic) - Optional + - Parent Folder - Optional (for nested folders) + - Icon - Optional (e.g., 'fa-folder') + - Color - Optional (e.g., '#007bff') + - Order - Optional (default: 0) + - Access Roles - Optional (leave empty for public) +4. Click "Create Folder" + +### Uploading a Document + +1. Navigate to folder where you want to upload +2. Click "Upload Document" button +3. Fill in document information: + - Title (English) - Required + - Title (Arabic) - Optional + - File - Required + - Description (English) - Optional + - Description (Arabic) - Optional + - Version - Optional (default: '1.0') + - Access Roles - Optional + - Tags - Optional (comma-separated) +4. Click "Upload Document" + +### Searching Documents + +1. Navigate to `/references/search/` +2. Use search/filter options: + - Search by text + - Filter by folder + - Filter by file type + - Filter by tags +3. Results are paginated + +### Creating Document Versions + +1. Go to document detail page +2. Click "Edit Document" +3. Check "Upload as new version" +4. Select new file +5. Click "Update Document" +6. Old version is marked as not latest +7. New version becomes the current version + +## Templates + +All templates are in `templates/references/`: + +- `dashboard.html` - Main dashboard +- `folder_view.html` - Folder contents +- `folder_form.html` - Create/edit folder +- `document_view.html` - Document details +- `document_form.html` - Upload/edit document +- `search.html` - Search results + +## Forms + +- `ReferenceFolderForm` - Folder creation/editing +- `ReferenceDocumentForm` - Document upload/editing +- `ReferenceDocumentSearchForm` - Document search + +## Admin Integration + +All models are registered with Django Admin: + +- ReferenceFolder - Full CRUD with inline actions +- ReferenceDocument - Full CRUD with file preview +- ReferenceDocumentAccess - Read-only access logs + +## File Upload Configuration + +The system supports the following file types: + +- PDF (.pdf) +- Word (.doc, .docx) +- Excel (.xls, .xlsx) +- PowerPoint (.ppt, .pptx) +- Text (.txt, .rtf) +- CSV (.csv) +- Images (.jpg, .jpeg, .png, .gif) + +## Best Practices + +1. **Folder Organization** + - Use meaningful names in both languages + - Keep structure simple and logical + - Limit nesting depth (3-4 levels max) + +2. **Document Management** + - Use clear, descriptive titles + - Add meaningful tags for searchability + - Keep document versions organized + - Archive old versions, don't delete + +3. **Access Control** + - Assign access roles at folder level for broad control + - Use document-level access for sensitive files + - Regularly review access permissions + +4. **File Uploads** + - Keep file sizes reasonable (under 50MB recommended) + - Use PDF format for documents when possible + - Optimize images before uploading + +## Integration with Hospital System + +The References section is fully integrated with the hospital tenant system: + +- Each hospital has its own set of folders and documents +- Files are segregated by hospital ID +- Users can only access their assigned hospital's references +- Context processors ensure hospital context is always available + +## Sidebar Navigation + +The References section is accessible from the sidebar: + +``` +Sidebar → References (icon: bi-folder) +``` + +This link navigates to `/references/` (dashboard). + +## Future Enhancements + +Potential improvements for future versions: + +1. **Bulk Operations** + - Bulk upload documents + - Bulk move/copy folders + - Bulk delete + +2. **Advanced Search** + - Full-text indexing with search engine + - Document content search (PDF text extraction) + - Saved search queries + +3. **Integration** + - Email documents directly from system + - Generate document download reports + - Integrate with external document storage (S3, etc.) + +4. **Collaboration** + - Document comments/annotations + - Document sharing links + - Workflow approvals for documents + +## Troubleshooting + +### Issue: Folder creation not refreshing + +**Solution**: The folder_form template has been fixed to use correct field names: +- `name` (not `name_en`) +- `description` (not `description_en`) +- Removed non-existent fields: `visibility`, `tags` + +### Issue: Can't see folders/documents + +**Possible causes:** +1. User doesn't have hospital assigned → Check user.hospital +2. Access roles not configured → Check folder/document access_roles +3. Folder/document is inactive → Check is_active/is_published flags +4. Soft deleted → Check is_deleted flag + +### Issue: File upload fails + +**Possible causes:** +1. File type not supported → Check allowed_types in model.clean() +2. File size too large → Check Django settings for FILE_UPLOAD_MAX_MEMORY_SIZE +3. Insufficient permissions → Check media directory write permissions + +## Technical Implementation + +### File Upload Handling + +Files are handled via Django's FileField with custom upload path function: +- Path includes hospital ID for segregation +- Date-based organization (YYYY/MM/DD) +- UUID-based filename for uniqueness + +### Soft Delete + +Both folders and documents use soft delete: +- `is_deleted` flag instead of actual deletion +- Maintains audit trail +- Allows recovery if needed + +### Version Control + +Documents support versioning: +- `parent_document` links to previous version +- `is_latest_version` indicates current version +- `version` string for semantic versioning + +## Conclusion + +The References section provides a comprehensive document management system with folder-based organization, role-based access control, version tracking, and full audit capabilities. It's simple and straightforward to use while providing powerful features for managing organizational documents. diff --git a/locale/en/LC_MESSAGES/django.po b/locale/en/LC_MESSAGES/django.po index c1a72a3..ea3fc1b 100644 --- a/locale/en/LC_MESSAGES/django.po +++ b/locale/en/LC_MESSAGES/django.po @@ -74,6 +74,10 @@ msgid "Social Media" msgstr "" #: templates/layouts/partials/sidebar.html:96 +msgid "References" +msgstr "" + +#: templates/layouts/partials/sidebar.html:107 msgid "Analytics" msgstr "" diff --git a/templates/complaints/complaint_detail.html b/templates/complaints/complaint_detail.html index 1e86593..08a2438 100644 --- a/templates/complaints/complaint_detail.html +++ b/templates/complaints/complaint_detail.html @@ -245,17 +245,6 @@ - {% if complaint.physician %} -
-
-
{{ _("Physician") }}
-
- Dr. {{ complaint.physician.first_name }} {{ complaint.physician.last_name }} - ({{ complaint.physician.specialty }}) -
-
-
- {% endif %} {% if complaint.department %}
@@ -315,6 +304,76 @@
{% endif %} + + {% if complaint.metadata.ai_analysis.staff_matches and user.is_px_admin %} +
+
+
+ + Staff Suggestions + {% if complaint.metadata.ai_analysis.needs_staff_review %} + Needs Review + {% endif %} +
+
+ {% if complaint.metadata.ai_analysis.extracted_staff_name %} +

+ AI extracted name: "{{ complaint.metadata.ai_analysis.extracted_staff_name }}" + ({% if complaint.metadata.ai_analysis.staff_match_count %}{{ complaint.metadata.ai_analysis.staff_match_count }} potential match{{ complaint.metadata.ai_analysis.staff_match_count|pluralize }}{% else %}No matches found{% endif %}) +

+ {% endif %} + +
+ {% for staff_match in complaint.metadata.ai_analysis.staff_matches %} +
+
+
+
+ + {{ staff_match.name_en }} + {% if staff_match.name_ar %} + ({{ staff_match.name_ar }}) + {% endif %} +
+ {% if staff_match.job_title %} + {{ staff_match.job_title }} + {% endif %} + {% if staff_match.specialization %} + • {{ staff_match.specialization }} + {% endif %} + {% if staff_match.department %} + • {{ staff_match.department }} + {% endif %} +
+
+ + {{ staff_match.confidence|mul:100|floatformat:0 }}% confidence + + {% if complaint.staff and staff_match.id == complaint.staff.id|stringformat:"s" %} +
+ Currently assigned +
+ {% elif not complaint.staff %} + + {% endif %} +
+
+
+ {% endfor %} +
+ +
+ +
+
+
+
+ {% endif %} +
@@ -348,7 +407,7 @@
- Confidence: {{ complaint.emotion_confidence|floatformat:0 }}% + Confidence: {{ complaint.emotion_confidence|mul:100|floatformat:0 }}%
@@ -575,6 +634,46 @@
{% trans "Quick Actions" %}
+ {% if user.is_px_admin %} + +
+ + + {% if complaint.metadata.ai_analysis.needs_staff_review %} + + + This complaint needs staff review + + {% endif %} +
+
+ {% endif %} + + + {% if can_edit and hospital_departments %} +
+ {% csrf_token %} + +
+ + +
+
+ {% endif %} +
{% csrf_token %} @@ -613,6 +712,12 @@
+ + +
+ + + + + + {% endblock %} diff --git a/templates/complaints/complaint_form.html b/templates/complaints/complaint_form.html index 68a9b9e..bf585ac 100644 --- a/templates/complaints/complaint_form.html +++ b/templates/complaints/complaint_form.html @@ -97,9 +97,9 @@
- - +
diff --git a/templates/complaints/public_complaint_form.html b/templates/complaints/public_complaint_form.html index d7d768d..2f6f6ec 100644 --- a/templates/complaints/public_complaint_form.html +++ b/templates/complaints/public_complaint_form.html @@ -340,11 +340,24 @@ function getName(category) { return category.name_en; } -// Load categories -function loadCategories() { +// Load categories based on hospital +function loadCategories(hospitalId) { + if (!hospitalId) { + // Clear categories if no hospital selected + const categorySelect = $('#id_category'); + categorySelect.find('option:not(:first)').remove(); + $('#subcategory_container').hide(); + $('#subcategory_description').hide(); + $('#category_description').hide(); + $('#id_subcategory').find('option:not(:first)').remove(); + $('#id_subcategory').prop('required', false); + return; + } + $.ajax({ url: '{% url "complaints:api_load_categories" %}', type: 'GET', + data: { hospital_id: hospitalId }, success: function(response) { // Store all categories allCategories = response.categories; @@ -429,17 +442,17 @@ function loadSubcategories(categoryId) { } } -// 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'; - } - - // Load categories immediately - loadCategories(); -} +// Handle hospital change +$('#id_hospital').on('change', function() { + const hospitalId = $(this).val(); + loadCategories(hospitalId); + // Clear category and subcategory when hospital changes + $('#id_category').val(''); + $('#id_subcategory').val(''); + $('#subcategory_container').hide(); + $('#subcategory_description').hide(); + $('#category_description').hide(); +}); // Handle category change $('#id_category').on('change', function() { @@ -510,9 +523,6 @@ $('#public_complaint_form').on('submit', function(e) { } }); }); - - // Initialize form on page load - initializeComplaintForm(); }); {% endblock %} diff --git a/templates/core/public_submit.html b/templates/core/public_submit.html index 5aa73f4..954fa07 100644 --- a/templates/core/public_submit.html +++ b/templates/core/public_submit.html @@ -5,132 +5,429 @@ {% block extra_css %} {% endblock %} {% block content %} +{% load static %}
+ +
+ Al Hammadi Hospital Logo +
+
-

{% trans "We Value Your Feedback" %}

-

{% trans "Choose how you'd like to reach us and we'll respond promptly" %}

+

{% trans "We Value Your Feedback" %}

+

{% trans "Your feedback helps us improve our services and provide better care for everyone." %}

@@ -181,7 +527,7 @@

{% trans "Complaint" %}

- {% trans "Report issues with services, staff, or facilities. We take your concerns seriously and will investigate thoroughly." %} + {% trans "Report an issue with our services. We take all concerns seriously and will investigate thoroughly." %}

@@ -192,7 +538,7 @@

{% trans "Observation" %}

- {% trans "Report incidents or issues anonymously. Help us improve safety and quality by sharing what you've noticed." %} + {% trans "Help us improve safety and quality by sharing what you've noticed. Submit anonymously." %}

@@ -203,7 +549,7 @@

{% trans "Inquiry" %}

- {% trans "Ask questions or request information. Need help understanding services, appointments, or policies?" %} + {% trans "Have questions? We're here to help with appointments, services, or general information." %}

@@ -213,14 +559,14 @@
@@ -229,7 +575,7 @@