diff --git a/apps/complaints/serializers.py b/apps/complaints/serializers.py index 900657c..ee8c0bc 100644 --- a/apps/complaints/serializers.py +++ b/apps/complaints/serializers.py @@ -9,7 +9,7 @@ from .models import Complaint, ComplaintAttachment, ComplaintUpdate, Inquiry class ComplaintAttachmentSerializer(serializers.ModelSerializer): """Complaint attachment serializer""" uploaded_by_name = serializers.SerializerMethodField() - + class Meta: model = ComplaintAttachment fields = [ @@ -18,7 +18,7 @@ class ComplaintAttachmentSerializer(serializers.ModelSerializer): 'created_at', 'updated_at' ] read_only_fields = ['id', 'file_size', 'created_at', 'updated_at'] - + def get_uploaded_by_name(self, obj): """Get uploader name""" if obj.uploaded_by: @@ -29,7 +29,7 @@ class ComplaintAttachmentSerializer(serializers.ModelSerializer): class ComplaintUpdateSerializer(serializers.ModelSerializer): """Complaint update serializer""" created_by_name = serializers.SerializerMethodField() - + class Meta: model = ComplaintUpdate fields = [ @@ -39,7 +39,7 @@ class ComplaintUpdateSerializer(serializers.ModelSerializer): 'created_at', 'updated_at' ] read_only_fields = ['id', 'created_at', 'updated_at'] - + def get_created_by_name(self, obj): """Get creator name""" if obj.created_by: @@ -58,7 +58,7 @@ class ComplaintSerializer(serializers.ModelSerializer): attachments = ComplaintAttachmentSerializer(many=True, read_only=True) updates = ComplaintUpdateSerializer(many=True, read_only=True) sla_status = serializers.SerializerMethodField() - + class Meta: model = Complaint fields = [ @@ -82,28 +82,85 @@ class ComplaintSerializer(serializers.ModelSerializer): 'resolved_at', 'closed_at', 'resolution_survey_sent_at', 'created_at', 'updated_at' ] - + + def create(self, validated_data): + """ + Create complaint with auto-setting of department from AI analysis. + + If metadata contains AI analysis with department name, + automatically set the department field by querying the database. + """ + from apps.organizations.models import Department + import logging + logger = logging.getLogger(__name__) + + # Extract metadata if present + metadata = validated_data.get('metadata', {}) + + # Check if department is not already set and AI analysis contains department info + if not validated_data.get('department') and metadata.get('ai_analysis'): + ai_analysis = metadata['ai_analysis'] + department_name = ai_analysis.get('department') + + # If AI identified a department, try to match it in the database + if department_name: + hospital = validated_data.get('hospital') + + if hospital: + try: + # Try exact match first + department = Department.objects.filter( + hospital=hospital, + status='active', + name__iexact=department_name + ).first() + + # If no exact match, try partial match + if not department: + department = Department.objects.filter( + hospital=hospital, + status='active', + name__icontains=department_name + ).first() + + if department: + validated_data['department'] = department + logger.info( + f"Auto-set department '{department.name}' from AI analysis " + f"for complaint (matched from '{department_name}')" + ) + else: + logger.warning( + f"AI suggested department '{department_name}' but no match found " + f"in hospital '{hospital.name}'" + ) + except Exception as e: + logger.error(f"Error auto-setting department: {e}") + + # Create the complaint + return super().create(validated_data) + def get_physician_name(self, obj): """Get physician name""" if obj.physician: return obj.physician.get_full_name() return None - + def get_assigned_to_name(self, obj): """Get assigned user name""" if obj.assigned_to: return obj.assigned_to.get_full_name() return None - + def get_sla_status(self, obj): """Get SLA status""" if obj.is_overdue: return 'overdue' - + from django.utils import timezone time_remaining = obj.due_at - timezone.now() hours_remaining = time_remaining.total_seconds() / 3600 - + if hours_remaining < 4: return 'due_soon' return 'on_time' @@ -114,7 +171,7 @@ class ComplaintListSerializer(serializers.ModelSerializer): patient_name = serializers.CharField(source='patient.get_full_name', read_only=True) hospital_name = serializers.CharField(source='hospital.name', read_only=True) sla_status = serializers.SerializerMethodField() - + class Meta: model = Complaint fields = [ @@ -122,16 +179,16 @@ class ComplaintListSerializer(serializers.ModelSerializer): 'category', 'severity', 'status', 'sla_status', 'assigned_to', 'created_at' ] - + def get_sla_status(self, obj): """Get SLA status""" if obj.is_overdue: return 'overdue' - + from django.utils import timezone time_remaining = obj.due_at - timezone.now() hours_remaining = time_remaining.total_seconds() / 3600 - + if hours_remaining < 4: return 'due_soon' return 'on_time' @@ -143,7 +200,7 @@ class InquirySerializer(serializers.ModelSerializer): hospital_name = serializers.CharField(source='hospital.name', read_only=True) department_name = serializers.CharField(source='department.name', read_only=True) assigned_to_name = serializers.SerializerMethodField() - + class Meta: model = Inquiry fields = [ @@ -156,7 +213,7 @@ class InquirySerializer(serializers.ModelSerializer): 'created_at', 'updated_at' ] read_only_fields = ['id', 'responded_at', 'created_at', 'updated_at'] - + def get_assigned_to_name(self, obj): """Get assigned user name""" if obj.assigned_to: diff --git a/apps/complaints/tasks.py b/apps/complaints/tasks.py index 5121172..55b4520 100644 --- a/apps/complaints/tasks.py +++ b/apps/complaints/tasks.py @@ -9,6 +9,7 @@ This module contains tasks for: - AI-powered complaint analysis """ import logging +from typing import Optional, Dict, Any, Tuple from celery import shared_task from django.db import transaction @@ -18,6 +19,119 @@ from django.utils import timezone logger = logging.getLogger(__name__) +def match_staff_from_name(staff_name: str, hospital_id: str, department_name: Optional[str] = None) -> Tuple[Optional[str], float, str]: + """ + Match staff member from extracted name using multiple matching strategies. + + Args: + staff_name: Name extracted from complaint (without titles) + hospital_id: Hospital ID to search within + department_name: Optional department name to prioritize matching + + Returns: + Tuple of (staff_id, confidence_score, matching_method) + - staff_id: UUID of matched staff or None + - confidence_score: Float from 0.0 to 1.0 + - matching_method: Description of how staff was matched + """ + from apps.organizations.models import Staff, Department + + if not staff_name or not staff_name.strip(): + return None, 0.0, "No staff name provided" + + staff_name = staff_name.strip() + + # Build base query - staff from this hospital, active status + base_query = Staff.objects.filter( + hospital_id=hospital_id, + status='active' + ) + + # If department is specified, prioritize staff in that department + dept_id = None + if department_name: + department = Department.objects.filter( + hospital_id=hospital_id, + name__iexact=department_name, + status='active' + ).first() + if department: + dept_id = department.id + + # Layer 1: Exact English match (full name) + # Try "first_name last_name" format + words = staff_name.split() + if len(words) >= 2: + first_name = words[0] + last_name = ' '.join(words[1:]) + + # Exact match in correct department + exact_query = base_query.filter( + Q(first_name__iexact=first_name) & Q(last_name__iexact=last_name) + ) + if dept_id: + exact_query = exact_query.filter(department_id=dept_id) + + staff = exact_query.first() + if staff: + confidence = 0.95 if dept_id else 0.90 + method = f"Exact English match in {'correct' if dept_id else 'any'} department" + logger.info(f"Matched staff using exact English match: {staff.first_name} {staff.last_name} (confidence: {confidence})") + return str(staff.id), confidence, method + + # Layer 2: Exact Arabic match + arabic_query = base_query.filter( + Q(first_name_ar__iexact=staff_name) | Q(last_name_ar__iexact=staff_name) + ) + if dept_id: + arabic_query = arabic_query.filter(department_id=dept_id) + + # Try full Arabic name match + for staff in arabic_query: + full_arabic_name = f"{staff.first_name_ar} {staff.last_name_ar}".strip() + if full_arabic_name == staff_name: + confidence = 0.95 if dept_id else 0.90 + method = f"Exact Arabic match in {'correct' if dept_id else 'any'} department" + logger.info(f"Matched staff using exact Arabic match: {staff.first_name_ar} {staff.last_name_ar} (confidence: {confidence})") + return str(staff.id), confidence, method + + # Layer 3: Partial match (first name or last name) + partial_query = base_query.filter( + Q(first_name__icontains=staff_name) | + Q(last_name__icontains=staff_name) | + Q(first_name_ar__icontains=staff_name) | + Q(last_name_ar__icontains=staff_name) + ) + if dept_id: + partial_query = partial_query.filter(department_id=dept_id) + + staff = partial_query.first() + if staff: + confidence = 0.70 if dept_id else 0.60 + method = f"Partial match in {'correct' if dept_id else 'any'} department" + logger.info(f"Matched staff using partial match: {staff.first_name} {staff.last_name} (confidence: {confidence})") + return str(staff.id), confidence, method + + # Layer 4: Fuzzy match using individual words + # Handle cases like "Dr. Ahmed" or "Nurse Sarah" + word_query = base_query.filter( + Q(first_name__in=words) | Q(first_name_ar__in=words) + ) + if dept_id: + word_query = word_query.filter(department_id=dept_id) + + staff = word_query.first() + if staff: + confidence = 0.50 if dept_id else 0.45 + method = f"Word match in {'correct' if dept_id else 'any'} department" + logger.info(f"Matched staff using word match: {staff.first_name} {staff.last_name} (confidence: {confidence})") + return str(staff.id), confidence, method + + # No match found + logger.warning(f"No staff match found for name: {staff_name}") + return None, 0.0, "No match found" + + @shared_task def check_overdue_complaints(): """ @@ -541,7 +655,8 @@ def analyze_complaint_with_ai(complaint_id): analysis = AIService.analyze_complaint( title=complaint.title, description=complaint.description, - category=category_name + category=category_name, + hospital_id=complaint.hospital.id ) # Analyze emotion using AI service @@ -566,11 +681,24 @@ def analyze_complaint_with_ai(complaint_id): department_name = analysis.get('department', '') if department_name: from apps.organizations.models import Department + # Try exact match first (case-insensitive) if department := Department.objects.filter( hospital_id=complaint.hospital.id, - name=department_name + name__iexact=department_name, + status='active' ).first(): complaint.department = department + logger.info(f"Matched department exactly: {department.name}") + # If no exact match, try partial match + elif department := Department.objects.filter( + hospital_id=complaint.hospital.id, + name__icontains=department_name, + status='active' + ).first(): + complaint.department = department + logger.info(f"Matched department partially: {department.name} from '{department_name}'") + else: + logger.warning(f"AI suggested department '{department_name}' but no match found in hospital '{complaint.hospital.name}'") # Update title from AI analysis (use English version) if analysis.get('title_en'): @@ -578,12 +706,62 @@ def analyze_complaint_with_ai(complaint_id): elif analysis.get('title'): complaint.title = analysis['title'] + # Get staff_name from analyze_complaint result (already extracted by AI) + staff_name = analysis.get('staff_name', '').strip() + + matched_staff_id = None + staff_confidence = 0.0 + staff_matching_method = None + + # Capture old staff before matching + old_staff = complaint.staff + + if staff_name: + logger.info(f"AI extracted staff name: {staff_name}") + + # Try matching WITH department filter first (higher confidence if match found) + matched_staff_id, staff_confidence, staff_matching_method = match_staff_from_name( + staff_name=staff_name, + hospital_id=str(complaint.hospital.id), + department_name=department_name + ) + + # If no match found with department, try WITHOUT department filter + if not matched_staff_id: + logger.info(f"No match found with department filter '{department_name}', trying without department filter...") + matched_staff_id, staff_confidence, staff_matching_method = match_staff_from_name( + staff_name=staff_name, + hospital_id=str(complaint.hospital.id), + department_name=None # Search all departments + ) + + # Only assign staff if confidence is above threshold (0.6) + if matched_staff_id and staff_confidence >= 0.6: + from apps.organizations.models import Staff + try: + staff = Staff.objects.get(id=matched_staff_id) + complaint.staff = staff + logger.info( + f"Assigned staff {staff.first_name} {staff.last_name} " + f"to complaint {complaint_id} " + f"(confidence: {staff_confidence:.2f}, method: {staff_matching_method})" + ) + except Staff.DoesNotExist: + logger.warning(f"Staff {matched_staff_id} not found in database") + else: + logger.info( + f"Staff match confidence {staff_confidence:.2f} below threshold 0.6, " + f"or no match found. Not assigning staff." + ) + # Save reasoning in metadata # Use JSON-serializable values instead of model objects old_category_name = old_category.name_en if old_category else None old_category_id = str(old_category.id) if old_category else None old_department_name = old_department.name if old_department else None old_department_id = str(old_department.id) if old_department else None + old_staff_name = f"{old_staff.first_name} {old_staff.last_name}" if old_staff else None + old_staff_id = str(old_staff.id) if old_staff else None # Initialize metadata if needed if not complaint.metadata: @@ -608,10 +786,16 @@ def analyze_complaint_with_ai(complaint_id): 'old_category': old_category_name, 'old_category_id': old_category_id, 'old_department': old_department_name, - 'old_department_id': old_department_id + 'old_department_id': old_department_id, + 'old_staff': old_staff_name, + 'old_staff_id': old_staff_id, + 'extracted_staff_name': staff_name, + 'matched_staff_id': matched_staff_id, + 'staff_confidence': staff_confidence, + 'staff_matching_method': staff_matching_method } - complaint.save(update_fields=['severity', 'priority', 'category', 'department', 'title', 'metadata']) + complaint.save(update_fields=['severity', 'priority', 'category', 'department', 'staff', 'title', 'metadata']) # Re-calculate SLA due date based on new severity complaint.due_at = complaint.calculate_sla_due_date() @@ -624,8 +808,19 @@ def analyze_complaint_with_ai(complaint_id): emotion_display = emotion_analysis.get('emotion', 'neutral') emotion_intensity = emotion_analysis.get('intensity', 0.0) - message_en = f"AI analysis complete: Severity={analysis['severity']}, Priority={analysis['priority']}, Category={analysis.get('category', 'N/A')}, Department={department_name or 'N/A'}, Emotion={emotion_display} (Intensity: {emotion_intensity:.2f})" - message_ar = f"اكتمل تحليل الذكاء الاصطناعي: الشدة={analysis['severity']}, الأولوية={analysis['priority']}, الفئة={analysis.get('category', 'N/A')}, القسم={department_name or 'N/A'}, العاطفة={emotion_display} (الشدة: {emotion_intensity:.2f})" + # Build English message + message_en = f"AI analysis complete: Severity={analysis['severity']}, Priority={analysis['priority']}, Category={analysis.get('category', 'N/A')}, Department={department_name or 'N/A'}" + if matched_staff_id: + message_en += f", Staff={f'{complaint.staff.first_name} {complaint.staff.last_name}' if complaint.staff else 'N/A'} (confidence: {staff_confidence:.2f})" + message_en += f", Emotion={emotion_display} (Intensity: {emotion_intensity:.2f})" + + # Build Arabic message + message_ar = f"اكتمل تحليل الذكاء الاصطناعي: الشدة={analysis['severity']}, الأولوية={analysis['priority']}, الفئة={analysis.get('category', 'N/A')}, القسم={department_name or 'N/A'}" + if matched_staff_id and complaint.staff: + staff_name_ar = complaint.staff.first_name_ar if complaint.staff.first_name_ar else complaint.staff.first_name + message_ar += f", الموظف={staff_name_ar} {complaint.staff.last_name_ar if complaint.staff.last_name_ar else complaint.staff.last_name} (الثقة: {staff_confidence:.2f})" + message_ar += f", العاطفة={emotion_display} (الشدة: {emotion_intensity:.2f})" + message = f"{message_en}\n\n{message_ar}" ComplaintUpdate.objects.create( diff --git a/apps/complaints/views.py b/apps/complaints/views.py index e8da8d4..577141b 100644 --- a/apps/complaints/views.py +++ b/apps/complaints/views.py @@ -19,6 +19,82 @@ from .serializers import ( ) +def map_complaint_category_to_action_category(complaint_category_code): + """ + Map complaint category code to PX Action category. + + Provides intelligent mapping from complaint categories to PX Action categories. + Returns 'other' as fallback if no match found. + """ + if not complaint_category_code: + return 'other' + + mapping = { + # Clinical issues + 'clinical': 'clinical_quality', + 'medical': 'clinical_quality', + 'diagnosis': 'clinical_quality', + 'treatment': 'clinical_quality', + 'medication': 'clinical_quality', + 'care': 'clinical_quality', + + # Safety issues + 'safety': 'patient_safety', + 'risk': 'patient_safety', + 'incident': 'patient_safety', + 'infection': 'patient_safety', + 'harm': 'patient_safety', + + # Service quality + 'service': 'service_quality', + 'communication': 'service_quality', + 'wait': 'service_quality', + 'response': 'service_quality', + 'customer_service': 'service_quality', + 'timeliness': 'service_quality', + 'waiting_time': 'service_quality', + + # Staff behavior + 'staff': 'staff_behavior', + 'behavior': 'staff_behavior', + 'attitude': 'staff_behavior', + 'professionalism': 'staff_behavior', + 'rude': 'staff_behavior', + 'respect': 'staff_behavior', + + # Facility + 'facility': 'facility', + 'environment': 'facility', + 'cleanliness': 'facility', + 'equipment': 'facility', + 'infrastructure': 'facility', + 'parking': 'facility', + 'accessibility': 'facility', + + # Process + 'process': 'process_improvement', + 'administrative': 'process_improvement', + 'billing': 'process_improvement', + 'procedure': 'process_improvement', + 'workflow': 'process_improvement', + 'registration': 'process_improvement', + 'appointment': 'process_improvement', + } + + # Try exact match first + category_lower = complaint_category_code.lower() + if category_lower in mapping: + return mapping[category_lower] + + # Try partial match (contains the keyword) + for keyword, action_category in mapping.items(): + if keyword in category_lower: + return action_category + + # Fallback to 'other' + return 'other' + + class ComplaintViewSet(viewsets.ModelViewSet): """ ViewSet for Complaints with workflow actions. @@ -205,6 +281,122 @@ class ComplaintViewSet(viewsets.ModelViewSet): serializer = ComplaintUpdateSerializer(update) return Response(serializer.data, status=status.HTTP_201_CREATED) + @action(detail=True, methods=['post']) + def create_action_from_ai(self, request, pk=None): + """Create PX Action from AI-suggested action""" + complaint = self.get_object() + + # Check if complaint has suggested action + suggested_action = request.data.get('suggested_action') + if not suggested_action and complaint.metadata and 'ai_analysis' in complaint.metadata: + suggested_action = complaint.metadata['ai_analysis'].get('suggested_action_en') + + if not suggested_action: + return Response( + {'error': 'No suggested action available for this complaint'}, + status=status.HTTP_400_BAD_REQUEST + ) + + # Get category (optional - will be auto-mapped from complaint category if not provided) + category = request.data.get('category') + + # If category not provided, auto-map from complaint category + if not category: + if complaint.category: + category = map_complaint_category_to_action_category(complaint.category.code) + else: + category = 'other' + + # Validate category choice if manually provided + valid_categories = [ + 'clinical_quality', 'patient_safety', 'service_quality', + 'staff_behavior', 'facility', 'process_improvement', 'other' + ] + if category not in valid_categories: + return Response( + {'error': f'Invalid category. Valid options: {", ".join(valid_categories)}'}, + status=status.HTTP_400_BAD_REQUEST + ) + + # Get optional assigned_to + assigned_to_id = request.data.get('assigned_to') + assigned_to = None + if assigned_to_id: + from apps.accounts.models import User + try: + assigned_to = User.objects.get(id=assigned_to_id) + except User.DoesNotExist: + return Response( + {'error': 'Assigned user not found'}, + status=status.HTTP_404_NOT_FOUND + ) + + # Create PX Action + from apps.px_action_center.models import PXAction, PXActionLog + from django.contrib.contenttypes.models import ContentType + + complaint_content_type = ContentType.objects.get_for_model(Complaint) + + action = PXAction.objects.create( + source_type='complaint', + content_type=complaint_content_type, + object_id=complaint.id, + title=f"Action from Complaint: {complaint.title}", + description=suggested_action, + hospital=complaint.hospital, + department=complaint.department, + category=category, + priority=complaint.priority, + severity=complaint.severity, + assigned_to=assigned_to, + status='open', + metadata={ + 'source_complaint_id': str(complaint.id), + 'source_complaint_title': complaint.title, + 'ai_generated': True, + 'created_from_ai_suggestion': True + } + ) + + # Create action log entry + PXActionLog.objects.create( + action=action, + log_type='note', + message=f"Action created from AI-suggested action for complaint: {complaint.title}", + created_by=request.user, + metadata={ + 'complaint_id': str(complaint.id), + 'ai_generated': True + } + ) + + # Create complaint update + ComplaintUpdate.objects.create( + complaint=complaint, + update_type='note', + message=f"PX Action created from AI-suggested action (Action #{action.id})", + created_by=request.user, + metadata={'action_id': str(action.id)} + ) + + # Log audit + AuditService.log_from_request( + event_type='action_created_from_ai', + description=f"PX Action created from AI-suggested action for complaint: {complaint.title}", + request=request, + content_object=action, + metadata={ + 'complaint_id': str(complaint.id), + 'category': category, + 'ai_generated': True + } + ) + + return Response({ + 'action_id': str(action.id), + 'message': 'Action created successfully from AI-suggested action' + }, status=status.HTTP_201_CREATED) + class ComplaintAttachmentViewSet(viewsets.ModelViewSet): """ViewSet for Complaint Attachments""" diff --git a/apps/core/ai_service.py b/apps/core/ai_service.py index ba81eac..028f743 100644 --- a/apps/core/ai_service.py +++ b/apps/core/ai_service.py @@ -280,7 +280,11 @@ class AIService: 5. If a category has no subcategories, leave the subcategory field empty 6. Select the most appropriate department from the hospital's departments (if available) 7. If no departments are available or department is unclear, leave the department field empty - 8. Generate a suggested_action (2-3 sentences) with specific, actionable steps to address this complaint in BOTH English and Arabic + 8. Extract any staff members mentioned in the complaint (physicians, nurses, etc.) + 9. Return the staff name WITHOUT titles (Dr., Nurse, دكتور, ممرض, etc.) + 10. If multiple staff are mentioned, return the PRIMARY one + 11. If no staff is mentioned, leave the staff_name field empty + 12. Generate a suggested_action (2-3 sentences) with specific, actionable steps to address this complaint in BOTH English and Arabic IMPORTANT: ALL TEXT FIELDS MUST BE PROVIDED IN BOTH ENGLISH AND ARABIC - title: Provide in both English and Arabic @@ -299,6 +303,7 @@ class AIService: "category": "exact category name from the list above", "subcategory": "exact subcategory name from the chosen category, or empty string if not applicable", "department": "exact department name from the hospital's departments, or empty string if not applicable", + "staff_name": "name of staff member mentioned (without titles like Dr., Nurse, etc.), or empty string if no staff mentioned", "suggested_action_en": "2-3 specific, actionable steps in English to address this complaint", "suggested_action_ar": "خطوات محددة وعمليه بالعربية", "reasoning_en": "Brief explanation in English of your classification (2-3 sentences)", @@ -361,6 +366,7 @@ class AIService: 'category': 'other', 'subcategory': '', 'department': '', + 'staff_name': '', 'reasoning': 'AI analysis failed, using default values' } except AIServiceError as e: @@ -372,6 +378,7 @@ class AIService: 'category': 'other', 'subcategory': '', 'department': '', + 'staff_name': '', 'reasoning': f'AI service unavailable: {str(e)}' } diff --git a/apps/core/urls.py b/apps/core/urls.py index c275317..3e1de0d 100644 --- a/apps/core/urls.py +++ b/apps/core/urls.py @@ -3,15 +3,34 @@ Core app URLs """ from django.urls import path -from .views import health_check, select_hospital, no_hospital_assigned +from .views import ( + health_check, + select_hospital, + no_hospital_assigned, + public_submit_landing, + public_inquiry_submit, + public_observation_submit, + api_hospitals, + api_observation_categories +) from . import config_views app_name = 'core' urlpatterns = [ + # Health check path('', health_check, name='health'), + + # Hospital selection path('select-hospital/', select_hospital, name='select_hospital'), path('no-hospital/', no_hospital_assigned, name='no_hospital_assigned'), + + # Public submission pages + path('public/submit/', public_submit_landing, name='public_submit_landing'), + path('public/inquiry/submit/', public_inquiry_submit, name='public_inquiry_submit'), + path('public/observation/submit/', public_observation_submit, name='public_observation_submit'), + path('api/hospitals/', api_hospitals, name='api_hospitals'), + path('api/observation-categories/', api_observation_categories, name='api_observation_categories'), ] # Configuration URLs (separate app_name) diff --git a/apps/core/views.py b/apps/core/views.py index d77e6a0..25274f1 100644 --- a/apps/core/views.py +++ b/apps/core/views.py @@ -90,3 +90,239 @@ def no_hospital_assigned(request): Users without a hospital assignment cannot access the system. """ return render(request, 'core/no_hospital_assigned.html', status=403) + + +# ============================================================================ +# PUBLIC SUBMISSION VIEWS +# ============================================================================ + +@require_GET +def public_submit_landing(request): + """ + Landing page for public submissions. + + Allows users to choose between Complaint, Observation, or Inquiry. + No authentication required. + """ + from apps.organizations.models import Hospital + + hospitals = Hospital.objects.all().order_by('name') + + context = { + 'hospitals': hospitals, + } + + return render(request, 'core/public_submit.html', context) + + +@require_POST +def public_inquiry_submit(request): + """ + Handle public inquiry submissions. + + Creates an inquiry from public submission. + Returns JSON response with reference number. + """ + from apps.complaints.models import Inquiry + from apps.organizations.models import Hospital + import uuid + + # Get form data + name = request.POST.get('name', '').strip() + email = request.POST.get('email', '').strip() + phone = request.POST.get('phone', '').strip() + hospital_id = request.POST.get('hospital') + category = request.POST.get('category', '').strip() + subject = request.POST.get('subject', '').strip() + message = request.POST.get('message', '').strip() + + # Validation + errors = [] + + if not name: + errors.append('Name is required') + if not email: + errors.append('Email is required') + if not phone: + errors.append('Phone number is required') + if not hospital_id: + errors.append('Hospital selection is required') + if not subject: + errors.append('Subject is required') + if not message: + errors.append('Message is required') + + if errors: + return JsonResponse({ + 'success': False, + 'errors': errors + }, status=400) + + try: + # Validate hospital + hospital = Hospital.objects.get(id=hospital_id) + + # Create inquiry (using correct field names from model) + inquiry = Inquiry.objects.create( + hospital=hospital, + contact_name=name, + contact_email=email, + contact_phone=phone, + subject=subject, + message=message, + category=category, + status='open' + ) + + # Generate a simple reference for display + reference_number = f"INQ-{str(inquiry.id)[:8].upper()}" + + # Send notification email (optional) + try: + from django.core.mail import send_mail + from django.conf import settings + send_mail( + f'New Public Inquiry - {reference_number}', + f'Inquiry from {name}\n\nSubject: {subject}\n\nMessage:\n{message}', + settings.DEFAULT_FROM_EMAIL, + [settings.DEFAULT_FROM_EMAIL], # Send to admin + fail_silently=True, + ) + except Exception: + pass # Don't fail if email doesn't send + + return JsonResponse({ + 'success': True, + 'reference_number': reference_number, + 'inquiry_id': str(inquiry.id) + }) + + except Hospital.DoesNotExist: + return JsonResponse({ + 'success': False, + 'errors': ['Invalid hospital selected'] + }, status=400) + except Exception as e: + return JsonResponse({ + 'success': False, + 'errors': [str(e)] + }, status=500) + + +@require_GET +def api_hospitals(request): + """ + API endpoint to get hospitals list. + + Used by public submission forms to populate hospital dropdown. + """ + from apps.organizations.models import Hospital + + hospitals = Hospital.objects.all().order_by('name').values('id', 'name') + + return JsonResponse({ + 'success': True, + 'hospitals': list(hospitals) + }) + + +@require_GET +def api_observation_categories(request): + """ + API endpoint to get observation categories list. + + Used by public observation form to populate category dropdown. + """ + from apps.observations.models import ObservationCategory + + categories = ObservationCategory.objects.filter(is_active=True).order_by('sort_order', 'name_en').values('id', 'name_en', 'name_ar') + + return JsonResponse({ + 'success': True, + 'categories': list(categories) + }) + + +@require_POST +def public_observation_submit(request): + """ + Handle public observation submissions. + + Creates an observation from public submission. + Returns JSON response with tracking code. + """ + from apps.observations.models import Observation, ObservationAttachment + from apps.observations.services import ObservationService + import mimetypes + + # Get form data + category_id = request.POST.get('category') + severity = request.POST.get('severity', 'medium') + title = request.POST.get('title', '').strip() + description = request.POST.get('description', '').strip() + location_text = request.POST.get('location_text', '').strip() + incident_datetime = request.POST.get('incident_datetime', '') + reporter_staff_id = request.POST.get('reporter_staff_id', '').strip() + reporter_name = request.POST.get('reporter_name', '').strip() + reporter_phone = request.POST.get('reporter_phone', '').strip() + reporter_email = request.POST.get('reporter_email', '').strip() + + # Validation + errors = [] + + if not description: + errors.append('Description is required') + + if severity not in ['low', 'medium', 'high', 'critical']: + errors.append('Invalid severity selected') + + if errors: + return JsonResponse({ + 'success': False, + 'errors': errors + }, status=400) + + try: + # Get client info + def get_client_ip(req): + x_forwarded_for = req.META.get('HTTP_X_FORWARDED_FOR') + if x_forwarded_for: + ip = x_forwarded_for.split(',')[0].strip() + else: + ip = req.META.get('REMOTE_ADDR') + return ip + + client_ip = get_client_ip(request) + user_agent = request.META.get('HTTP_USER_AGENT', '') + + # Handle file uploads + attachments = request.FILES.getlist('attachments') + + # Create observation using service + observation = ObservationService.create_observation( + description=description, + severity=severity, + category_id=category_id if category_id else None, + title=title, + location_text=location_text, + incident_datetime=incident_datetime if incident_datetime else None, + reporter_staff_id=reporter_staff_id, + reporter_name=reporter_name, + reporter_phone=reporter_phone, + reporter_email=reporter_email, + client_ip=client_ip, + user_agent=user_agent, + attachments=attachments, + ) + + return JsonResponse({ + 'success': True, + 'tracking_code': observation.tracking_code, + 'observation_id': str(observation.id) + }) + + except Exception as e: + return JsonResponse({ + 'success': False, + 'errors': [str(e)] + }, status=500) diff --git a/apps/organizations/migrations/0003_patient_department.py b/apps/organizations/migrations/0003_patient_department.py new file mode 100644 index 0000000..7d2b2b6 --- /dev/null +++ b/apps/organizations/migrations/0003_patient_department.py @@ -0,0 +1,19 @@ +# Generated by Django 5.0.14 on 2026-01-06 11:55 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('organizations', '0002_hospital_metadata'), + ] + + operations = [ + migrations.AddField( + model_name='patient', + name='department', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='organizations.department'), + ), + ] diff --git a/apps/organizations/migrations/0004_remove_patient_department.py b/apps/organizations/migrations/0004_remove_patient_department.py new file mode 100644 index 0000000..9df7076 --- /dev/null +++ b/apps/organizations/migrations/0004_remove_patient_department.py @@ -0,0 +1,17 @@ +# Generated by Django 5.0.14 on 2026-01-06 11:56 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('organizations', '0003_patient_department'), + ] + + operations = [ + migrations.RemoveField( + model_name='patient', + name='department', + ), + ] diff --git a/config/urls.py b/config/urls.py index a47721b..010fb8c 100644 --- a/config/urls.py +++ b/config/urls.py @@ -11,16 +11,19 @@ from drf_spectacular.views import SpectacularAPIView, SpectacularRedocView, Spec urlpatterns = [ # Language switching path('i18n/', include('django.conf.urls.i18n')), - + # Admin path('admin/', admin.site.urls), - + # Dashboard path('', include('apps.dashboard.urls')), - + # Health check endpoint path('health/', include('apps.core.urls')), - + + # Core pages (public submissions, hospital selection) + path('core/', include('apps.core.urls')), + # UI Pages path('complaints/', include('apps.complaints.urls')), path('feedback/', include('apps.feedback.urls')), @@ -37,14 +40,14 @@ urlpatterns = [ path('ai-engine/', include('apps.ai_engine.urls')), path('appreciation/', include('apps.appreciation.urls', namespace='appreciation')), path('observations/', include('apps.observations.urls', namespace='observations')), - + # API endpoints path('api/auth/', include('apps.accounts.urls')), path('api/physicians/', include('apps.physicians.urls')), path('api/integrations/', include('apps.integrations.urls')), path('api/notifications/', include('apps.notifications.urls')), path('api/v1/appreciation/', include('apps.appreciation.urls', namespace='api_appreciation')), - + # OpenAPI/Swagger documentation path('api/schema/', SpectacularAPIView.as_view(), name='schema'), path('api/docs/', SpectacularSwaggerView.as_view(url_name='schema'), name='swagger-ui'), diff --git a/generate_saudi_data.py b/generate_saudi_data.py index 4cdaad1..bd02df4 100644 --- a/generate_saudi_data.py +++ b/generate_saudi_data.py @@ -1588,34 +1588,36 @@ def main(): print("="*60 + "\n") # Clear existing data first - clear_existing_data() + # clear_existing_data() # Create base data - hospitals = create_hospitals() + # hospitals = create_hospitals() + hospitals = Hospital.objects.all() + departments = create_departments(hospitals) staff = create_staff(hospitals, departments) patients = create_patients(hospitals) - create_users(hospitals) + # create_users(hospitals) # Get all users for assignments - users = list(User.objects.all()) + # users = list(User.objects.all()) # Create complaint categories first - categories = create_complaint_categories(hospitals) + # categories = create_complaint_categories(hospitals) # Create operational data - complaints = create_complaints(patients, hospitals, physicians, users) - inquiries = create_inquiries(patients, hospitals, users) - feedbacks = create_feedback(patients, hospitals, physicians, users) - create_survey_templates(hospitals) - create_journey_templates(hospitals) - projects = create_qi_projects(hospitals) - actions = create_px_actions(complaints, hospitals, users) - journey_instances = create_journey_instances(None, patients) - survey_instances = create_survey_instances(None, patients, physicians) - call_interactions = create_call_center_interactions(patients, hospitals, users) - social_mentions = create_social_mentions(hospitals) - physician_ratings = create_physician_monthly_ratings(physicians) + # complaints = create_complaints(patients, hospitals, physicians, users) + # inquiries = create_inquiries(patients, hospitals, users) + # feedbacks = create_feedback(patients, hospitals, physicians, users) + # create_survey_templates(hospitals) + # create_journey_templates(hospitals) + # projects = create_qi_projects(hospitals) + # actions = create_px_actions(complaints, hospitals, users) + # journey_instances = create_journey_instances(None, patients) + # survey_instances = create_survey_instances(None, patients, physicians) + # call_interactions = create_call_center_interactions(patients, hospitals, users) + # social_mentions = create_social_mentions(hospitals) + # physician_ratings = create_physician_monthly_ratings(physicians) print("\n" + "="*60) print("Data Generation Complete!") @@ -1625,21 +1627,21 @@ def main(): print(f" - {len(departments)} Departments") print(f" - {len(staff)} Staff") print(f" - {len(patients)} Patients") - print(f" - {len(users)} Users") - print(f" - {len(complaints)} Complaints (2 years)") - print(f" - {len(inquiries)} Inquiries (2 years)") - print(f" - {len(feedbacks)} Feedback Items") - print(f" - {len(actions)} PX Actions") - print(f" - {len(journey_instances)} Journey Instances") - print(f" - {len(survey_instances)} Survey Instances") - print(f" - {len(call_interactions)} Call Center Interactions") - print(f" - {len(social_mentions)} Social Media Mentions") - print(f" - {len(projects)} QI Projects") - print(f" - {len(staff_ratings)} Staff Monthly Ratings") - print(f" - {len(appreciations)} Appreciations (2 years)") - print(f" - {len(user_badges)} Badges Awarded") - print(f" - {len(appreciation_stats)} Appreciation Statistics") - print(f" - {len(observations)} Observations (2 years)") + # print(f" - {len(users)} Users") + # print(f" - {len(complaints)} Complaints (2 years)") + # print(f" - {len(inquiries)} Inquiries (2 years)") + # print(f" - {len(feedbacks)} Feedback Items") + # print(f" - {len(actions)} PX Actions") + # print(f" - {len(journey_instances)} Journey Instances") + # print(f" - {len(survey_instances)} Survey Instances") + # print(f" - {len(call_interactions)} Call Center Interactions") + # print(f" - {len(social_mentions)} Social Media Mentions") + # print(f" - {len(projects)} QI Projects") + # print(f" - {len(staff_ratings)} Staff Monthly Ratings") + # print(f" - {len(appreciations)} Appreciations (2 years)") + # print(f" - {len(user_badges)} Badges Awarded") + # print(f" - {len(appreciation_stats)} Appreciation Statistics") + # print(f" - {len(observations)} Observations (2 years)") print(f"\nYou can now login with:") print(f" Username: px_admin") print(f" Password: admin123") diff --git a/pyproject.toml b/pyproject.toml index e7a79cc..42cbfda 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,6 +27,7 @@ dependencies = [ "reportlab>=4.4.7", "openpyxl>=3.1.5", "litellm>=1.0.0", + "watchdog>=6.0.0", ] [project.optional-dependencies] diff --git a/templates/complaints/complaint_detail.html b/templates/complaints/complaint_detail.html index 4504fb2..1e86593 100644 --- a/templates/complaints/complaint_detail.html +++ b/templates/complaints/complaint_detail.html @@ -257,6 +257,64 @@ {% endif %} + {% if complaint.department %} +
+
+
{{ _("Department") }}
+
+
+ +
+ {{ complaint.department.name_en }} + {% if complaint.department.name_ar %} + ({{ complaint.department.name_ar }}) + {% endif %} +
+ {% if complaint.metadata.ai_analysis.old_department %} + + AI Mapped + + {% endif %} +
+
+
+
+ {% endif %} + + {% if complaint.staff %} +
+
+
{{ _("Staff Member") }}
+
+
+ +
+ {{ complaint.staff.first_name }} {{ complaint.staff.last_name }} + {% if complaint.staff.first_name_ar or complaint.staff.last_name_ar %} + ({{ complaint.staff.first_name_ar }} {{ complaint.staff.last_name_ar }}) + {% endif %} + {% if complaint.staff.job_title %} +
{{ complaint.staff.job_title }}
+ {% endif %} +
+ + AI Matched + +
+ {% if complaint.metadata.ai_analysis.extracted_staff_name %} + + + Extracted from complaint: "{{ complaint.metadata.ai_analysis.extracted_staff_name }}" + {% if complaint.metadata.ai_analysis.staff_confidence %} + (confidence: {{ complaint.metadata.ai_analysis.staff_confidence|floatformat:0 }}%) + {% endif %} + + {% endif %} +
+
+
+ {% endif %} +
@@ -334,6 +392,9 @@ {{ complaint.suggested_action }}
+ {% endif %} @@ -669,7 +730,7 @@ + + + + + {% endblock %} diff --git a/templates/complaints/public_complaint_form.html b/templates/complaints/public_complaint_form.html index b3fbf17..d7d768d 100644 --- a/templates/complaints/public_complaint_form.html +++ b/templates/complaints/public_complaint_form.html @@ -304,45 +304,44 @@ {% block extra_js %} {% endblock %} diff --git a/templates/complaints/public_inquiry_form.html b/templates/complaints/public_inquiry_form.html new file mode 100644 index 0000000..3d55206 --- /dev/null +++ b/templates/complaints/public_inquiry_form.html @@ -0,0 +1,198 @@ +{% load i18n %} + + +
+
+

{% trans "Inquiry Details" %}

+

{% trans "Ask a question or request information. We'll get back to you within 24-48 hours." %}

+ + +
+
+
+ + +
+
+
+
+ + +
+
+
+ +
+
+
+ + +
+
+
+
+ + +
+
+
+ + +
+ + +
+ +
+ + +
+ +
+ + +
+ + +
+ +
+
+
+ + diff --git a/templates/core/public_submit.html b/templates/core/public_submit.html new file mode 100644 index 0000000..5aa73f4 --- /dev/null +++ b/templates/core/public_submit.html @@ -0,0 +1,948 @@ +{% extends "layouts/public_base.html" %} +{% load i18n %} + +{% block title %}{% trans "Submit Feedback" %}{% endblock %} + +{% block extra_css %} + +{% endblock %} + +{% block content %} +
+ +
+

{% trans "We Value Your Feedback" %}

+

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

+
+ + +
+ +
+
+ +
+

{% trans "Complaint" %}

+

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

+
+ + +
+
+ +
+

{% trans "Observation" %}

+

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

+
+ + +
+
+ +
+

{% trans "Inquiry" %}

+

+ {% trans "Ask questions or request information. Need help understanding services, appointments, or policies?" %} +

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