From 6e829f1573a85a2137947a049f363fcc8895501b Mon Sep 17 00:00:00 2001 From: Marwan Alwali Date: Sun, 4 Jan 2026 10:32:40 +0300 Subject: [PATCH] added-observations --- apps/observations/README.md | 195 +++++ apps/observations/__init__.py | 11 + apps/observations/admin.py | 221 ++++++ apps/observations/apps.py | 18 + apps/observations/forms.py | 413 +++++++++++ apps/observations/management/__init__.py | 1 + .../management/commands/__init__.py | 1 + .../commands/seed_observation_categories.py | 163 +++++ apps/observations/migrations/0001_initial.py | 147 ++++ apps/observations/migrations/__init__.py | 1 + apps/observations/models.py | 415 +++++++++++ apps/observations/services.py | 599 ++++++++++++++++ apps/observations/signals.py | 40 ++ apps/observations/tests.py | 488 +++++++++++++ apps/observations/urls.py | 82 +++ apps/observations/views.py | 672 ++++++++++++++++++ config/settings/base.py | 1 + config/urls.py | 1 + templates/layouts/partials/sidebar.html | 55 ++ templates/observations/category_form.html | 103 +++ templates/observations/category_list.html | 111 +++ templates/observations/convert_to_action.html | 103 +++ .../observations/observation_detail.html | 462 ++++++++++++ templates/observations/observation_list.html | 453 ++++++++++++ templates/observations/public_new.html | 339 +++++++++ templates/observations/public_success.html | 191 +++++ templates/observations/public_track.html | 274 +++++++ 27 files changed, 5560 insertions(+) create mode 100644 apps/observations/README.md create mode 100644 apps/observations/__init__.py create mode 100644 apps/observations/admin.py create mode 100644 apps/observations/apps.py create mode 100644 apps/observations/forms.py create mode 100644 apps/observations/management/__init__.py create mode 100644 apps/observations/management/commands/__init__.py create mode 100644 apps/observations/management/commands/seed_observation_categories.py create mode 100644 apps/observations/migrations/0001_initial.py create mode 100644 apps/observations/migrations/__init__.py create mode 100644 apps/observations/models.py create mode 100644 apps/observations/services.py create mode 100644 apps/observations/signals.py create mode 100644 apps/observations/tests.py create mode 100644 apps/observations/urls.py create mode 100644 apps/observations/views.py create mode 100644 templates/observations/category_form.html create mode 100644 templates/observations/category_list.html create mode 100644 templates/observations/convert_to_action.html create mode 100644 templates/observations/observation_detail.html create mode 100644 templates/observations/observation_list.html create mode 100644 templates/observations/public_new.html create mode 100644 templates/observations/public_success.html create mode 100644 templates/observations/public_track.html diff --git a/apps/observations/README.md b/apps/observations/README.md new file mode 100644 index 0000000..2fe3038 --- /dev/null +++ b/apps/observations/README.md @@ -0,0 +1,195 @@ +# Observations App + +Staff observation reporting module for Al Hammadi Hospital. + +## Overview + +This app allows any staff member at Al Hammadi to report issues they notice. Reporting can be anonymous (no login required), but the user may optionally provide Staff ID and Name. + +PX360 staff will triage the observation and route it to the responsible department and/or create an action in the PX Action Center. + +## Features + +- **Anonymous Reporting**: No login required for submission +- **Optional Identification**: Staff can optionally provide their ID and name +- **Tracking System**: Unique tracking codes for public status lookup +- **Full Lifecycle Management**: Status tracking from NEW to CLOSED +- **Department Routing**: Assign observations to responsible departments +- **Action Center Integration**: Convert observations to PX Actions +- **Notification System**: Automated notifications for triage team and assignees +- **Bilingual Support**: English and Arabic category names + +## Routes + +### Public Routes (No Login Required) + +| Route | Method | Description | +|-------|--------|-------------| +| `/observations/new/` | GET/POST | Submit new observation | +| `/observations/submitted//` | GET | Success page with tracking code | +| `/observations/track/` | GET | Track observation by code | + +### Internal Routes (Login Required) + +| Route | Method | Description | +|-------|--------|-------------| +| `/observations/` | GET | List all observations with filters | +| `/observations//` | GET | Observation detail with timeline | +| `/observations//triage/` | POST | Triage observation | +| `/observations//status/` | POST | Change observation status | +| `/observations//note/` | POST | Add internal note | +| `/observations//convert-to-action/` | GET/POST | Convert to PX Action | + +### Category Management (Permission Required) + +| Route | Method | Description | +|-------|--------|-------------| +| `/observations/categories/` | GET | List categories | +| `/observations/categories/create/` | GET/POST | Create category | +| `/observations/categories//edit/` | GET/POST | Edit category | +| `/observations/categories//delete/` | POST | Delete category | + +## Permissions + +| Permission | Description | +|------------|-------------| +| `observations.view_observation` | Can view observations | +| `observations.triage_observation` | Can triage observations | +| `observations.manage_categories` | Can manage observation categories | + +## Models + +### ObservationCategory +- `name_en`, `name_ar`: Bilingual names +- `description`: Category description +- `icon`: Bootstrap icon class +- `sort_order`: Display order +- `is_active`: Active status + +### Observation +- `tracking_code`: Unique code (e.g., OBS-ABC123) +- `category`: FK to ObservationCategory +- `title`: Optional short title +- `description`: Required detailed description +- `severity`: LOW, MEDIUM, HIGH, CRITICAL +- `location_text`: Where the issue was observed +- `incident_datetime`: When the issue occurred +- `reporter_staff_id`, `reporter_name`, `reporter_phone`, `reporter_email`: Optional reporter info +- `status`: NEW, TRIAGED, ASSIGNED, IN_PROGRESS, RESOLVED, CLOSED, REJECTED, DUPLICATE +- `assigned_department`: FK to Department +- `assigned_to`: FK to User +- `action_id`: Link to PX Action if converted + +### ObservationAttachment +- `observation`: FK to Observation +- `file`: Uploaded file +- `filename`, `file_type`, `file_size`: File metadata + +### ObservationNote +- `observation`: FK to Observation +- `note`: Note text +- `created_by`: FK to User +- `is_internal`: Internal-only flag + +### ObservationStatusLog +- `observation`: FK to Observation +- `from_status`, `to_status`: Status transition +- `changed_by`: FK to User +- `comment`: Optional comment + +## Workflow + +1. **Submission**: Staff submits observation via public form (anonymous or identified) +2. **Notification**: PX360 triage team receives notification +3. **Triage**: Staff triages observation, assigns department/owner +4. **Assignment**: Assigned user receives notification +5. **Resolution**: Issue is resolved and closed +6. **Action Center**: Optionally convert to PX Action for formal tracking + +## Installation + +1. Add to `INSTALLED_APPS` in settings: +```python +LOCAL_APPS = [ + ... + 'apps.observations', +] +``` + +2. Add URL configuration: +```python +urlpatterns = [ + ... + path('observations/', include('apps.observations.urls')), +] +``` + +3. Run migrations: +```bash +python manage.py makemigrations observations +python manage.py migrate +``` + +4. Seed default categories: +```bash +python manage.py seed_observation_categories +``` + +## Management Commands + +### seed_observation_categories +Seeds default observation categories with bilingual names. + +```bash +# Seed categories (update existing) +python manage.py seed_observation_categories + +# Clear and reseed +python manage.py seed_observation_categories --clear +``` + +## Testing + +Run tests: +```bash +python manage.py test apps.observations +``` + +## Integration + +### Action Center +Observations can be converted to PX Actions via the "Convert to Action" feature. This creates a linked action with: +- Title derived from observation +- Description with observation details +- Priority mapped from severity +- Link back to original observation + +### Notifications +The app integrates with `apps.notifications` to send: +- New observation alerts to PX Admin group +- Assignment notifications to assigned users +- Resolution notifications to stakeholders + +## Templates + +Templates are located in `templates/observations/`: +- `public_new.html`: Public submission form +- `public_success.html`: Success page with tracking code +- `public_track.html`: Public tracking page +- `observation_list.html`: Internal list view +- `observation_detail.html`: Internal detail view +- `convert_to_action.html`: Convert to action form +- `category_list.html`: Category management list +- `category_form.html`: Category create/edit form + +## API Endpoints + +### AJAX Helper +- `GET /observations/api/users-by-department/?department_id=`: Get users for a department + +## Security + +- Public forms include honeypot field for spam protection +- Internal views require authentication +- Category management requires specific permission +- RBAC filtering based on user's hospital/department diff --git a/apps/observations/__init__.py b/apps/observations/__init__.py new file mode 100644 index 0000000..0088156 --- /dev/null +++ b/apps/observations/__init__.py @@ -0,0 +1,11 @@ +""" +Observations app - Staff observation reporting module for Al Hammadi. + +This app allows any staff member to report issues they notice. +Reporting can be anonymous (no login required), but the user may optionally +provide Staff ID and Name. + +PX360 staff will triage the observation and route it to the responsible +department and/or create an action in apps.px_action_center. +""" +default_app_config = 'apps.observations.apps.ObservationsConfig' diff --git a/apps/observations/admin.py b/apps/observations/admin.py new file mode 100644 index 0000000..131be88 --- /dev/null +++ b/apps/observations/admin.py @@ -0,0 +1,221 @@ +""" +Observations admin configuration. +""" +from django.contrib import admin +from django.utils.html import format_html + +from .models import ( + Observation, + ObservationAttachment, + ObservationCategory, + ObservationNote, + ObservationStatusLog, +) + + +@admin.register(ObservationCategory) +class ObservationCategoryAdmin(admin.ModelAdmin): + """Admin for ObservationCategory model.""" + list_display = ['name_en', 'name_ar', 'is_active', 'sort_order', 'observation_count', 'created_at'] + list_filter = ['is_active', 'created_at'] + search_fields = ['name_en', 'name_ar', 'description'] + ordering = ['sort_order', 'name_en'] + + fieldsets = ( + (None, { + 'fields': ('name_en', 'name_ar', 'description') + }), + ('Settings', { + 'fields': ('icon', 'sort_order', 'is_active') + }), + ) + + def observation_count(self, obj): + return obj.observations.count() + observation_count.short_description = 'Observations' + + +class ObservationAttachmentInline(admin.TabularInline): + """Inline admin for ObservationAttachment.""" + model = ObservationAttachment + extra = 0 + readonly_fields = ['filename', 'file_type', 'file_size', 'created_at'] + fields = ['file', 'filename', 'file_type', 'file_size', 'description', 'created_at'] + + +class ObservationNoteInline(admin.TabularInline): + """Inline admin for ObservationNote.""" + model = ObservationNote + extra = 0 + readonly_fields = ['created_by', 'created_at'] + fields = ['note', 'is_internal', 'created_by', 'created_at'] + + +class ObservationStatusLogInline(admin.TabularInline): + """Inline admin for ObservationStatusLog.""" + model = ObservationStatusLog + extra = 0 + readonly_fields = ['from_status', 'to_status', 'changed_by', 'comment', 'created_at'] + fields = ['from_status', 'to_status', 'changed_by', 'comment', 'created_at'] + can_delete = False + + def has_add_permission(self, request, obj=None): + return False + + +@admin.register(Observation) +class ObservationAdmin(admin.ModelAdmin): + """Admin for Observation model.""" + list_display = [ + 'tracking_code', 'title_display', 'category', 'severity_badge', + 'status_badge', 'reporter_display', 'assigned_department', + 'assigned_to', 'created_at' + ] + list_filter = [ + 'status', 'severity', 'category', 'assigned_department', + 'created_at', 'triaged_at', 'resolved_at' + ] + search_fields = [ + 'tracking_code', 'title', 'description', + 'reporter_name', 'reporter_staff_id', 'location_text' + ] + readonly_fields = [ + 'tracking_code', 'created_at', 'updated_at', + 'triaged_at', 'resolved_at', 'closed_at', + 'client_ip', 'user_agent' + ] + ordering = ['-created_at'] + date_hierarchy = 'created_at' + + fieldsets = ( + ('Tracking', { + 'fields': ('tracking_code', 'status') + }), + ('Content', { + 'fields': ('category', 'title', 'description', 'severity') + }), + ('Location & Time', { + 'fields': ('location_text', 'incident_datetime') + }), + ('Reporter Information', { + 'fields': ('reporter_staff_id', 'reporter_name', 'reporter_phone', 'reporter_email'), + 'classes': ('collapse',) + }), + ('Assignment', { + 'fields': ('assigned_department', 'assigned_to') + }), + ('Triage', { + 'fields': ('triaged_by', 'triaged_at'), + 'classes': ('collapse',) + }), + ('Resolution', { + 'fields': ('resolved_by', 'resolved_at', 'resolution_notes'), + 'classes': ('collapse',) + }), + ('Closure', { + 'fields': ('closed_by', 'closed_at'), + 'classes': ('collapse',) + }), + ('Action Center', { + 'fields': ('action_id',), + 'classes': ('collapse',) + }), + ('Metadata', { + 'fields': ('client_ip', 'user_agent', 'metadata', 'created_at', 'updated_at'), + 'classes': ('collapse',) + }), + ) + + inlines = [ObservationAttachmentInline, ObservationNoteInline, ObservationStatusLogInline] + + def title_display(self, obj): + if obj.title: + return obj.title[:50] + '...' if len(obj.title) > 50 else obj.title + return obj.description[:50] + '...' if len(obj.description) > 50 else obj.description + title_display.short_description = 'Title/Description' + + def severity_badge(self, obj): + colors = { + 'low': '#28a745', + 'medium': '#ffc107', + 'high': '#dc3545', + 'critical': '#343a40', + } + color = colors.get(obj.severity, '#6c757d') + return format_html( + '{}', + color, obj.get_severity_display() + ) + severity_badge.short_description = 'Severity' + + def status_badge(self, obj): + colors = { + 'new': '#007bff', + 'triaged': '#17a2b8', + 'assigned': '#17a2b8', + 'in_progress': '#ffc107', + 'resolved': '#28a745', + 'closed': '#6c757d', + 'rejected': '#dc3545', + 'duplicate': '#6c757d', + } + color = colors.get(obj.status, '#6c757d') + return format_html( + '{}', + color, obj.get_status_display() + ) + status_badge.short_description = 'Status' + + def reporter_display(self, obj): + if obj.is_anonymous: + return format_html('Anonymous') + return obj.reporter_display + reporter_display.short_description = 'Reporter' + + +@admin.register(ObservationAttachment) +class ObservationAttachmentAdmin(admin.ModelAdmin): + """Admin for ObservationAttachment model.""" + list_display = ['observation', 'filename', 'file_type', 'file_size_display', 'created_at'] + list_filter = ['file_type', 'created_at'] + search_fields = ['observation__tracking_code', 'filename', 'description'] + readonly_fields = ['file_size', 'created_at'] + + def file_size_display(self, obj): + if obj.file_size < 1024: + return f"{obj.file_size} B" + elif obj.file_size < 1024 * 1024: + return f"{obj.file_size / 1024:.1f} KB" + else: + return f"{obj.file_size / (1024 * 1024):.1f} MB" + file_size_display.short_description = 'Size' + + +@admin.register(ObservationNote) +class ObservationNoteAdmin(admin.ModelAdmin): + """Admin for ObservationNote model.""" + list_display = ['observation', 'note_preview', 'created_by', 'is_internal', 'created_at'] + list_filter = ['is_internal', 'created_at'] + search_fields = ['observation__tracking_code', 'note', 'created_by__email'] + readonly_fields = ['created_at'] + + def note_preview(self, obj): + return obj.note[:100] + '...' if len(obj.note) > 100 else obj.note + note_preview.short_description = 'Note' + + +@admin.register(ObservationStatusLog) +class ObservationStatusLogAdmin(admin.ModelAdmin): + """Admin for ObservationStatusLog model.""" + list_display = ['observation', 'from_status', 'to_status', 'changed_by', 'created_at'] + list_filter = ['from_status', 'to_status', 'created_at'] + search_fields = ['observation__tracking_code', 'comment', 'changed_by__email'] + readonly_fields = ['observation', 'from_status', 'to_status', 'changed_by', 'comment', 'created_at'] + + def has_add_permission(self, request): + return False + + def has_change_permission(self, request, obj=None): + return False diff --git a/apps/observations/apps.py b/apps/observations/apps.py new file mode 100644 index 0000000..371ce1c --- /dev/null +++ b/apps/observations/apps.py @@ -0,0 +1,18 @@ +""" +Observations app configuration. +""" +from django.apps import AppConfig + + +class ObservationsConfig(AppConfig): + """Configuration for the Observations app.""" + default_auto_field = 'django.db.models.BigAutoField' + name = 'apps.observations' + verbose_name = 'Observations' + + def ready(self): + """Import signals when app is ready.""" + try: + import apps.observations.signals # noqa + except ImportError: + pass diff --git a/apps/observations/forms.py b/apps/observations/forms.py new file mode 100644 index 0000000..fee0f2e --- /dev/null +++ b/apps/observations/forms.py @@ -0,0 +1,413 @@ +""" +Observations forms - Public and internal forms for observation management. +""" +from django import forms +from django.core.validators import FileExtensionValidator +from django.utils import timezone + +from apps.accounts.models import User +from apps.organizations.models import Department + +from .models import ( + Observation, + ObservationAttachment, + ObservationCategory, + ObservationNote, + ObservationSeverity, + ObservationStatus, +) + + +# Allowed file extensions for attachments +ALLOWED_EXTENSIONS = ['jpg', 'jpeg', 'png', 'gif', 'pdf', 'doc', 'docx', 'xls', 'xlsx'] +MAX_FILE_SIZE = 10 * 1024 * 1024 # 10 MB + + +class ObservationPublicForm(forms.ModelForm): + """ + Public form for submitting observations. + + Features: + - No login required + - Optional reporter information + - Honeypot field for spam protection + - File attachments support + """ + # Honeypot field for spam protection + website = forms.CharField( + required=False, + widget=forms.TextInput(attrs={ + 'style': 'display:none !important;', + 'tabindex': '-1', + 'autocomplete': 'off', + }), + label='' + ) + + # File attachments (handled separately in the view) + # Note: Multiple file upload is handled via HTML attribute and view processing + attachments = forms.FileField( + required=False, + widget=forms.FileInput(attrs={ + 'accept': '.jpg,.jpeg,.png,.gif,.pdf,.doc,.docx,.xls,.xlsx', + 'class': 'form-control', + }), + help_text="Upload a file (max 10MB). Allowed: images, PDF, Word, Excel." + ) + + class Meta: + model = Observation + fields = [ + 'category', + 'title', + 'description', + 'severity', + 'location_text', + 'incident_datetime', + 'reporter_staff_id', + 'reporter_name', + 'reporter_phone', + 'reporter_email', + ] + widgets = { + 'category': forms.Select(attrs={ + 'class': 'form-select', + }), + 'title': forms.TextInput(attrs={ + 'class': 'form-control', + 'placeholder': 'Brief title (optional)', + }), + 'description': forms.Textarea(attrs={ + 'class': 'form-control', + 'rows': 5, + 'placeholder': 'Please describe what you observed in detail...', + }), + 'severity': forms.Select(attrs={ + 'class': 'form-select', + }), + 'location_text': forms.TextInput(attrs={ + 'class': 'form-control', + 'placeholder': 'e.g., Building A, Floor 2, Room 205', + }), + 'incident_datetime': forms.DateTimeInput(attrs={ + 'class': 'form-control', + 'type': 'datetime-local', + }), + 'reporter_staff_id': forms.TextInput(attrs={ + 'class': 'form-control', + 'placeholder': 'Your staff ID (optional)', + }), + 'reporter_name': forms.TextInput(attrs={ + 'class': 'form-control', + 'placeholder': 'Your name (optional)', + }), + 'reporter_phone': forms.TextInput(attrs={ + 'class': 'form-control', + 'placeholder': 'Your phone number (optional)', + }), + 'reporter_email': forms.EmailInput(attrs={ + 'class': 'form-control', + 'placeholder': 'Your email (optional)', + }), + } + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # Only show active categories + self.fields['category'].queryset = ObservationCategory.objects.filter( + is_active=True + ).order_by('sort_order', 'name_en') + self.fields['category'].empty_label = "Select a category (optional)" + + # Set default incident datetime to now + if not self.instance.pk: + self.initial['incident_datetime'] = timezone.now().strftime('%Y-%m-%dT%H:%M') + + def clean_website(self): + """Honeypot validation - should be empty.""" + website = self.cleaned_data.get('website') + if website: + raise forms.ValidationError("Spam detected.") + return website + + def clean_description(self): + """Validate description is not too short.""" + description = self.cleaned_data.get('description', '') + if len(description.strip()) < 10: + raise forms.ValidationError( + "Please provide a more detailed description (at least 10 characters)." + ) + return description + + +class ObservationTriageForm(forms.Form): + """ + Form for triaging observations. + + Used by PX360 staff to assign department, owner, and update status. + """ + assigned_department = forms.ModelChoiceField( + queryset=Department.objects.filter(status='active'), + required=False, + empty_label="Select department", + widget=forms.Select(attrs={'class': 'form-select'}) + ) + + assigned_to = forms.ModelChoiceField( + queryset=User.objects.filter(is_active=True), + required=False, + empty_label="Select assignee", + widget=forms.Select(attrs={'class': 'form-select'}) + ) + + status = forms.ChoiceField( + choices=ObservationStatus.choices, + widget=forms.Select(attrs={'class': 'form-select'}) + ) + + note = forms.CharField( + required=False, + widget=forms.Textarea(attrs={ + 'class': 'form-control', + 'rows': 3, + 'placeholder': 'Add a note about this triage action (optional)...', + }) + ) + + def __init__(self, *args, hospital=None, **kwargs): + super().__init__(*args, **kwargs) + + # Filter departments by hospital if provided + if hospital: + self.fields['assigned_department'].queryset = Department.objects.filter( + hospital=hospital, + status='active' + ) + self.fields['assigned_to'].queryset = User.objects.filter( + is_active=True, + hospital=hospital + ) + + +class ObservationStatusForm(forms.Form): + """ + Form for changing observation status. + """ + status = forms.ChoiceField( + choices=ObservationStatus.choices, + widget=forms.Select(attrs={'class': 'form-select'}) + ) + + comment = forms.CharField( + required=False, + widget=forms.Textarea(attrs={ + 'class': 'form-control', + 'rows': 3, + 'placeholder': 'Add a comment about this status change (optional)...', + }) + ) + + +class ObservationNoteForm(forms.ModelForm): + """ + Form for adding internal notes to observations. + """ + class Meta: + model = ObservationNote + fields = ['note', 'is_internal'] + widgets = { + 'note': forms.Textarea(attrs={ + 'class': 'form-control', + 'rows': 3, + 'placeholder': 'Add your note here...', + }), + 'is_internal': forms.CheckboxInput(attrs={ + 'class': 'form-check-input', + }), + } + + +class ObservationCategoryForm(forms.ModelForm): + """ + Form for managing observation categories. + """ + class Meta: + model = ObservationCategory + fields = ['name_en', 'name_ar', 'description', 'icon', 'sort_order', 'is_active'] + widgets = { + 'name_en': forms.TextInput(attrs={ + 'class': 'form-control', + 'placeholder': 'Category name in English', + }), + 'name_ar': forms.TextInput(attrs={ + 'class': 'form-control', + 'placeholder': 'Category name in Arabic', + 'dir': 'rtl', + }), + 'description': forms.Textarea(attrs={ + 'class': 'form-control', + 'rows': 3, + 'placeholder': 'Optional description...', + }), + 'icon': forms.TextInput(attrs={ + 'class': 'form-control', + 'placeholder': 'e.g., bi-exclamation-triangle', + }), + 'sort_order': forms.NumberInput(attrs={ + 'class': 'form-control', + 'min': 0, + }), + 'is_active': forms.CheckboxInput(attrs={ + 'class': 'form-check-input', + }), + } + + +class ObservationFilterForm(forms.Form): + """ + Form for filtering observations in the list view. + """ + search = forms.CharField( + required=False, + widget=forms.TextInput(attrs={ + 'class': 'form-control', + 'placeholder': 'Search by tracking code, description...', + }) + ) + + status = forms.ChoiceField( + required=False, + choices=[('', 'All Statuses')] + list(ObservationStatus.choices), + widget=forms.Select(attrs={'class': 'form-select'}) + ) + + severity = forms.ChoiceField( + required=False, + choices=[('', 'All Severities')] + list(ObservationSeverity.choices), + widget=forms.Select(attrs={'class': 'form-select'}) + ) + + category = forms.ModelChoiceField( + required=False, + queryset=ObservationCategory.objects.filter(is_active=True), + empty_label="All Categories", + widget=forms.Select(attrs={'class': 'form-select'}) + ) + + assigned_department = forms.ModelChoiceField( + required=False, + queryset=Department.objects.filter(status='active'), + empty_label="All Departments", + widget=forms.Select(attrs={'class': 'form-select'}) + ) + + assigned_to = forms.ModelChoiceField( + required=False, + queryset=User.objects.filter(is_active=True), + empty_label="All Assignees", + widget=forms.Select(attrs={'class': 'form-select'}) + ) + + date_from = forms.DateField( + required=False, + widget=forms.DateInput(attrs={ + 'class': 'form-control', + 'type': 'date', + }) + ) + + date_to = forms.DateField( + required=False, + widget=forms.DateInput(attrs={ + 'class': 'form-control', + 'type': 'date', + }) + ) + + is_anonymous = forms.ChoiceField( + required=False, + choices=[ + ('', 'All'), + ('yes', 'Anonymous Only'), + ('no', 'Identified Only'), + ], + widget=forms.Select(attrs={'class': 'form-select'}) + ) + + +class ConvertToActionForm(forms.Form): + """ + Form for converting an observation to a PX Action. + """ + title = forms.CharField( + max_length=500, + widget=forms.TextInput(attrs={ + 'class': 'form-control', + }) + ) + + description = forms.CharField( + widget=forms.Textarea(attrs={ + 'class': 'form-control', + 'rows': 4, + }) + ) + + category = forms.ChoiceField( + choices=[ + ('clinical_quality', 'Clinical Quality'), + ('patient_safety', 'Patient Safety'), + ('service_quality', 'Service Quality'), + ('staff_behavior', 'Staff Behavior'), + ('facility', 'Facility & Environment'), + ('process_improvement', 'Process Improvement'), + ('other', 'Other'), + ], + widget=forms.Select(attrs={'class': 'form-select'}) + ) + + priority = forms.ChoiceField( + choices=[ + ('low', 'Low'), + ('medium', 'Medium'), + ('high', 'High'), + ('critical', 'Critical'), + ], + widget=forms.Select(attrs={'class': 'form-select'}) + ) + + assigned_department = forms.ModelChoiceField( + required=False, + queryset=Department.objects.filter(status='active'), + empty_label="Select department", + widget=forms.Select(attrs={'class': 'form-select'}) + ) + + assigned_to = forms.ModelChoiceField( + required=False, + queryset=User.objects.filter(is_active=True), + empty_label="Select assignee", + widget=forms.Select(attrs={'class': 'form-select'}) + ) + + +class ObservationTrackForm(forms.Form): + """ + Form for tracking an observation by its tracking code. + """ + tracking_code = forms.CharField( + max_length=20, + widget=forms.TextInput(attrs={ + 'class': 'form-control form-control-lg', + 'placeholder': 'Enter your tracking code (e.g., OBS-ABC123)', + 'autofocus': True, + }) + ) + + def clean_tracking_code(self): + """Normalize tracking code.""" + code = self.cleaned_data.get('tracking_code', '').strip().upper() + if not code: + raise forms.ValidationError("Please enter a tracking code.") + return code diff --git a/apps/observations/management/__init__.py b/apps/observations/management/__init__.py new file mode 100644 index 0000000..2c1c7c1 --- /dev/null +++ b/apps/observations/management/__init__.py @@ -0,0 +1 @@ +# Management commands diff --git a/apps/observations/management/commands/__init__.py b/apps/observations/management/commands/__init__.py new file mode 100644 index 0000000..2c1c7c1 --- /dev/null +++ b/apps/observations/management/commands/__init__.py @@ -0,0 +1 @@ +# Management commands diff --git a/apps/observations/management/commands/seed_observation_categories.py b/apps/observations/management/commands/seed_observation_categories.py new file mode 100644 index 0000000..861b5fa --- /dev/null +++ b/apps/observations/management/commands/seed_observation_categories.py @@ -0,0 +1,163 @@ +""" +Management command to seed default observation categories. + +Usage: + python manage.py seed_observation_categories + python manage.py seed_observation_categories --clear # Clear existing and reseed +""" +from django.core.management.base import BaseCommand + +from apps.observations.models import ObservationCategory + + +class Command(BaseCommand): + help = 'Seed default observation categories with bilingual names' + + def add_arguments(self, parser): + parser.add_argument( + '--clear', + action='store_true', + help='Clear existing categories before seeding', + ) + + def handle(self, *args, **options): + if options['clear']: + self.stdout.write('Clearing existing categories...') + ObservationCategory.objects.all().delete() + + # Default categories with bilingual names + categories = [ + { + 'name_en': 'Patient Safety', + 'name_ar': 'سلامة المرضى', + 'description': 'Issues related to patient safety and potential harm', + 'icon': 'bi-shield-exclamation', + 'sort_order': 1, + }, + { + 'name_en': 'Clinical Quality', + 'name_ar': 'الجودة السريرية', + 'description': 'Clinical care quality concerns', + 'icon': 'bi-heart-pulse', + 'sort_order': 2, + }, + { + 'name_en': 'Infection Control', + 'name_ar': 'مكافحة العدوى', + 'description': 'Infection prevention and control issues', + 'icon': 'bi-virus', + 'sort_order': 3, + }, + { + 'name_en': 'Medication Safety', + 'name_ar': 'سلامة الأدوية', + 'description': 'Medication errors or near misses', + 'icon': 'bi-capsule', + 'sort_order': 4, + }, + { + 'name_en': 'Equipment & Devices', + 'name_ar': 'المعدات والأجهزة', + 'description': 'Medical equipment or device issues', + 'icon': 'bi-tools', + 'sort_order': 5, + }, + { + 'name_en': 'Facility & Environment', + 'name_ar': 'المرافق والبيئة', + 'description': 'Building, maintenance, or environmental concerns', + 'icon': 'bi-building', + 'sort_order': 6, + }, + { + 'name_en': 'Staff Behavior', + 'name_ar': 'سلوك الموظفين', + 'description': 'Staff conduct or professionalism concerns', + 'icon': 'bi-people', + 'sort_order': 7, + }, + { + 'name_en': 'Communication', + 'name_ar': 'التواصل', + 'description': 'Communication breakdowns or issues', + 'icon': 'bi-chat-dots', + 'sort_order': 8, + }, + { + 'name_en': 'Documentation', + 'name_ar': 'التوثيق', + 'description': 'Medical records or documentation issues', + 'icon': 'bi-file-text', + 'sort_order': 9, + }, + { + 'name_en': 'Process & Workflow', + 'name_ar': 'العمليات وسير العمل', + 'description': 'Process inefficiencies or workflow problems', + 'icon': 'bi-diagram-3', + 'sort_order': 10, + }, + { + 'name_en': 'Security', + 'name_ar': 'الأمن', + 'description': 'Security concerns or incidents', + 'icon': 'bi-shield-lock', + 'sort_order': 11, + }, + { + 'name_en': 'IT & Systems', + 'name_ar': 'تقنية المعلومات والأنظمة', + 'description': 'IT systems or technology issues', + 'icon': 'bi-pc-display', + 'sort_order': 12, + }, + { + 'name_en': 'Housekeeping', + 'name_ar': 'التدبير المنزلي', + 'description': 'Cleanliness and housekeeping issues', + 'icon': 'bi-house', + 'sort_order': 13, + }, + { + 'name_en': 'Food Services', + 'name_ar': 'خدمات الطعام', + 'description': 'Food quality or dietary concerns', + 'icon': 'bi-cup-hot', + 'sort_order': 14, + }, + { + 'name_en': 'Other', + 'name_ar': 'أخرى', + 'description': 'Other observations not fitting other categories', + 'icon': 'bi-three-dots', + 'sort_order': 99, + }, + ] + + created_count = 0 + updated_count = 0 + + for cat_data in categories: + category, created = ObservationCategory.objects.update_or_create( + name_en=cat_data['name_en'], + defaults={ + 'name_ar': cat_data['name_ar'], + 'description': cat_data['description'], + 'icon': cat_data['icon'], + 'sort_order': cat_data['sort_order'], + 'is_active': True, + } + ) + if created: + created_count += 1 + self.stdout.write(f' Created: {category.name_en}') + else: + updated_count += 1 + self.stdout.write(f' Updated: {category.name_en}') + + self.stdout.write( + self.style.SUCCESS( + f'\nSuccessfully seeded observation categories: ' + f'{created_count} created, {updated_count} updated' + ) + ) diff --git a/apps/observations/migrations/0001_initial.py b/apps/observations/migrations/0001_initial.py new file mode 100644 index 0000000..aa375e5 --- /dev/null +++ b/apps/observations/migrations/0001_initial.py @@ -0,0 +1,147 @@ +# Generated by Django 5.0.14 on 2026-01-04 07:26 + +import apps.observations.models +import django.db.models.deletion +import django.utils.timezone +import uuid +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('organizations', '0002_hospital_metadata'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='ObservationCategory', + 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_en', models.CharField(max_length=200, verbose_name='Name (English)')), + ('name_ar', models.CharField(blank=True, max_length=200, verbose_name='Name (Arabic)')), + ('description', models.TextField(blank=True)), + ('is_active', models.BooleanField(db_index=True, default=True)), + ('sort_order', models.IntegerField(default=0, help_text='Lower numbers appear first')), + ('icon', models.CharField(blank=True, help_text='Bootstrap icon class', max_length=50)), + ], + options={ + 'verbose_name': 'Observation Category', + 'verbose_name_plural': 'Observation Categories', + 'ordering': ['sort_order', 'name_en'], + }, + ), + migrations.CreateModel( + name='Observation', + 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)), + ('tracking_code', models.CharField(db_index=True, default=apps.observations.models.generate_tracking_code, help_text='Unique code for tracking this observation', max_length=20, unique=True)), + ('title', models.CharField(blank=True, help_text='Optional short title', max_length=300)), + ('description', models.TextField(help_text='Detailed description of the observation')), + ('severity', models.CharField(choices=[('low', 'Low'), ('medium', 'Medium'), ('high', 'High'), ('critical', 'Critical')], db_index=True, default='medium', max_length=20)), + ('location_text', models.CharField(blank=True, help_text='Where the issue was observed (building, floor, room, etc.)', max_length=500)), + ('incident_datetime', models.DateTimeField(default=django.utils.timezone.now, help_text='When the issue was observed')), + ('reporter_staff_id', models.CharField(blank=True, help_text='Optional staff ID of the reporter', max_length=50)), + ('reporter_name', models.CharField(blank=True, help_text='Optional name of the reporter', max_length=200)), + ('reporter_phone', models.CharField(blank=True, help_text='Optional phone number for follow-up', max_length=20)), + ('reporter_email', models.EmailField(blank=True, help_text='Optional email for follow-up', max_length=254)), + ('status', models.CharField(choices=[('new', 'New'), ('triaged', 'Triaged'), ('assigned', 'Assigned'), ('in_progress', 'In Progress'), ('resolved', 'Resolved'), ('closed', 'Closed'), ('rejected', 'Rejected'), ('duplicate', 'Duplicate')], db_index=True, default='new', max_length=20)), + ('triaged_at', models.DateTimeField(blank=True, null=True)), + ('resolved_at', models.DateTimeField(blank=True, null=True)), + ('resolution_notes', models.TextField(blank=True)), + ('closed_at', models.DateTimeField(blank=True, null=True)), + ('action_id', models.UUIDField(blank=True, help_text='ID of linked PX Action if converted', null=True)), + ('client_ip', models.GenericIPAddressField(blank=True, null=True)), + ('user_agent', models.TextField(blank=True)), + ('metadata', models.JSONField(blank=True, default=dict)), + ('assigned_department', models.ForeignKey(blank=True, help_text='Department responsible for handling this observation', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='assigned_observations', to='organizations.department')), + ('assigned_to', models.ForeignKey(blank=True, help_text='User assigned to handle this observation', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='assigned_observations', to=settings.AUTH_USER_MODEL)), + ('closed_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='closed_observations', to=settings.AUTH_USER_MODEL)), + ('resolved_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='resolved_observations', to=settings.AUTH_USER_MODEL)), + ('triaged_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='triaged_observations', to=settings.AUTH_USER_MODEL)), + ('category', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='observations', to='observations.observationcategory')), + ], + options={ + 'ordering': ['-created_at'], + 'permissions': [('triage_observation', 'Can triage observations'), ('manage_categories', 'Can manage observation categories')], + }, + ), + migrations.CreateModel( + name='ObservationAttachment', + 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(help_text='Uploaded file', upload_to='observations/%Y/%m/%d/')), + ('filename', models.CharField(blank=True, max_length=500)), + ('file_type', models.CharField(blank=True, max_length=100)), + ('file_size', models.IntegerField(default=0, help_text='File size in bytes')), + ('description', models.CharField(blank=True, max_length=500)), + ('observation', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='attachments', to='observations.observation')), + ], + options={ + 'ordering': ['-created_at'], + }, + ), + migrations.CreateModel( + name='ObservationNote', + 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)), + ('note', models.TextField()), + ('is_internal', models.BooleanField(default=True, help_text='Internal notes are not visible to public')), + ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='observation_notes', to=settings.AUTH_USER_MODEL)), + ('observation', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='notes', to='observations.observation')), + ], + options={ + 'ordering': ['-created_at'], + }, + ), + migrations.CreateModel( + name='ObservationStatusLog', + 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)), + ('from_status', models.CharField(blank=True, choices=[('new', 'New'), ('triaged', 'Triaged'), ('assigned', 'Assigned'), ('in_progress', 'In Progress'), ('resolved', 'Resolved'), ('closed', 'Closed'), ('rejected', 'Rejected'), ('duplicate', 'Duplicate')], max_length=20)), + ('to_status', models.CharField(choices=[('new', 'New'), ('triaged', 'Triaged'), ('assigned', 'Assigned'), ('in_progress', 'In Progress'), ('resolved', 'Resolved'), ('closed', 'Closed'), ('rejected', 'Rejected'), ('duplicate', 'Duplicate')], max_length=20)), + ('comment', models.TextField(blank=True, help_text='Optional comment about the status change')), + ('changed_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='observation_status_changes', to=settings.AUTH_USER_MODEL)), + ('observation', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='status_logs', to='observations.observation')), + ], + options={ + 'verbose_name': 'Observation Status Log', + 'verbose_name_plural': 'Observation Status Logs', + 'ordering': ['-created_at'], + }, + ), + migrations.AddIndex( + model_name='observation', + index=models.Index(fields=['status', '-created_at'], name='observation_status_2b5566_idx'), + ), + migrations.AddIndex( + model_name='observation', + index=models.Index(fields=['severity', '-created_at'], name='observation_severit_ba73c0_idx'), + ), + migrations.AddIndex( + model_name='observation', + index=models.Index(fields=['tracking_code'], name='observation_trackin_23f207_idx'), + ), + migrations.AddIndex( + model_name='observation', + index=models.Index(fields=['assigned_department', 'status'], name='observation_assigne_33edad_idx'), + ), + migrations.AddIndex( + model_name='observation', + index=models.Index(fields=['assigned_to', 'status'], name='observation_assigne_83ab1c_idx'), + ), + ] diff --git a/apps/observations/migrations/__init__.py b/apps/observations/migrations/__init__.py new file mode 100644 index 0000000..609f0e8 --- /dev/null +++ b/apps/observations/migrations/__init__.py @@ -0,0 +1 @@ +# Observations migrations diff --git a/apps/observations/models.py b/apps/observations/models.py new file mode 100644 index 0000000..48c1884 --- /dev/null +++ b/apps/observations/models.py @@ -0,0 +1,415 @@ +""" +Observations models - Staff observation reporting for Al Hammadi. + +This module implements the observation reporting system that: +- Allows anonymous submission (no login required) +- Supports optional staff identification +- Tracks observation lifecycle with status changes +- Links to departments and action center +- Maintains audit trail with notes and status logs +""" +import secrets +import string + +from django.conf import settings +from django.contrib.contenttypes.fields import GenericForeignKey +from django.contrib.contenttypes.models import ContentType +from django.db import models +from django.utils import timezone + +from apps.core.models import TimeStampedModel, UUIDModel + + +def generate_tracking_code(): + """Generate a unique tracking code for observations.""" + # Format: OBS-XXXXXX (6 alphanumeric characters) + chars = string.ascii_uppercase + string.digits + code = ''.join(secrets.choice(chars) for _ in range(6)) + return f"OBS-{code}" + + +class ObservationSeverity(models.TextChoices): + """Observation severity choices.""" + LOW = 'low', 'Low' + MEDIUM = 'medium', 'Medium' + HIGH = 'high', 'High' + CRITICAL = 'critical', 'Critical' + + +class ObservationStatus(models.TextChoices): + """Observation status choices.""" + NEW = 'new', 'New' + TRIAGED = 'triaged', 'Triaged' + ASSIGNED = 'assigned', 'Assigned' + IN_PROGRESS = 'in_progress', 'In Progress' + RESOLVED = 'resolved', 'Resolved' + CLOSED = 'closed', 'Closed' + REJECTED = 'rejected', 'Rejected' + DUPLICATE = 'duplicate', 'Duplicate' + + +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.""" + return self.name_en + + +class Observation(UUIDModel, TimeStampedModel): + """ + Observation - Staff-reported issue or concern. + + Key features: + - Anonymous submission supported (no login required) + - Optional reporter identification (staff_id, name) + - Unique tracking code for public lookup + - Full lifecycle management with status tracking + - Links to departments and action center + """ + # Tracking + tracking_code = models.CharField( + max_length=20, + unique=True, + db_index=True, + default=generate_tracking_code, + help_text="Unique code for tracking this observation" + ) + + # Classification + category = models.ForeignKey( + ObservationCategory, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name='observations' + ) + + # Content + title = models.CharField( + max_length=300, + blank=True, + help_text="Optional short title" + ) + description = models.TextField( + help_text="Detailed description of the observation" + ) + + # Severity + severity = models.CharField( + max_length=20, + choices=ObservationSeverity.choices, + default=ObservationSeverity.MEDIUM, + db_index=True + ) + + # Location and timing + location_text = models.CharField( + max_length=500, + blank=True, + help_text="Where the issue was observed (building, floor, room, etc.)" + ) + incident_datetime = models.DateTimeField( + default=timezone.now, + help_text="When the issue was observed" + ) + + # Optional reporter information (anonymous supported) + reporter_staff_id = models.CharField( + max_length=50, + blank=True, + help_text="Optional staff ID of the reporter" + ) + reporter_name = models.CharField( + max_length=200, + blank=True, + help_text="Optional name of the reporter" + ) + reporter_phone = models.CharField( + max_length=20, + blank=True, + help_text="Optional phone number for follow-up" + ) + reporter_email = models.EmailField( + blank=True, + help_text="Optional email for follow-up" + ) + + # Status and workflow + status = models.CharField( + max_length=20, + choices=ObservationStatus.choices, + default=ObservationStatus.NEW, + db_index=True + ) + + # Internal routing + assigned_department = models.ForeignKey( + 'organizations.Department', + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name='assigned_observations', + help_text="Department responsible for handling this observation" + ) + assigned_to = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name='assigned_observations', + help_text="User assigned to handle this observation" + ) + + # Triage information + triaged_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.SET_NULL, + null=True, + blank=True, + 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( + settings.AUTH_USER_MODEL, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name='resolved_observations' + ) + resolution_notes = models.TextField(blank=True) + + # Closure + closed_at = models.DateTimeField(null=True, blank=True) + closed_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.SET_NULL, + null=True, + 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( + null=True, + 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=['status', '-created_at']), + models.Index(fields=['severity', '-created_at']), + models.Index(fields=['tracking_code']), + models.Index(fields=['assigned_department', 'status']), + models.Index(fields=['assigned_to', 'status']), + ] + permissions = [ + ('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.""" + if self.reporter_name: + return self.reporter_name + 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 = { + 'low': 'success', + 'medium': 'warning', + 'high': 'danger', + 'critical': 'dark', + } + return colors.get(self.severity, 'secondary') + + def get_status_color(self): + """Get Bootstrap color class for status.""" + colors = { + 'new': 'primary', + 'triaged': 'info', + 'assigned': 'info', + 'in_progress': 'warning', + 'resolved': 'success', + 'closed': 'secondary', + 'rejected': 'danger', + 'duplicate': 'secondary', + } + return colors.get(self.status, 'secondary') + + +class ObservationAttachment(UUIDModel, TimeStampedModel): + """ + Attachment for an observation (photos, documents, etc.). + """ + observation = models.ForeignKey( + Observation, + on_delete=models.CASCADE, + related_name='attachments' + ) + + file = models.FileField( + upload_to='observations/%Y/%m/%d/', + help_text="Uploaded file" + ) + filename = models.CharField(max_length=500, blank=True) + file_type = models.CharField(max_length=100, blank=True) + file_size = models.IntegerField( + 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: + if not self.filename: + self.filename = self.file.name + if not self.file_size and hasattr(self.file, 'size'): + self.file_size = self.file.size + if not self.file_type: + import mimetypes + self.file_type = mimetypes.guess_type(self.file.name)[0] or '' + super().save(*args, **kwargs) + + +class ObservationNote(UUIDModel, TimeStampedModel): + """ + Internal note on an observation. + + Used by PX360 staff to add comments and updates. + """ + observation = models.ForeignKey( + Observation, + 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}" + + +class ObservationStatusLog(UUIDModel, TimeStampedModel): + """ + Status change log for observations. + + Tracks all status transitions for audit trail. + """ + observation = models.ForeignKey( + Observation, + on_delete=models.CASCADE, + related_name='status_logs' + ) + + from_status = models.CharField( + max_length=20, + choices=ObservationStatus.choices, + blank=True + ) + to_status = models.CharField( + max_length=20, + choices=ObservationStatus.choices + ) + + changed_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.SET_NULL, + null=True, + 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/observations/services.py b/apps/observations/services.py new file mode 100644 index 0000000..b4b3ad9 --- /dev/null +++ b/apps/observations/services.py @@ -0,0 +1,599 @@ +""" +Observations services - Business logic for observation management. + +This module provides services for: +- Creating and managing observations +- Converting observations to PX Actions +- Sending notifications +- Status management with audit logging +""" +import logging +from typing import Optional + +from django.contrib.auth import get_user_model +from django.contrib.contenttypes.models import ContentType +from django.db import transaction +from django.urls import reverse +from django.utils import timezone + +from apps.notifications.services import NotificationService +from apps.organizations.models import Department, Hospital + +from .models import ( + Observation, + ObservationAttachment, + ObservationNote, + ObservationStatus, + ObservationStatusLog, +) + +logger = logging.getLogger(__name__) +User = get_user_model() + + +class ObservationService: + """ + Service class for observation management. + """ + + @staticmethod + @transaction.atomic + def create_observation( + description: str, + severity: str = 'medium', + category=None, + title: str = '', + location_text: str = '', + incident_datetime=None, + reporter_staff_id: str = '', + reporter_name: str = '', + reporter_phone: str = '', + reporter_email: str = '', + client_ip: str = None, + user_agent: str = '', + attachments: list = None, + ) -> Observation: + """ + Create a new observation. + + Args: + description: Detailed description of the observation + severity: Severity level (low, medium, high, critical) + category: ObservationCategory instance (optional) + title: Short title (optional) + location_text: Location description (optional) + incident_datetime: When the incident occurred (optional, defaults to now) + reporter_staff_id: Staff ID of reporter (optional) + reporter_name: Name of reporter (optional) + reporter_phone: Phone of reporter (optional) + reporter_email: Email of reporter (optional) + client_ip: IP address of submitter (optional) + user_agent: Browser user agent (optional) + attachments: List of uploaded files (optional) + + Returns: + Created Observation instance + """ + observation = Observation.objects.create( + description=description, + severity=severity, + category=category, + title=title, + location_text=location_text, + incident_datetime=incident_datetime or timezone.now(), + 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, + ) + + # Create initial status log + ObservationStatusLog.objects.create( + observation=observation, + from_status='', + to_status=ObservationStatus.NEW, + comment='Observation submitted' + ) + + # Handle attachments + if attachments: + for file in attachments: + ObservationAttachment.objects.create( + observation=observation, + file=file, + ) + + # Send notification to PX360 triage team + ObservationService.notify_new_observation(observation) + + logger.info(f"Created observation {observation.tracking_code}") + return observation + + @staticmethod + @transaction.atomic + def change_status( + observation: Observation, + new_status: str, + changed_by: User = None, + comment: str = '', + ) -> ObservationStatusLog: + """ + Change observation status with audit logging. + + Args: + observation: Observation instance + new_status: New status value + changed_by: User making the change (optional) + comment: Comment about the change (optional) + + Returns: + Created ObservationStatusLog instance + """ + old_status = observation.status + + # Update observation + observation.status = new_status + + # Handle status-specific updates + if new_status == ObservationStatus.TRIAGED: + observation.triaged_at = timezone.now() + observation.triaged_by = changed_by + elif new_status == ObservationStatus.RESOLVED: + observation.resolved_at = timezone.now() + observation.resolved_by = changed_by + elif new_status == ObservationStatus.CLOSED: + observation.closed_at = timezone.now() + observation.closed_by = changed_by + + observation.save() + + # Create status log + status_log = ObservationStatusLog.objects.create( + observation=observation, + from_status=old_status, + to_status=new_status, + changed_by=changed_by, + comment=comment, + ) + + # Send notifications based on status change + if new_status == ObservationStatus.ASSIGNED and observation.assigned_to: + ObservationService.notify_assignment(observation) + elif new_status in [ObservationStatus.RESOLVED, ObservationStatus.CLOSED]: + ObservationService.notify_resolution(observation) + + logger.info( + f"Observation {observation.tracking_code} status changed: " + f"{old_status} -> {new_status} by {changed_by}" + ) + + return status_log + + @staticmethod + @transaction.atomic + def triage_observation( + observation: Observation, + triaged_by: User, + assigned_department: Department = None, + assigned_to: User = None, + new_status: str = None, + note: str = '', + ) -> Observation: + """ + Triage an observation - assign department/owner and update status. + + Args: + observation: Observation instance + triaged_by: User performing the triage + assigned_department: Department to assign (optional) + assigned_to: User to assign (optional) + new_status: New status (optional, defaults to TRIAGED or ASSIGNED) + note: Triage note (optional) + + Returns: + Updated Observation instance + """ + # Update assignment + if assigned_department: + observation.assigned_department = assigned_department + if assigned_to: + observation.assigned_to = assigned_to + + # Determine new status + if not new_status: + if assigned_to: + new_status = ObservationStatus.ASSIGNED + else: + new_status = ObservationStatus.TRIAGED + + observation.save() + + # Change status + ObservationService.change_status( + observation=observation, + new_status=new_status, + changed_by=triaged_by, + comment=note or f"Triaged by {triaged_by.get_full_name()}" + ) + + # Add note if provided + if note: + ObservationNote.objects.create( + observation=observation, + note=note, + created_by=triaged_by, + is_internal=True, + ) + + return observation + + @staticmethod + @transaction.atomic + def convert_to_action( + observation: Observation, + created_by: User, + title: str = None, + description: str = None, + category: str = 'other', + priority: str = None, + assigned_department: Department = None, + assigned_to: User = None, + ): + """ + Convert an observation to a PX Action. + + Args: + observation: Observation instance + created_by: User creating the action + title: Action title (optional, defaults to observation title) + description: Action description (optional) + category: Action category + priority: Action priority (optional, derived from severity) + assigned_department: Department to assign (optional) + assigned_to: User to assign (optional) + + Returns: + Created PXAction instance + """ + from apps.px_action_center.models import ActionSource, PXAction + + # Get hospital from department or use first hospital + hospital = None + if assigned_department: + hospital = assigned_department.hospital + elif observation.assigned_department: + hospital = observation.assigned_department.hospital + else: + hospital = Hospital.objects.first() + + if not hospital: + raise ValueError("No hospital found for creating action") + + # Prepare title and description + if not title: + title = f"Observation {observation.tracking_code}" + if observation.title: + title += f" - {observation.title}" + elif observation.category: + title += f" - {observation.category.name_en}" + + if not description: + description = f""" +Observation Details: +- Tracking Code: {observation.tracking_code} +- Category: {observation.category.name_en if observation.category else 'N/A'} +- Severity: {observation.get_severity_display()} +- Location: {observation.location_text or 'N/A'} +- Incident Date: {observation.incident_datetime.strftime('%Y-%m-%d %H:%M')} +- Reporter: {observation.reporter_display} + +Description: +{observation.description} + +View observation: /observations/{observation.id}/ + """.strip() + + # Map severity to priority + if not priority: + severity_to_priority = { + 'low': 'low', + 'medium': 'medium', + 'high': 'high', + 'critical': 'critical', + } + priority = severity_to_priority.get(observation.severity, 'medium') + + # Create PX Action + action = PXAction.objects.create( + source_type=ActionSource.MANUAL, + content_type=ContentType.objects.get_for_model(Observation), + object_id=observation.id, + title=title, + description=description, + hospital=hospital, + department=assigned_department or observation.assigned_department, + category=category, + priority=priority, + severity=observation.severity, + assigned_to=assigned_to or observation.assigned_to, + metadata={ + 'observation_tracking_code': observation.tracking_code, + 'observation_id': str(observation.id), + } + ) + + # Update observation with action link + observation.action_id = action.id + observation.save(update_fields=['action_id']) + + # Add note to observation + ObservationNote.objects.create( + observation=observation, + note=f"Converted to PX Action: {action.id}", + created_by=created_by, + is_internal=True, + ) + + logger.info( + f"Observation {observation.tracking_code} converted to action {action.id}" + ) + + return action + + @staticmethod + def add_note( + observation: Observation, + note: str, + created_by: User, + is_internal: bool = True, + ) -> ObservationNote: + """ + Add a note to an observation. + + Args: + observation: Observation instance + note: Note text + created_by: User creating the note + is_internal: Whether the note is internal-only + + Returns: + Created ObservationNote instance + """ + return ObservationNote.objects.create( + observation=observation, + note=note, + created_by=created_by, + is_internal=is_internal, + ) + + @staticmethod + def add_attachment( + observation: Observation, + file, + description: str = '', + ) -> ObservationAttachment: + """ + Add an attachment to an observation. + + Args: + observation: Observation instance + file: Uploaded file + description: File description (optional) + + Returns: + Created ObservationAttachment instance + """ + return ObservationAttachment.objects.create( + observation=observation, + file=file, + description=description, + ) + + @staticmethod + def notify_new_observation(observation: Observation): + """ + Send notification for new observation to PX360 triage team. + + Args: + observation: Observation instance + """ + try: + # Get PX Admin users to notify + px_admins = User.objects.filter( + is_active=True, + groups__name='PX Admin' + ) + + subject = f"New Observation: {observation.tracking_code}" + message = f""" +A new observation has been submitted and requires triage. + +Tracking Code: {observation.tracking_code} +Category: {observation.category.name_en if observation.category else 'N/A'} +Severity: {observation.get_severity_display()} +Location: {observation.location_text or 'N/A'} +Reporter: {observation.reporter_display} + +Description: +{observation.description[:500]}{'...' if len(observation.description) > 500 else ''} + +Please review and triage this observation. + """.strip() + + for admin in px_admins: + if admin.email: + NotificationService.send_email( + email=admin.email, + subject=subject, + message=message, + related_object=observation, + metadata={ + 'observation_id': str(observation.id), + 'tracking_code': observation.tracking_code, + 'notification_type': 'new_observation', + } + ) + + logger.info(f"Sent new observation notification for {observation.tracking_code}") + + except Exception as e: + logger.error(f"Failed to send new observation notification: {e}") + + @staticmethod + def notify_assignment(observation: Observation): + """ + Send notification when observation is assigned. + + Args: + observation: Observation instance + """ + try: + if not observation.assigned_to: + return + + subject = f"Observation Assigned: {observation.tracking_code}" + message = f""" +An observation has been assigned to you. + +Tracking Code: {observation.tracking_code} +Category: {observation.category.name_en if observation.category else 'N/A'} +Severity: {observation.get_severity_display()} +Location: {observation.location_text or 'N/A'} + +Description: +{observation.description[:500]}{'...' if len(observation.description) > 500 else ''} + +Please review and take appropriate action. + """.strip() + + if observation.assigned_to.email: + NotificationService.send_email( + email=observation.assigned_to.email, + subject=subject, + message=message, + related_object=observation, + metadata={ + 'observation_id': str(observation.id), + 'tracking_code': observation.tracking_code, + 'notification_type': 'observation_assigned', + } + ) + + logger.info( + f"Sent assignment notification for {observation.tracking_code} " + f"to {observation.assigned_to.email}" + ) + + except Exception as e: + logger.error(f"Failed to send assignment notification: {e}") + + @staticmethod + def notify_resolution(observation: Observation): + """ + Send internal notification when observation is resolved/closed. + + Args: + observation: Observation instance + """ + try: + # Notify assigned user and department manager + recipients = [] + + if observation.assigned_to and observation.assigned_to.email: + recipients.append(observation.assigned_to.email) + + if observation.assigned_department and observation.assigned_department.manager: + if observation.assigned_department.manager.email: + recipients.append(observation.assigned_department.manager.email) + + if not recipients: + return + + subject = f"Observation {observation.get_status_display()}: {observation.tracking_code}" + message = f""" +An observation has been {observation.get_status_display().lower()}. + +Tracking Code: {observation.tracking_code} +Category: {observation.category.name_en if observation.category else 'N/A'} +Status: {observation.get_status_display()} + +Resolution Notes: +{observation.resolution_notes or 'No resolution notes provided.'} + """.strip() + + for email in set(recipients): + NotificationService.send_email( + email=email, + subject=subject, + message=message, + related_object=observation, + metadata={ + 'observation_id': str(observation.id), + 'tracking_code': observation.tracking_code, + 'notification_type': 'observation_resolved', + } + ) + + logger.info(f"Sent resolution notification for {observation.tracking_code}") + + except Exception as e: + logger.error(f"Failed to send resolution notification: {e}") + + @staticmethod + def get_statistics(hospital=None, department=None, date_from=None, date_to=None): + """ + Get observation statistics. + + Args: + hospital: Filter by hospital (optional) + department: Filter by department (optional) + date_from: Start date (optional) + date_to: End date (optional) + + Returns: + Dictionary with statistics + """ + from django.db.models import Count, Q + + queryset = Observation.objects.all() + + if hospital: + queryset = queryset.filter(assigned_department__hospital=hospital) + if department: + queryset = queryset.filter(assigned_department=department) + if date_from: + queryset = queryset.filter(created_at__gte=date_from) + if date_to: + queryset = queryset.filter(created_at__lte=date_to) + + # Status counts + status_counts = queryset.values('status').annotate(count=Count('id')) + status_dict = {item['status']: item['count'] for item in status_counts} + + # Severity counts + severity_counts = queryset.values('severity').annotate(count=Count('id')) + severity_dict = {item['severity']: item['count'] for item in severity_counts} + + # Category counts + category_counts = queryset.values( + 'category__name_en' + ).annotate(count=Count('id')).order_by('-count')[:10] + + return { + 'total': queryset.count(), + 'new': status_dict.get('new', 0), + 'triaged': status_dict.get('triaged', 0), + 'assigned': status_dict.get('assigned', 0), + 'in_progress': status_dict.get('in_progress', 0), + 'resolved': status_dict.get('resolved', 0), + 'closed': status_dict.get('closed', 0), + 'rejected': status_dict.get('rejected', 0), + 'duplicate': status_dict.get('duplicate', 0), + 'anonymous_count': queryset.filter( + Q(reporter_staff_id='') & Q(reporter_name='') + ).count(), + 'severity': severity_dict, + 'top_categories': list(category_counts), + } diff --git a/apps/observations/signals.py b/apps/observations/signals.py new file mode 100644 index 0000000..2bb79f7 --- /dev/null +++ b/apps/observations/signals.py @@ -0,0 +1,40 @@ +""" +Observations signals - Signal handlers for observation events. +""" +import logging + +from django.db.models.signals import post_save +from django.dispatch import receiver + +from .models import Observation, ObservationStatusLog + +logger = logging.getLogger(__name__) + + +@receiver(post_save, sender=Observation) +def observation_post_save(sender, instance, created, **kwargs): + """ + Handle post-save events for observations. + + - Log creation events + - Could trigger additional notifications or integrations + """ + if created: + logger.info( + f"New observation created: {instance.tracking_code} " + f"(severity: {instance.severity}, anonymous: {instance.is_anonymous})" + ) + + +@receiver(post_save, sender=ObservationStatusLog) +def status_log_post_save(sender, instance, created, **kwargs): + """ + Handle post-save events for status logs. + + - Log status changes + """ + if created: + logger.info( + f"Observation {instance.observation.tracking_code} status changed: " + f"{instance.from_status} -> {instance.to_status}" + ) diff --git a/apps/observations/tests.py b/apps/observations/tests.py new file mode 100644 index 0000000..df71089 --- /dev/null +++ b/apps/observations/tests.py @@ -0,0 +1,488 @@ +""" +Tests for the Observations app. + +Tests cover: +- Public submission (no login required) +- Tracking code uniqueness +- Internal pages require auth/permissions +- Status change logs are created +- Convert-to-action creates an action and links correctly +""" +from django.contrib.auth import get_user_model +from django.contrib.auth.models import Group, Permission +from django.test import Client, TestCase +from django.urls import reverse + +from apps.organizations.models import Department, Hospital + +from .models import ( + Observation, + ObservationCategory, + ObservationNote, + ObservationSeverity, + ObservationStatus, + ObservationStatusLog, +) +from .services import ObservationService + +User = get_user_model() + + +class ObservationModelTests(TestCase): + """Tests for Observation model.""" + + def test_tracking_code_generated(self): + """Test that tracking code is automatically generated.""" + observation = Observation.objects.create( + description="Test observation" + ) + self.assertIsNotNone(observation.tracking_code) + self.assertTrue(observation.tracking_code.startswith('OBS-')) + self.assertEqual(len(observation.tracking_code), 10) # OBS-XXXXXX + + def test_tracking_code_unique(self): + """Test that tracking codes are unique.""" + obs1 = Observation.objects.create(description="Test 1") + obs2 = Observation.objects.create(description="Test 2") + self.assertNotEqual(obs1.tracking_code, obs2.tracking_code) + + def test_is_anonymous_property(self): + """Test is_anonymous property.""" + # Anonymous observation + obs_anon = Observation.objects.create(description="Anonymous") + self.assertTrue(obs_anon.is_anonymous) + + # Identified by staff ID + obs_staff = Observation.objects.create( + description="With staff ID", + reporter_staff_id="12345" + ) + self.assertFalse(obs_staff.is_anonymous) + + # Identified by name + obs_name = Observation.objects.create( + description="With name", + reporter_name="John Doe" + ) + self.assertFalse(obs_name.is_anonymous) + + def test_severity_color(self): + """Test severity color method.""" + observation = Observation.objects.create( + description="Test", + severity=ObservationSeverity.HIGH + ) + self.assertEqual(observation.get_severity_color(), 'danger') + + def test_status_color(self): + """Test status color method.""" + observation = Observation.objects.create( + description="Test", + status=ObservationStatus.NEW + ) + self.assertEqual(observation.get_status_color(), 'primary') + + +class ObservationCategoryTests(TestCase): + """Tests for ObservationCategory model.""" + + def test_category_creation(self): + """Test category creation.""" + category = ObservationCategory.objects.create( + name_en="Test Category", + name_ar="فئة اختبار" + ) + self.assertEqual(str(category), "Test Category") + self.assertEqual(category.name, "Test Category") + + +class PublicViewTests(TestCase): + """Tests for public views (no login required).""" + + def setUp(self): + self.client = Client() + self.category = ObservationCategory.objects.create( + name_en="Test Category", + is_active=True + ) + + def test_public_form_accessible(self): + """Test that public form is accessible without login.""" + response = self.client.get(reverse('observations:observation_create_public')) + self.assertEqual(response.status_code, 200) + self.assertContains(response, 'Report an Observation') + + def test_public_submission_creates_observation(self): + """Test that public submission creates an observation.""" + data = { + 'description': 'This is a test observation with enough detail.', + 'severity': 'medium', + 'category': self.category.id, + } + response = self.client.post( + reverse('observations:observation_create_public'), + data + ) + + # Should redirect to success page + self.assertEqual(response.status_code, 302) + + # Observation should be created + self.assertEqual(Observation.objects.count(), 1) + observation = Observation.objects.first() + self.assertEqual(observation.description, data['description']) + self.assertEqual(observation.status, ObservationStatus.NEW) + + def test_public_submission_anonymous(self): + """Test anonymous submission.""" + data = { + 'description': 'Anonymous observation test with details.', + 'severity': 'low', + } + response = self.client.post( + reverse('observations:observation_create_public'), + data + ) + + self.assertEqual(response.status_code, 302) + observation = Observation.objects.first() + self.assertTrue(observation.is_anonymous) + + def test_public_submission_with_reporter_info(self): + """Test submission with reporter information.""" + data = { + 'description': 'Observation with reporter info and details.', + 'severity': 'medium', + 'reporter_staff_id': '12345', + 'reporter_name': 'John Doe', + } + response = self.client.post( + reverse('observations:observation_create_public'), + data + ) + + self.assertEqual(response.status_code, 302) + observation = Observation.objects.first() + self.assertFalse(observation.is_anonymous) + self.assertEqual(observation.reporter_staff_id, '12345') + self.assertEqual(observation.reporter_name, 'John Doe') + + def test_success_page_shows_tracking_code(self): + """Test success page displays tracking code.""" + observation = Observation.objects.create( + description="Test observation" + ) + response = self.client.get( + reverse('observations:observation_submitted', + kwargs={'tracking_code': observation.tracking_code}) + ) + self.assertEqual(response.status_code, 200) + self.assertContains(response, observation.tracking_code) + + def test_track_page_accessible(self): + """Test tracking page is accessible.""" + response = self.client.get(reverse('observations:observation_track')) + self.assertEqual(response.status_code, 200) + + def test_track_observation_by_code(self): + """Test tracking observation by code.""" + observation = Observation.objects.create( + description="Test observation" + ) + response = self.client.get( + reverse('observations:observation_track'), + {'tracking_code': observation.tracking_code} + ) + self.assertEqual(response.status_code, 200) + self.assertContains(response, observation.tracking_code) + + def test_honeypot_blocks_spam(self): + """Test honeypot field blocks spam submissions.""" + data = { + 'description': 'Spam observation with enough detail here.', + 'severity': 'medium', + 'website': 'spam-value', # Honeypot field + } + response = self.client.post( + reverse('observations:observation_create_public'), + data + ) + + # Should not create observation + self.assertEqual(Observation.objects.count(), 0) + + +class InternalViewTests(TestCase): + """Tests for internal views (login required).""" + + def setUp(self): + self.client = Client() + + # Create hospital and department + self.hospital = Hospital.objects.create( + name="Test Hospital", + code="TH001" + ) + self.department = Department.objects.create( + name="Test Department", + hospital=self.hospital, + status='active' + ) + + # Create users + self.admin_user = User.objects.create_user( + email='admin@test.com', + password='testpass123', + is_staff=True, + hospital=self.hospital + ) + + self.regular_user = User.objects.create_user( + email='user@test.com', + password='testpass123', + hospital=self.hospital + ) + + # Create PX Admin group and add admin user + px_admin_group, _ = Group.objects.get_or_create(name='PX Admin') + self.admin_user.groups.add(px_admin_group) + + # Create observation + self.observation = Observation.objects.create( + description="Test observation for internal views" + ) + + def test_list_requires_login(self): + """Test that list view requires login.""" + response = self.client.get(reverse('observations:observation_list')) + self.assertEqual(response.status_code, 302) + self.assertIn('login', response.url) + + def test_list_accessible_when_logged_in(self): + """Test list view accessible when logged in.""" + self.client.login(email='admin@test.com', password='testpass123') + response = self.client.get(reverse('observations:observation_list')) + self.assertEqual(response.status_code, 200) + + def test_detail_requires_login(self): + """Test that detail view requires login.""" + response = self.client.get( + reverse('observations:observation_detail', + kwargs={'pk': self.observation.id}) + ) + self.assertEqual(response.status_code, 302) + + def test_detail_accessible_when_logged_in(self): + """Test detail view accessible when logged in.""" + self.client.login(email='admin@test.com', password='testpass123') + response = self.client.get( + reverse('observations:observation_detail', + kwargs={'pk': self.observation.id}) + ) + self.assertEqual(response.status_code, 200) + self.assertContains(response, self.observation.tracking_code) + + +class ObservationServiceTests(TestCase): + """Tests for ObservationService.""" + + def setUp(self): + self.hospital = Hospital.objects.create( + name="Test Hospital", + code="TH001" + ) + self.department = Department.objects.create( + name="Test Department", + hospital=self.hospital, + status='active' + ) + self.user = User.objects.create_user( + email='test@test.com', + password='testpass123', + hospital=self.hospital + ) + self.category = ObservationCategory.objects.create( + name_en="Test Category", + is_active=True + ) + + def test_create_observation_service(self): + """Test creating observation via service.""" + observation = ObservationService.create_observation( + description="Service created observation", + severity='high', + category=self.category, + reporter_name="Test Reporter" + ) + + self.assertIsNotNone(observation.id) + self.assertEqual(observation.status, ObservationStatus.NEW) + self.assertFalse(observation.is_anonymous) + + # Check status log was created + self.assertEqual(observation.status_logs.count(), 1) + log = observation.status_logs.first() + self.assertEqual(log.to_status, ObservationStatus.NEW) + + def test_change_status_creates_log(self): + """Test that changing status creates a log entry.""" + observation = Observation.objects.create( + description="Test observation" + ) + + ObservationService.change_status( + observation=observation, + new_status=ObservationStatus.TRIAGED, + changed_by=self.user, + comment="Triaging this observation" + ) + + observation.refresh_from_db() + self.assertEqual(observation.status, ObservationStatus.TRIAGED) + self.assertIsNotNone(observation.triaged_at) + + # Check log + log = observation.status_logs.filter(to_status=ObservationStatus.TRIAGED).first() + self.assertIsNotNone(log) + self.assertEqual(log.changed_by, self.user) + self.assertEqual(log.comment, "Triaging this observation") + + def test_triage_observation(self): + """Test triaging an observation.""" + observation = Observation.objects.create( + description="Test observation" + ) + + ObservationService.triage_observation( + observation=observation, + triaged_by=self.user, + assigned_department=self.department, + assigned_to=self.user, + note="Assigning to department" + ) + + observation.refresh_from_db() + self.assertEqual(observation.assigned_department, self.department) + self.assertEqual(observation.assigned_to, self.user) + self.assertEqual(observation.status, ObservationStatus.ASSIGNED) + + # Check note was created + self.assertTrue(observation.notes.filter(note="Assigning to department").exists()) + + def test_add_note(self): + """Test adding a note to observation.""" + observation = Observation.objects.create( + description="Test observation" + ) + + note = ObservationService.add_note( + observation=observation, + note="This is a test note", + created_by=self.user, + is_internal=True + ) + + self.assertIsNotNone(note.id) + self.assertEqual(note.observation, observation) + self.assertEqual(note.created_by, self.user) + self.assertTrue(note.is_internal) + + +class StatusLogTests(TestCase): + """Tests for status logging.""" + + def setUp(self): + self.user = User.objects.create_user( + email='test@test.com', + password='testpass123' + ) + + def test_status_log_created_on_status_change(self): + """Test that status log is created when status changes.""" + observation = Observation.objects.create( + description="Test observation" + ) + + # Change status + ObservationService.change_status( + observation=observation, + new_status=ObservationStatus.IN_PROGRESS, + changed_by=self.user + ) + + # Check log exists + logs = ObservationStatusLog.objects.filter(observation=observation) + self.assertEqual(logs.count(), 1) + + log = logs.first() + self.assertEqual(log.from_status, ObservationStatus.NEW) + self.assertEqual(log.to_status, ObservationStatus.IN_PROGRESS) + self.assertEqual(log.changed_by, self.user) + + def test_multiple_status_changes_logged(self): + """Test that multiple status changes are all logged.""" + observation = Observation.objects.create( + description="Test observation" + ) + + # Multiple status changes + statuses = [ + ObservationStatus.TRIAGED, + ObservationStatus.ASSIGNED, + ObservationStatus.IN_PROGRESS, + ObservationStatus.RESOLVED, + ] + + for status in statuses: + ObservationService.change_status( + observation=observation, + new_status=status, + changed_by=self.user + ) + + # Check all logs exist + logs = ObservationStatusLog.objects.filter(observation=observation) + self.assertEqual(logs.count(), len(statuses)) + + +class CategoryManagementTests(TestCase): + """Tests for category management.""" + + def setUp(self): + self.client = Client() + self.user = User.objects.create_user( + email='admin@test.com', + password='testpass123', + is_staff=True + ) + + # Add manage_categories permission + permission = Permission.objects.get(codename='manage_categories') + self.user.user_permissions.add(permission) + + def test_category_list_requires_permission(self): + """Test category list requires permission.""" + self.client.login(email='admin@test.com', password='testpass123') + response = self.client.get(reverse('observations:category_list')) + self.assertEqual(response.status_code, 200) + + def test_category_create(self): + """Test creating a category.""" + self.client.login(email='admin@test.com', password='testpass123') + + data = { + 'name_en': 'New Category', + 'name_ar': 'فئة جديدة', + 'sort_order': 1, + 'is_active': True, + } + + response = self.client.post( + reverse('observations:category_create'), + data + ) + + self.assertEqual(response.status_code, 302) + self.assertTrue( + ObservationCategory.objects.filter(name_en='New Category').exists() + ) diff --git a/apps/observations/urls.py b/apps/observations/urls.py new file mode 100644 index 0000000..ded03bf --- /dev/null +++ b/apps/observations/urls.py @@ -0,0 +1,82 @@ +""" +Observations URL configuration. + +Public routes (no login required): +- /observations/new/ - Submit new observation +- /observations/submitted// - Success page +- /observations/track/ - Track observation by code + +Internal routes (login required): +- /observations/ - List observations +- /observations// - Observation detail +- /observations//triage/ - Triage observation +- /observations//status/ - Change status +- /observations//note/ - Add note +- /observations//convert-to-action/ - Convert to PX Action +- /observations/categories/ - Category management +""" +from django.urls import path + +from . import views + +app_name = 'observations' + +urlpatterns = [ + # ========================================================================== + # PUBLIC ROUTES (No Login Required) + # ========================================================================== + + # Submit new observation + path('new/', views.observation_create_public, name='observation_create_public'), + + # Success page after submission + path('submitted//', views.observation_submitted, name='observation_submitted'), + + # Track observation by code + path('track/', views.observation_track, name='observation_track'), + + # ========================================================================== + # INTERNAL ROUTES (Login Required) + # ========================================================================== + + # List observations + path('', views.observation_list, name='observation_list'), + + # Observation detail + path('/', views.observation_detail, name='observation_detail'), + + # Triage observation + path('/triage/', views.observation_triage, name='observation_triage'), + + # Change status + path('/status/', views.observation_change_status, name='observation_change_status'), + + # Add note + path('/note/', views.observation_add_note, name='observation_add_note'), + + # Convert to PX Action + path('/convert-to-action/', views.observation_convert_to_action, name='observation_convert_to_action'), + + # ========================================================================== + # CATEGORY MANAGEMENT + # ========================================================================== + + # List categories + path('categories/', views.category_list, name='category_list'), + + # Create category + path('categories/create/', views.category_create, name='category_create'), + + # Edit category + path('categories//edit/', views.category_edit, name='category_edit'), + + # Delete category + path('categories//delete/', views.category_delete, name='category_delete'), + + # ========================================================================== + # AJAX/API HELPERS + # ========================================================================== + + # Get users by department + path('api/users-by-department/', views.get_users_by_department, name='get_users_by_department'), +] diff --git a/apps/observations/views.py b/apps/observations/views.py new file mode 100644 index 0000000..af6b680 --- /dev/null +++ b/apps/observations/views.py @@ -0,0 +1,672 @@ +""" +Observations views - Public and internal views for observation management. + +Public views (no login required): +- observation_create_public: Submit new observation +- observation_submitted: Success page with tracking code +- observation_track: Track observation by code + +Internal views (login required): +- observation_list: List all observations with filters +- observation_detail: View observation details +- observation_triage: Triage observation +- observation_change_status: Change observation status +- observation_add_note: Add internal note +- observation_convert_to_action: Convert to PX Action +- category_list: Manage categories +- category_create/edit/delete: Category CRUD +""" +import logging + +from django.contrib import messages +from django.contrib.auth.decorators import login_required, permission_required +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, redirect, render +from django.utils import timezone +from django.views.decorators.http import require_http_methods + +from apps.accounts.models import User +from apps.organizations.models import Department + +from .forms import ( + ConvertToActionForm, + ObservationCategoryForm, + ObservationNoteForm, + ObservationPublicForm, + ObservationStatusForm, + ObservationTrackForm, + ObservationTriageForm, +) +from .models import ( + Observation, + ObservationAttachment, + ObservationCategory, + ObservationNote, + ObservationStatus, + ObservationStatusLog, +) +from .services import ObservationService + +logger = logging.getLogger(__name__) + + +# ============================================================================= +# PUBLIC VIEWS (No Login Required) +# ============================================================================= + +def observation_create_public(request): + """ + Public view for submitting observations. + + No login required - anonymous submissions allowed. + """ + if request.method == 'POST': + form = ObservationPublicForm(request.POST, request.FILES) + + if form.is_valid(): + try: + # Get client info + 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=form.cleaned_data['description'], + severity=form.cleaned_data['severity'], + category=form.cleaned_data.get('category'), + title=form.cleaned_data.get('title', ''), + location_text=form.cleaned_data.get('location_text', ''), + incident_datetime=form.cleaned_data.get('incident_datetime'), + reporter_staff_id=form.cleaned_data.get('reporter_staff_id', ''), + reporter_name=form.cleaned_data.get('reporter_name', ''), + reporter_phone=form.cleaned_data.get('reporter_phone', ''), + reporter_email=form.cleaned_data.get('reporter_email', ''), + client_ip=client_ip, + user_agent=user_agent, + attachments=attachments, + ) + + return redirect('observations:observation_submitted', tracking_code=observation.tracking_code) + + except Exception as e: + logger.error(f"Error creating observation: {e}") + messages.error(request, "An error occurred while submitting your observation. Please try again.") + else: + form = ObservationPublicForm() + + context = { + 'form': form, + 'categories': ObservationCategory.objects.filter(is_active=True).order_by('sort_order'), + } + + return render(request, 'observations/public_new.html', context) + + +def observation_submitted(request, tracking_code): + """ + Success page after observation submission. + + Shows tracking code for future reference. + """ + observation = get_object_or_404(Observation, tracking_code=tracking_code) + + context = { + 'observation': observation, + 'tracking_code': tracking_code, + } + + return render(request, 'observations/public_success.html', context) + + +def observation_track(request): + """ + Public view to track observation status by tracking code. + + Shows minimal status information only (no internal notes). + """ + observation = None + form = ObservationTrackForm(request.GET or None) + + if request.GET.get('tracking_code'): + if form.is_valid(): + tracking_code = form.cleaned_data['tracking_code'] + try: + observation = Observation.objects.get(tracking_code=tracking_code) + except Observation.DoesNotExist: + messages.error(request, "No observation found with that tracking code.") + + context = { + 'form': form, + 'observation': observation, + } + + return render(request, 'observations/public_track.html', context) + + +# ============================================================================= +# INTERNAL VIEWS (Login Required) +# ============================================================================= + +@login_required +def observation_list(request): + """ + Internal view for listing observations with filters. + + Features: + - Server-side pagination + - Advanced filters (status, severity, category, department, etc.) + - Search by tracking code, description + - RBAC filtering + """ + # Base queryset with optimizations + queryset = Observation.objects.select_related( + 'category', 'assigned_department', 'assigned_to', + 'triaged_by', 'resolved_by', 'closed_by' + ) + + # Apply RBAC filters + user = request.user + if user.is_px_admin(): + pass # See all + elif user.is_hospital_admin() and user.hospital: + queryset = queryset.filter( + Q(assigned_department__hospital=user.hospital) | + Q(assigned_department__isnull=True) + ) + elif user.is_department_manager() and user.department: + queryset = queryset.filter(assigned_department=user.department) + elif user.hospital: + queryset = queryset.filter( + Q(assigned_department__hospital=user.hospital) | + Q(assigned_department__isnull=True) + ) + + # Apply filters from request + status_filter = request.GET.get('status') + if status_filter: + queryset = queryset.filter(status=status_filter) + + severity_filter = request.GET.get('severity') + if severity_filter: + queryset = queryset.filter(severity=severity_filter) + + category_filter = request.GET.get('category') + if category_filter: + queryset = queryset.filter(category_id=category_filter) + + department_filter = request.GET.get('assigned_department') + if department_filter: + queryset = queryset.filter(assigned_department_id=department_filter) + + assigned_to_filter = request.GET.get('assigned_to') + if assigned_to_filter: + queryset = queryset.filter(assigned_to_id=assigned_to_filter) + + is_anonymous_filter = request.GET.get('is_anonymous') + if is_anonymous_filter == 'yes': + queryset = queryset.filter(reporter_staff_id='', reporter_name='') + elif is_anonymous_filter == 'no': + queryset = queryset.exclude(reporter_staff_id='', reporter_name='') + + # Search + search_query = request.GET.get('search') + if search_query: + queryset = queryset.filter( + Q(tracking_code__icontains=search_query) | + Q(title__icontains=search_query) | + Q(description__icontains=search_query) | + Q(reporter_name__icontains=search_query) | + Q(reporter_staff_id__icontains=search_query) | + Q(location_text__icontains=search_query) + ) + + # Date range filters + date_from = request.GET.get('date_from') + if date_from: + queryset = queryset.filter(created_at__date__gte=date_from) + + date_to = request.GET.get('date_to') + if date_to: + queryset = queryset.filter(created_at__date__lte=date_to) + + # Ordering + order_by = request.GET.get('order_by', '-created_at') + queryset = queryset.order_by(order_by) + + # Pagination + page_size = int(request.GET.get('page_size', 25)) + paginator = Paginator(queryset, page_size) + page_number = request.GET.get('page', 1) + page_obj = paginator.get_page(page_number) + + # Get filter options + departments = Department.objects.filter(status='active') + if not user.is_px_admin() and user.hospital: + departments = departments.filter(hospital=user.hospital) + + assignable_users = User.objects.filter(is_active=True) + if not user.is_px_admin() and user.hospital: + assignable_users = assignable_users.filter(hospital=user.hospital) + + categories = ObservationCategory.objects.filter(is_active=True) + + # Statistics + stats = ObservationService.get_statistics() + + context = { + 'page_obj': page_obj, + 'observations': page_obj.object_list, + 'stats': stats, + 'departments': departments, + 'assignable_users': assignable_users, + 'categories': categories, + 'status_choices': ObservationStatus.choices, + 'filters': request.GET, + } + + return render(request, 'observations/observation_list.html', context) + + +@login_required +def observation_detail(request, pk): + """ + Internal view for observation details. + + Features: + - Full observation details + - Timeline (status logs + notes) + - Attachments + - Linked PX Action + - Workflow actions + """ + observation = get_object_or_404( + Observation.objects.select_related( + 'category', 'assigned_department', 'assigned_to', + 'triaged_by', 'resolved_by', 'closed_by' + ).prefetch_related( + 'attachments', + 'notes__created_by', + 'status_logs__changed_by' + ), + pk=pk + ) + + # Check access + user = request.user + if not user.is_px_admin(): + if user.is_hospital_admin() and observation.assigned_department: + if observation.assigned_department.hospital != user.hospital: + messages.error(request, "You don't have permission to view this observation.") + return redirect('observations:observation_list') + elif user.is_department_manager() and observation.assigned_department: + if observation.assigned_department != user.department: + messages.error(request, "You don't have permission to view this observation.") + return redirect('observations:observation_list') + + # Get timeline (combine status logs and notes) + status_logs = list(observation.status_logs.all()) + notes = list(observation.notes.all()) + + timeline = [] + for log in status_logs: + timeline.append({ + 'type': 'status_change', + 'created_at': log.created_at, + 'item': log, + }) + for note in notes: + timeline.append({ + 'type': 'note', + 'created_at': note.created_at, + 'item': note, + }) + timeline.sort(key=lambda x: x['created_at'], reverse=True) + + # Get attachments + attachments = observation.attachments.all() + + # Get linked PX Action if exists + px_action = None + if observation.action_id: + from apps.px_action_center.models import PXAction + try: + px_action = PXAction.objects.get(id=observation.action_id) + except PXAction.DoesNotExist: + pass + + # Get assignable users and departments + departments = Department.objects.filter(status='active') + assignable_users = User.objects.filter(is_active=True) + if user.hospital: + departments = departments.filter(hospital=user.hospital) + assignable_users = assignable_users.filter(hospital=user.hospital) + + # Forms + triage_form = ObservationTriageForm( + initial={ + 'assigned_department': observation.assigned_department, + 'assigned_to': observation.assigned_to, + 'status': observation.status, + }, + hospital=user.hospital + ) + status_form = ObservationStatusForm(initial={'status': observation.status}) + note_form = ObservationNoteForm() + + context = { + 'observation': observation, + 'timeline': timeline, + 'attachments': attachments, + 'px_action': px_action, + 'departments': departments, + 'assignable_users': assignable_users, + 'triage_form': triage_form, + 'status_form': status_form, + 'note_form': note_form, + 'status_choices': ObservationStatus.choices, + 'can_triage': user.has_perm('observations.triage_observation') or user.is_px_admin(), + 'can_convert': user.is_px_admin() or user.is_hospital_admin(), + } + + return render(request, 'observations/observation_detail.html', context) + + +@login_required +@require_http_methods(["POST"]) +def observation_triage(request, pk): + """ + Triage an observation - assign department/owner and update status. + """ + observation = get_object_or_404(Observation, pk=pk) + + # Check permission + user = request.user + if not (user.has_perm('observations.triage_observation') or user.is_px_admin()): + messages.error(request, "You don't have permission to triage observations.") + return redirect('observations:observation_detail', pk=pk) + + form = ObservationTriageForm(request.POST, hospital=user.hospital) + + if form.is_valid(): + try: + ObservationService.triage_observation( + observation=observation, + triaged_by=user, + assigned_department=form.cleaned_data.get('assigned_department'), + assigned_to=form.cleaned_data.get('assigned_to'), + new_status=form.cleaned_data.get('status'), + note=form.cleaned_data.get('note', ''), + ) + messages.success(request, "Observation triaged successfully.") + except Exception as e: + logger.error(f"Error triaging observation: {e}") + messages.error(request, f"Error triaging observation: {str(e)}") + else: + messages.error(request, "Invalid form data.") + + return redirect('observations:observation_detail', pk=pk) + + +@login_required +@require_http_methods(["POST"]) +def observation_change_status(request, pk): + """ + Change observation status. + """ + observation = get_object_or_404(Observation, pk=pk) + + # Check permission + user = request.user + if not (user.has_perm('observations.triage_observation') or user.is_px_admin()): + messages.error(request, "You don't have permission to change observation status.") + return redirect('observations:observation_detail', pk=pk) + + form = ObservationStatusForm(request.POST) + + if form.is_valid(): + try: + ObservationService.change_status( + observation=observation, + new_status=form.cleaned_data['status'], + changed_by=user, + comment=form.cleaned_data.get('comment', ''), + ) + messages.success(request, f"Status changed to {form.cleaned_data['status']}.") + except Exception as e: + logger.error(f"Error changing status: {e}") + messages.error(request, f"Error changing status: {str(e)}") + else: + messages.error(request, "Invalid form data.") + + return redirect('observations:observation_detail', pk=pk) + + +@login_required +@require_http_methods(["POST"]) +def observation_add_note(request, pk): + """ + Add internal note to observation. + """ + observation = get_object_or_404(Observation, pk=pk) + + form = ObservationNoteForm(request.POST) + + if form.is_valid(): + try: + ObservationService.add_note( + observation=observation, + note=form.cleaned_data['note'], + created_by=request.user, + is_internal=form.cleaned_data.get('is_internal', True), + ) + messages.success(request, "Note added successfully.") + except Exception as e: + logger.error(f"Error adding note: {e}") + messages.error(request, f"Error adding note: {str(e)}") + else: + messages.error(request, "Please enter a note.") + + return redirect('observations:observation_detail', pk=pk) + + +@login_required +@require_http_methods(["GET", "POST"]) +def observation_convert_to_action(request, pk): + """ + Convert observation to PX Action. + """ + observation = get_object_or_404(Observation, 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 convert observations to actions.") + return redirect('observations:observation_detail', pk=pk) + + # Check if already converted + if observation.action_id: + messages.warning(request, "This observation has already been converted to an action.") + return redirect('observations:observation_detail', pk=pk) + + if request.method == 'POST': + form = ConvertToActionForm(request.POST) + + if form.is_valid(): + try: + action = ObservationService.convert_to_action( + observation=observation, + created_by=user, + title=form.cleaned_data['title'], + description=form.cleaned_data['description'], + category=form.cleaned_data['category'], + priority=form.cleaned_data['priority'], + assigned_department=form.cleaned_data.get('assigned_department'), + assigned_to=form.cleaned_data.get('assigned_to'), + ) + messages.success(request, f"Observation converted to action successfully.") + return redirect('observations:observation_detail', pk=pk) + except Exception as e: + logger.error(f"Error converting to action: {e}") + messages.error(request, f"Error converting to action: {str(e)}") + else: + # Pre-populate form + initial = { + 'title': f"Observation {observation.tracking_code}", + 'description': observation.description, + 'priority': observation.severity, + 'assigned_department': observation.assigned_department, + 'assigned_to': observation.assigned_to, + } + if observation.title: + initial['title'] += f" - {observation.title}" + elif observation.category: + initial['title'] += f" - {observation.category.name_en}" + + form = ConvertToActionForm(initial=initial) + + context = { + 'observation': observation, + 'form': form, + } + + return render(request, 'observations/convert_to_action.html', context) + + +# ============================================================================= +# CATEGORY MANAGEMENT VIEWS +# ============================================================================= + +@login_required +@permission_required('observations.manage_categories', raise_exception=True) +def category_list(request): + """ + List observation categories. + """ + categories = ObservationCategory.objects.all().order_by('sort_order', 'name_en') + + context = { + 'categories': categories, + } + + return render(request, 'observations/category_list.html', context) + + +@login_required +@permission_required('observations.manage_categories', raise_exception=True) +@require_http_methods(["GET", "POST"]) +def category_create(request): + """ + Create new observation category. + """ + if request.method == 'POST': + form = ObservationCategoryForm(request.POST) + if form.is_valid(): + form.save() + messages.success(request, "Category created successfully.") + return redirect('observations:category_list') + else: + form = ObservationCategoryForm() + + context = { + 'form': form, + 'title': 'Create Category', + } + + return render(request, 'observations/category_form.html', context) + + +@login_required +@permission_required('observations.manage_categories', raise_exception=True) +@require_http_methods(["GET", "POST"]) +def category_edit(request, pk): + """ + Edit observation category. + """ + category = get_object_or_404(ObservationCategory, pk=pk) + + if request.method == 'POST': + form = ObservationCategoryForm(request.POST, instance=category) + if form.is_valid(): + form.save() + messages.success(request, "Category updated successfully.") + return redirect('observations:category_list') + else: + form = ObservationCategoryForm(instance=category) + + context = { + 'form': form, + 'category': category, + 'title': 'Edit Category', + } + + return render(request, 'observations/category_form.html', context) + + +@login_required +@permission_required('observations.manage_categories', raise_exception=True) +@require_http_methods(["POST"]) +def category_delete(request, pk): + """ + Delete observation category. + """ + category = get_object_or_404(ObservationCategory, pk=pk) + + # Check if category is in use + if category.observations.exists(): + messages.error(request, "Cannot delete category that is in use.") + return redirect('observations:category_list') + + category.delete() + messages.success(request, "Category deleted successfully.") + return redirect('observations:category_list') + + +# ============================================================================= +# AJAX/API HELPERS +# ============================================================================= + +@login_required +def get_users_by_department(request): + """ + Get users for a department (AJAX). + """ + department_id = request.GET.get('department_id') + if not department_id: + return JsonResponse({'users': []}) + + users = User.objects.filter( + is_active=True, + department_id=department_id + ).values('id', 'first_name', 'last_name', 'email') + + return JsonResponse({ + 'users': [ + { + 'id': str(u['id']), + 'name': f"{u['first_name']} {u['last_name']}", + 'email': u['email'], + } + for u in users + ] + }) + + +# ============================================================================= +# UTILITY FUNCTIONS +# ============================================================================= + +def get_client_ip(request): + """ + Get client IP address from request. + """ + x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR') + if x_forwarded_for: + ip = x_forwarded_for.split(',')[0].strip() + else: + ip = request.META.get('REMOTE_ADDR') + return ip diff --git a/config/settings/base.py b/config/settings/base.py index 6d7dd01..fb88287 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -64,6 +64,7 @@ LOCAL_APPS = [ 'apps.ai_engine', 'apps.dashboard', 'apps.appreciation', + 'apps.observations', ] INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + LOCAL_APPS diff --git a/config/urls.py b/config/urls.py index ebcde5d..90fb171 100644 --- a/config/urls.py +++ b/config/urls.py @@ -35,6 +35,7 @@ urlpatterns = [ path('config/', include('apps.core.config_urls')), 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')), diff --git a/templates/layouts/partials/sidebar.html b/templates/layouts/partials/sidebar.html index 057fe86..9d708a3 100644 --- a/templates/layouts/partials/sidebar.html +++ b/templates/layouts/partials/sidebar.html @@ -69,6 +69,61 @@ + + + + + +