added-observations

This commit is contained in:
Marwan Alwali 2026-01-04 10:32:40 +03:00
parent 4841e92aa8
commit 6e829f1573
27 changed files with 5560 additions and 0 deletions

195
apps/observations/README.md Normal file
View File

@ -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/<tracking_code>/` | 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/<id>/` | GET | Observation detail with timeline |
| `/observations/<id>/triage/` | POST | Triage observation |
| `/observations/<id>/status/` | POST | Change observation status |
| `/observations/<id>/note/` | POST | Add internal note |
| `/observations/<id>/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/<id>/edit/` | GET/POST | Edit category |
| `/observations/categories/<id>/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=<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

View File

@ -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'

221
apps/observations/admin.py Normal file
View File

@ -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(
'<span style="background-color: {}; color: white; padding: 3px 8px; '
'border-radius: 4px; font-size: 11px;">{}</span>',
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(
'<span style="background-color: {}; color: white; padding: 3px 8px; '
'border-radius: 4px; font-size: 11px;">{}</span>',
color, obj.get_status_display()
)
status_badge.short_description = 'Status'
def reporter_display(self, obj):
if obj.is_anonymous:
return format_html('<em style="color: #6c757d;">Anonymous</em>')
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

18
apps/observations/apps.py Normal file
View File

@ -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

413
apps/observations/forms.py Normal file
View File

@ -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

View File

@ -0,0 +1 @@
# Management commands

View File

@ -0,0 +1 @@
# Management commands

View File

@ -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'
)
)

View File

@ -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'),
),
]

View File

@ -0,0 +1 @@
# Observations migrations

415
apps/observations/models.py Normal file
View File

@ -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}"

View File

@ -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),
}

View File

@ -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}"
)

488
apps/observations/tests.py Normal file
View File

@ -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()
)

82
apps/observations/urls.py Normal file
View File

@ -0,0 +1,82 @@
"""
Observations URL configuration.
Public routes (no login required):
- /observations/new/ - Submit new observation
- /observations/submitted/<tracking_code>/ - Success page
- /observations/track/ - Track observation by code
Internal routes (login required):
- /observations/ - List observations
- /observations/<id>/ - Observation detail
- /observations/<id>/triage/ - Triage observation
- /observations/<id>/status/ - Change status
- /observations/<id>/note/ - Add note
- /observations/<id>/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/<str:tracking_code>/', 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('<uuid:pk>/', views.observation_detail, name='observation_detail'),
# Triage observation
path('<uuid:pk>/triage/', views.observation_triage, name='observation_triage'),
# Change status
path('<uuid:pk>/status/', views.observation_change_status, name='observation_change_status'),
# Add note
path('<uuid:pk>/note/', views.observation_add_note, name='observation_add_note'),
# Convert to PX Action
path('<uuid:pk>/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/<uuid:pk>/edit/', views.category_edit, name='category_edit'),
# Delete category
path('categories/<uuid:pk>/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'),
]

672
apps/observations/views.py Normal file
View File

@ -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

View File

@ -64,6 +64,7 @@ LOCAL_APPS = [
'apps.ai_engine',
'apps.dashboard',
'apps.appreciation',
'apps.observations',
]
INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + LOCAL_APPS

View File

@ -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')),

View File

@ -69,6 +69,61 @@
</a>
</li>
<!-- Appreciation -->
<li class="nav-item">
<a class="nav-link {% if 'appreciation' in request.path %}active{% endif %}"
data-bs-toggle="collapse"
href="#appreciationMenu"
role="button"
aria-expanded="{% if 'appreciation' in request.path %}true{% else %}false{% endif %}"
aria-controls="appreciationMenu">
<i class="bi bi-heart-fill"></i>
{% trans "Appreciation" %}
<i class="bi bi-chevron-down ms-auto"></i>
</a>
<div class="collapse {% if 'appreciation' in request.path %}show{% endif %}" id="appreciationMenu">
<ul class="nav flex-column ms-3">
<li class="nav-item">
<a class="nav-link {% if request.resolver_match.url_name == 'appreciation_list' %}active{% endif %}"
href="{% url 'appreciation:appreciation_list' %}">
<i class="bi bi-list-ul"></i>
{% trans "All Appreciations" %}
</a>
</li>
<li class="nav-item">
<a class="nav-link {% if request.resolver_match.url_name == 'appreciation_send' %}active{% endif %}"
href="{% url 'appreciation:appreciation_send' %}">
<i class="bi bi-send"></i>
{% trans "Send Appreciation" %}
</a>
</li>
<li class="nav-item">
<a class="nav-link {% if request.resolver_match.url_name == 'leaderboard_view' %}active{% endif %}"
href="{% url 'appreciation:leaderboard_view' %}">
<i class="bi bi-trophy"></i>
{% trans "Leaderboard" %}
</a>
</li>
<li class="nav-item">
<a class="nav-link {% if request.resolver_match.url_name == 'my_badges_view' %}active{% endif %}"
href="{% url 'appreciation:my_badges_view' %}">
<i class="bi bi-award"></i>
{% trans "My Badges" %}
</a>
</li>
</ul>
</div>
</li>
<!-- Observations -->
<li class="nav-item">
<a class="nav-link {% if 'observations' in request.path and 'new' not in request.path %}active{% endif %}"
href="{% url 'observations:observation_list' %}">
<i class="bi bi-eye"></i>
{% trans "Observations" %}
</a>
</li>
<!-- PX Actions -->
<li class="nav-item">
<a class="nav-link {% if 'actions' in request.path %}active{% endif %}"

View File

@ -0,0 +1,103 @@
{% extends "layouts/base.html" %}
{% load i18n %}
{% load static %}
{% block title %}{{ title }} - {% trans "Observation Categories" %} - PX360{% endblock %}
{% block content %}
<div class="container-fluid">
<!-- Breadcrumb -->
<nav aria-label="breadcrumb" class="mb-3">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="{% url 'observations:observation_list' %}">{% trans "Observations" %}</a></li>
<li class="breadcrumb-item"><a href="{% url 'observations:category_list' %}">{% trans "Categories" %}</a></li>
<li class="breadcrumb-item active">{{ title }}</li>
</ol>
</nav>
<div class="row justify-content-center">
<div class="col-lg-6">
<div class="card">
<div class="card-header">
<h5 class="mb-0">
<i class="bi bi-tag me-2"></i>{{ title }}
</h5>
</div>
<div class="card-body">
<form method="post">
{% csrf_token %}
<div class="mb-3">
<label class="form-label">{% trans "Name (English)" %} <span class="text-danger">*</span></label>
{{ form.name_en }}
{% if form.name_en.errors %}
<div class="text-danger small mt-1">{{ form.name_en.errors.0 }}</div>
{% endif %}
</div>
<div class="mb-3">
<label class="form-label">{% trans "Name (Arabic)" %}</label>
{{ form.name_ar }}
{% if form.name_ar.errors %}
<div class="text-danger small mt-1">{{ form.name_ar.errors.0 }}</div>
{% endif %}
</div>
<div class="mb-3">
<label class="form-label">{% trans "Description" %}</label>
{{ form.description }}
{% if form.description.errors %}
<div class="text-danger small mt-1">{{ form.description.errors.0 }}</div>
{% endif %}
</div>
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label">{% trans "Icon" %}</label>
{{ form.icon }}
<div class="form-text">
{% trans "Bootstrap icon class, e.g., bi-exclamation-triangle" %}
<br>
<a href="https://icons.getbootstrap.com/" target="_blank">{% trans "Browse icons" %}</a>
</div>
{% if form.icon.errors %}
<div class="text-danger small mt-1">{{ form.icon.errors.0 }}</div>
{% endif %}
</div>
<div class="col-md-6 mb-3">
<label class="form-label">{% trans "Sort Order" %}</label>
{{ form.sort_order }}
<div class="form-text">{% trans "Lower numbers appear first" %}</div>
{% if form.sort_order.errors %}
<div class="text-danger small mt-1">{{ form.sort_order.errors.0 }}</div>
{% endif %}
</div>
</div>
<div class="mb-4">
<div class="form-check">
{{ form.is_active }}
<label class="form-check-label" for="id_is_active">
{% trans "Active" %}
</label>
</div>
<div class="form-text">{% trans "Inactive categories won't appear in the public form" %}</div>
</div>
<hr>
<div class="d-flex justify-content-between">
<a href="{% url 'observations:category_list' %}" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left me-1"></i>{% trans "Cancel" %}
</a>
<button type="submit" class="btn btn-primary">
<i class="bi bi-check-circle me-1"></i>{% trans "Save Category" %}
</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,111 @@
{% extends "layouts/base.html" %}
{% load i18n %}
{% load static %}
{% block title %}{% trans "Observation Categories" %} - PX360{% endblock %}
{% block content %}
<div class="container-fluid">
<!-- Page Header -->
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h2 class="mb-1">
<i class="bi bi-tags text-primary me-2"></i>
{% trans "Observation Categories" %}
</h2>
<p class="text-muted mb-0">{% trans "Manage categories for observation classification" %}</p>
</div>
<div class="d-flex gap-2">
<a href="{% url 'observations:observation_list' %}" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left me-1"></i>{% trans "Back to Observations" %}
</a>
<a href="{% url 'observations:category_create' %}" class="btn btn-primary">
<i class="bi bi-plus-circle me-1"></i>{% trans "Add Category" %}
</a>
</div>
</div>
<!-- Categories Table -->
<div class="card">
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light">
<tr>
<th style="width: 50px;">{% trans "Order" %}</th>
<th>{% trans "Name (English)" %}</th>
<th>{% trans "Name (Arabic)" %}</th>
<th>{% trans "Icon" %}</th>
<th>{% trans "Observations" %}</th>
<th>{% trans "Status" %}</th>
<th>{% trans "Actions" %}</th>
</tr>
</thead>
<tbody>
{% for category in categories %}
<tr>
<td>
<span class="badge bg-secondary">{{ category.sort_order }}</span>
</td>
<td>
<strong>{{ category.name_en }}</strong>
{% if category.description %}
<br><small class="text-muted">{{ category.description|truncatewords:10 }}</small>
{% endif %}
</td>
<td dir="rtl">{{ category.name_ar|default:"-" }}</td>
<td>
{% if category.icon %}
<i class="{{ category.icon }}"></i>
<small class="text-muted ms-1">{{ category.icon }}</small>
{% else %}
<span class="text-muted">-</span>
{% endif %}
</td>
<td>
<span class="badge bg-info">{{ category.observations.count }}</span>
</td>
<td>
{% if category.is_active %}
<span class="badge bg-success">{% trans "Active" %}</span>
{% else %}
<span class="badge bg-secondary">{% trans "Inactive" %}</span>
{% endif %}
</td>
<td>
<div class="btn-group btn-group-sm">
<a href="{% url 'observations:category_edit' category.id %}"
class="btn btn-outline-primary" title="{% trans 'Edit' %}">
<i class="bi bi-pencil"></i>
</a>
{% if not category.observations.exists %}
<form method="post" action="{% url 'observations:category_delete' category.id %}"
style="display: inline;"
onsubmit="return confirm('{% trans "Are you sure you want to delete this category?" %}');">
{% csrf_token %}
<button type="submit" class="btn btn-outline-danger" title="{% trans 'Delete' %}">
<i class="bi bi-trash"></i>
</button>
</form>
{% endif %}
</div>
</td>
</tr>
{% empty %}
<tr>
<td colspan="7" class="text-center py-5">
<i class="bi bi-tags" style="font-size: 3rem; color: #ccc;"></i>
<p class="text-muted mt-3">{% trans "No categories found" %}</p>
<a href="{% url 'observations:category_create' %}" class="btn btn-primary">
<i class="bi bi-plus-circle me-1"></i>{% trans "Add First Category" %}
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,103 @@
{% extends "layouts/base.html" %}
{% load i18n %}
{% load static %}
{% block title %}{% trans "Convert to Action" %} - {{ observation.tracking_code }} - PX360{% endblock %}
{% block content %}
<div class="container-fluid">
<!-- Breadcrumb -->
<nav aria-label="breadcrumb" class="mb-3">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="{% url 'observations:observation_list' %}">{% trans "Observations" %}</a></li>
<li class="breadcrumb-item"><a href="{% url 'observations:observation_detail' observation.id %}">{{ observation.tracking_code }}</a></li>
<li class="breadcrumb-item active">{% trans "Convert to Action" %}</li>
</ol>
</nav>
<div class="row justify-content-center">
<div class="col-lg-8">
<div class="card">
<div class="card-header bg-success text-white">
<h5 class="mb-0">
<i class="bi bi-arrow-right-circle me-2"></i>
{% trans "Convert Observation to PX Action" %}
</h5>
</div>
<div class="card-body">
<!-- Observation Summary -->
<div class="alert alert-info mb-4">
<h6 class="alert-heading">{% trans "Observation Summary" %}</h6>
<div class="row">
<div class="col-md-6">
<small class="text-muted">{% trans "Tracking Code" %}</small>
<div><strong>{{ observation.tracking_code }}</strong></div>
</div>
<div class="col-md-6">
<small class="text-muted">{% trans "Severity" %}</small>
<div>
<span class="badge bg-{{ observation.get_severity_color }}">
{{ observation.get_severity_display }}
</span>
</div>
</div>
</div>
<hr>
<small class="text-muted">{% trans "Description" %}</small>
<p class="mb-0">{{ observation.description|truncatewords:50 }}</p>
</div>
<!-- Convert Form -->
<form method="post">
{% csrf_token %}
<div class="mb-3">
<label class="form-label">{% trans "Action Title" %} <span class="text-danger">*</span></label>
{{ form.title }}
<div class="form-text">{% trans "A clear, actionable title for the PX Action" %}</div>
</div>
<div class="mb-3">
<label class="form-label">{% trans "Description" %} <span class="text-danger">*</span></label>
{{ form.description }}
</div>
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label">{% trans "Category" %} <span class="text-danger">*</span></label>
{{ form.category }}
</div>
<div class="col-md-6 mb-3">
<label class="form-label">{% trans "Priority" %} <span class="text-danger">*</span></label>
{{ form.priority }}
</div>
</div>
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label">{% trans "Assign to Department" %}</label>
{{ form.assigned_department }}
</div>
<div class="col-md-6 mb-3">
<label class="form-label">{% trans "Assign to User" %}</label>
{{ form.assigned_to }}
</div>
</div>
<hr>
<div class="d-flex justify-content-between">
<a href="{% url 'observations:observation_detail' observation.id %}" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left me-1"></i>{% trans "Cancel" %}
</a>
<button type="submit" class="btn btn-success">
<i class="bi bi-check-circle me-1"></i>{% trans "Create PX Action" %}
</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,462 @@
{% extends "layouts/base.html" %}
{% load i18n %}
{% load static %}
{% block title %}{{ observation.tracking_code }} - {% trans "Observation Detail" %} - PX360{% endblock %}
{% block extra_css %}
<style>
.detail-card {
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
}
.tracking-code {
font-family: 'Courier New', monospace;
font-size: 1.5rem;
font-weight: 700;
color: #667eea;
}
.status-badge {
padding: 6px 16px;
border-radius: 20px;
font-size: 0.9rem;
font-weight: 600;
}
.status-new { background: #e3f2fd; color: #1976d2; }
.status-triaged { background: #e0f7fa; color: #00838f; }
.status-assigned { background: #e0f7fa; color: #00838f; }
.status-in_progress { background: #fff3e0; color: #f57c00; }
.status-resolved { background: #e8f5e9; color: #388e3c; }
.status-closed { background: #f5f5f5; color: #616161; }
.status-rejected { background: #ffebee; color: #d32f2f; }
.status-duplicate { background: #f5f5f5; color: #616161; }
.severity-badge {
padding: 6px 16px;
border-radius: 20px;
font-size: 0.9rem;
font-weight: 600;
}
.severity-low { background: #e8f5e9; color: #388e3c; }
.severity-medium { background: #fff3e0; color: #f57c00; }
.severity-high { background: #ffebee; color: #d32f2f; }
.severity-critical { background: #880e4f; color: #fff; }
.anonymous-indicator {
background: #e9ecef;
color: #6c757d;
padding: 4px 12px;
border-radius: 12px;
font-size: 0.85rem;
font-style: italic;
}
.timeline {
position: relative;
padding-left: 30px;
}
.timeline::before {
content: '';
position: absolute;
left: 10px;
top: 0;
bottom: 0;
width: 2px;
background: #dee2e6;
}
.timeline-item {
position: relative;
padding-bottom: 20px;
}
.timeline-item:last-child {
padding-bottom: 0;
}
.timeline-item::before {
content: '';
position: absolute;
left: -24px;
top: 5px;
width: 12px;
height: 12px;
border-radius: 50%;
background: #667eea;
border: 2px solid white;
}
.timeline-item.note::before {
background: #17a2b8;
}
.timeline-item.status::before {
background: #28a745;
}
.info-row {
padding: 10px 0;
border-bottom: 1px solid #f0f0f0;
}
.info-row:last-child {
border-bottom: none;
}
.info-label {
color: #6c757d;
font-size: 0.85rem;
}
.attachment-item {
display: flex;
align-items: center;
padding: 10px;
background: #f8f9fa;
border-radius: 8px;
margin-bottom: 8px;
}
.attachment-item i {
font-size: 1.5rem;
margin-right: 12px;
color: #667eea;
}
.action-card {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border-radius: 12px;
padding: 20px;
}
.action-card .btn {
background: white;
color: #667eea;
border: none;
}
.action-card .btn:hover {
background: #f8f9fa;
}
</style>
{% endblock %}
{% block content %}
<div class="container-fluid">
<!-- Breadcrumb -->
<nav aria-label="breadcrumb" class="mb-3">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="{% url 'observations:observation_list' %}">{% trans "Observations" %}</a></li>
<li class="breadcrumb-item active">{{ observation.tracking_code }}</li>
</ol>
</nav>
<!-- Page Header -->
<div class="d-flex justify-content-between align-items-start mb-4">
<div>
<div class="d-flex align-items-center gap-3 mb-2">
<span class="tracking-code">{{ observation.tracking_code }}</span>
<span class="status-badge status-{{ observation.status }}">
{{ observation.get_status_display }}
</span>
<span class="severity-badge severity-{{ observation.severity }}">
{{ observation.get_severity_display }}
</span>
{% if observation.is_anonymous %}
<span class="anonymous-indicator">
<i class="bi bi-incognito me-1"></i>{% trans "Anonymous" %}
</span>
{% endif %}
</div>
<h4 class="mb-0">
{% if observation.title %}
{{ observation.title }}
{% else %}
{{ observation.description|truncatewords:10 }}
{% endif %}
</h4>
</div>
<div class="d-flex gap-2">
{% if can_convert and not observation.action_id %}
<a href="{% url 'observations:observation_convert_to_action' observation.id %}"
class="btn btn-success">
<i class="bi bi-arrow-right-circle me-1"></i>{% trans "Convert to Action" %}
</a>
{% endif %}
<a href="{% url 'observations:observation_list' %}" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left me-1"></i>{% trans "Back to List" %}
</a>
</div>
</div>
<div class="row">
<!-- Main Content -->
<div class="col-lg-8">
<!-- Description Card -->
<div class="card detail-card mb-4">
<div class="card-header bg-white">
<h5 class="mb-0"><i class="bi bi-card-text me-2"></i>{% trans "Description" %}</h5>
</div>
<div class="card-body">
<p class="mb-0" style="white-space: pre-wrap;">{{ observation.description }}</p>
</div>
</div>
<!-- Details Card -->
<div class="card detail-card mb-4">
<div class="card-header bg-white">
<h5 class="mb-0"><i class="bi bi-info-circle me-2"></i>{% trans "Details" %}</h5>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-6">
<div class="info-row">
<div class="info-label">{% trans "Category" %}</div>
<div>{{ observation.category.name_en|default:"Not specified" }}</div>
</div>
<div class="info-row">
<div class="info-label">{% trans "Location" %}</div>
<div>{{ observation.location_text|default:"Not specified" }}</div>
</div>
<div class="info-row">
<div class="info-label">{% trans "Incident Date/Time" %}</div>
<div>{{ observation.incident_datetime|date:"M d, Y H:i" }}</div>
</div>
</div>
<div class="col-md-6">
<div class="info-row">
<div class="info-label">{% trans "Submitted" %}</div>
<div>{{ observation.created_at|date:"M d, Y H:i" }}</div>
</div>
<div class="info-row">
<div class="info-label">{% trans "Last Updated" %}</div>
<div>{{ observation.updated_at|date:"M d, Y H:i" }}</div>
</div>
{% if observation.triaged_at %}
<div class="info-row">
<div class="info-label">{% trans "Triaged" %}</div>
<div>{{ observation.triaged_at|date:"M d, Y H:i" }}
{% if observation.triaged_by %}by {{ observation.triaged_by.get_full_name }}{% endif %}
</div>
</div>
{% endif %}
</div>
</div>
</div>
</div>
<!-- Reporter Information -->
<div class="card detail-card mb-4">
<div class="card-header bg-white">
<h5 class="mb-0"><i class="bi bi-person me-2"></i>{% trans "Reporter Information" %}</h5>
</div>
<div class="card-body">
{% if observation.is_anonymous %}
<div class="text-center py-3">
<i class="bi bi-incognito" style="font-size: 2rem; color: #6c757d;"></i>
<p class="text-muted mt-2 mb-0">{% trans "This observation was submitted anonymously" %}</p>
</div>
{% else %}
<div class="row">
<div class="col-md-6">
{% if observation.reporter_staff_id %}
<div class="info-row">
<div class="info-label">{% trans "Staff ID" %}</div>
<div>{{ observation.reporter_staff_id }}</div>
</div>
{% endif %}
{% if observation.reporter_name %}
<div class="info-row">
<div class="info-label">{% trans "Name" %}</div>
<div>{{ observation.reporter_name }}</div>
</div>
{% endif %}
</div>
<div class="col-md-6">
{% if observation.reporter_phone %}
<div class="info-row">
<div class="info-label">{% trans "Phone" %}</div>
<div>{{ observation.reporter_phone }}</div>
</div>
{% endif %}
{% if observation.reporter_email %}
<div class="info-row">
<div class="info-label">{% trans "Email" %}</div>
<div>{{ observation.reporter_email }}</div>
</div>
{% endif %}
</div>
</div>
{% endif %}
</div>
</div>
<!-- Attachments -->
{% if attachments %}
<div class="card detail-card mb-4">
<div class="card-header bg-white">
<h5 class="mb-0"><i class="bi bi-paperclip me-2"></i>{% trans "Attachments" %} ({{ attachments.count }})</h5>
</div>
<div class="card-body">
{% for attachment in attachments %}
<div class="attachment-item">
<i class="bi bi-file-earmark"></i>
<div class="flex-grow-1">
<div>{{ attachment.filename }}</div>
<small class="text-muted">{{ attachment.file_type }} - {{ attachment.file_size|filesizeformat }}</small>
</div>
<a href="{{ attachment.file.url }}" class="btn btn-sm btn-outline-primary" target="_blank">
<i class="bi bi-download"></i>
</a>
</div>
{% endfor %}
</div>
</div>
{% endif %}
<!-- Timeline -->
<div class="card detail-card mb-4">
<div class="card-header bg-white">
<h5 class="mb-0"><i class="bi bi-clock-history me-2"></i>{% trans "Timeline" %}</h5>
</div>
<div class="card-body">
{% if timeline %}
<div class="timeline">
{% for item in timeline %}
<div class="timeline-item {{ item.type }}">
<div class="d-flex justify-content-between">
<div>
{% if item.type == 'status_change' %}
<strong>{% trans "Status Changed" %}</strong>
<span class="text-muted">
{% if item.item.from_status %}
{{ item.item.from_status }} →
{% endif %}
{{ item.item.to_status }}
</span>
{% if item.item.changed_by %}
<small class="text-muted d-block">by {{ item.item.changed_by.get_full_name }}</small>
{% endif %}
{% if item.item.comment %}
<p class="mb-0 mt-1 small">{{ item.item.comment }}</p>
{% endif %}
{% elif item.type == 'note' %}
<strong>{% trans "Note Added" %}</strong>
{% if item.item.created_by %}
<small class="text-muted d-block">by {{ item.item.created_by.get_full_name }}</small>
{% endif %}
<p class="mb-0 mt-1">{{ item.item.note }}</p>
{% endif %}
</div>
<small class="text-muted">{{ item.created_at|date:"M d, H:i" }}</small>
</div>
</div>
{% endfor %}
</div>
{% else %}
<p class="text-muted text-center mb-0">{% trans "No timeline entries yet" %}</p>
{% endif %}
</div>
</div>
</div>
<!-- Sidebar -->
<div class="col-lg-4">
<!-- Assignment Card -->
<div class="card detail-card mb-4">
<div class="card-header bg-white">
<h5 class="mb-0"><i class="bi bi-people me-2"></i>{% trans "Assignment" %}</h5>
</div>
<div class="card-body">
<div class="info-row">
<div class="info-label">{% trans "Department" %}</div>
<div>{{ observation.assigned_department.name|default:"Not assigned" }}</div>
</div>
<div class="info-row">
<div class="info-label">{% trans "Assigned To" %}</div>
<div>{{ observation.assigned_to.get_full_name|default:"Not assigned" }}</div>
</div>
</div>
</div>
<!-- Linked Action -->
{% if px_action %}
<div class="action-card mb-4">
<h6 class="mb-3"><i class="bi bi-link-45deg me-2"></i>{% trans "Linked PX Action" %}</h6>
<p class="small mb-2">{{ px_action.title|truncatewords:10 }}</p>
<a href="{% url 'px_action_center:action_detail' px_action.id %}" class="btn btn-sm">
<i class="bi bi-arrow-right me-1"></i>{% trans "View Action" %}
</a>
</div>
{% endif %}
<!-- Triage Form -->
{% if can_triage %}
<div class="card detail-card mb-4">
<div class="card-header bg-white">
<h5 class="mb-0"><i class="bi bi-sliders me-2"></i>{% trans "Triage" %}</h5>
</div>
<div class="card-body">
<form method="post" action="{% url 'observations:observation_triage' observation.id %}">
{% csrf_token %}
<div class="mb-3">
<label class="form-label">{% trans "Department" %}</label>
{{ triage_form.assigned_department }}
</div>
<div class="mb-3">
<label class="form-label">{% trans "Assign To" %}</label>
{{ triage_form.assigned_to }}
</div>
<div class="mb-3">
<label class="form-label">{% trans "Status" %}</label>
{{ triage_form.status }}
</div>
<div class="mb-3">
<label class="form-label">{% trans "Note" %}</label>
{{ triage_form.note }}
</div>
<button type="submit" class="btn btn-primary w-100">
<i class="bi bi-check-circle me-1"></i>{% trans "Update" %}
</button>
</form>
</div>
</div>
{% endif %}
<!-- Add Note Form -->
<div class="card detail-card mb-4">
<div class="card-header bg-white">
<h5 class="mb-0"><i class="bi bi-chat-left-text me-2"></i>{% trans "Add Note" %}</h5>
</div>
<div class="card-body">
<form method="post" action="{% url 'observations:observation_add_note' observation.id %}">
{% csrf_token %}
<div class="mb-3">
{{ note_form.note }}
</div>
<div class="mb-3 form-check">
{{ note_form.is_internal }}
<label class="form-check-label" for="id_is_internal">{% trans "Internal note (not visible to public)" %}</label>
</div>
<button type="submit" class="btn btn-outline-primary w-100">
<i class="bi bi-plus-circle me-1"></i>{% trans "Add Note" %}
</button>
</form>
</div>
</div>
<!-- Quick Status Change -->
<div class="card detail-card mb-4">
<div class="card-header bg-white">
<h5 class="mb-0"><i class="bi bi-arrow-repeat me-2"></i>{% trans "Quick Status Change" %}</h5>
</div>
<div class="card-body">
<form method="post" action="{% url 'observations:observation_change_status' observation.id %}">
{% csrf_token %}
<div class="mb-3">
{{ status_form.status }}
</div>
<div class="mb-3">
{{ status_form.comment }}
</div>
<button type="submit" class="btn btn-outline-secondary w-100">
<i class="bi bi-check me-1"></i>{% trans "Change Status" %}
</button>
</form>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,453 @@
{% extends "layouts/base.html" %}
{% load i18n %}
{% load static %}
{% block title %}{% trans "Observations Console" %} - PX360{% endblock %}
{% block extra_css %}
<style>
.filter-panel {
background: #f8f9fa;
border: 1px solid #dee2e6;
border-radius: 8px;
padding: 20px;
margin-bottom: 20px;
}
.filter-panel.collapsed .filter-body {
display: none;
}
.table-toolbar {
background: #fff;
border: 1px solid #dee2e6;
border-radius: 8px;
padding: 15px;
margin-bottom: 15px;
display: flex;
justify-content: space-between;
align-items: center;
}
.status-badge {
padding: 4px 12px;
border-radius: 12px;
font-size: 0.85rem;
font-weight: 500;
}
.status-new { background: #e3f2fd; color: #1976d2; }
.status-triaged { background: #e0f7fa; color: #00838f; }
.status-assigned { background: #e0f7fa; color: #00838f; }
.status-in_progress { background: #fff3e0; color: #f57c00; }
.status-resolved { background: #e8f5e9; color: #388e3c; }
.status-closed { background: #f5f5f5; color: #616161; }
.status-rejected { background: #ffebee; color: #d32f2f; }
.status-duplicate { background: #f5f5f5; color: #616161; }
.severity-badge {
padding: 4px 12px;
border-radius: 12px;
font-size: 0.85rem;
font-weight: 500;
}
.severity-low { background: #e8f5e9; color: #388e3c; }
.severity-medium { background: #fff3e0; color: #f57c00; }
.severity-high { background: #ffebee; color: #d32f2f; }
.severity-critical { background: #880e4f; color: #fff; }
.anonymous-badge {
background: #e9ecef;
color: #6c757d;
padding: 2px 8px;
border-radius: 10px;
font-size: 0.75rem;
font-style: italic;
}
.observation-row:hover {
background: #f8f9fa;
cursor: pointer;
}
.stat-card {
border-left: 4px solid;
transition: transform 0.2s;
}
.stat-card:hover {
transform: translateY(-2px);
}
.tracking-code {
font-family: 'Courier New', monospace;
font-weight: 600;
color: #667eea;
}
</style>
{% endblock %}
{% block content %}
<div class="container-fluid">
<!-- Page Header -->
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h2 class="mb-1">
<i class="bi bi-eye-fill text-primary me-2"></i>
{% trans "Observations Console" %}
</h2>
<p class="text-muted mb-0">{% trans "Manage and triage staff-reported observations" %}</p>
</div>
<div class="d-flex gap-2">
<a href="{% url 'observations:category_list' %}" class="btn btn-outline-secondary">
<i class="bi bi-tags me-1"></i> {% trans "Categories" %}
</a>
<a href="{% url 'observations:observation_create_public' %}" class="btn btn-primary" target="_blank">
<i class="bi bi-plus-circle me-1"></i> {% trans "Public Form" %}
</a>
</div>
</div>
<!-- Statistics Cards -->
<div class="row mb-4">
<div class="col-md-3 col-sm-6 mb-3">
<div class="card stat-card border-primary">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center">
<div>
<h6 class="text-muted mb-1">{% trans "Total" %}</h6>
<h3 class="mb-0">{{ stats.total }}</h3>
</div>
<div class="text-primary">
<i class="bi bi-list-ul" style="font-size: 2rem;"></i>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-3 col-sm-6 mb-3">
<div class="card stat-card border-info">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center">
<div>
<h6 class="text-muted mb-1">{% trans "New" %}</h6>
<h3 class="mb-0">{{ stats.new }}</h3>
</div>
<div class="text-info">
<i class="bi bi-inbox" style="font-size: 2rem;"></i>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-3 col-sm-6 mb-3">
<div class="card stat-card border-warning">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center">
<div>
<h6 class="text-muted mb-1">{% trans "In Progress" %}</h6>
<h3 class="mb-0">{{ stats.in_progress }}</h3>
</div>
<div class="text-warning">
<i class="bi bi-hourglass-split" style="font-size: 2rem;"></i>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-3 col-sm-6 mb-3">
<div class="card stat-card border-secondary">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center">
<div>
<h6 class="text-muted mb-1">{% trans "Anonymous" %}</h6>
<h3 class="mb-0">{{ stats.anonymous_count }}</h3>
</div>
<div class="text-secondary">
<i class="bi bi-incognito" style="font-size: 2rem;"></i>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Filter Panel -->
<div class="filter-panel" id="filterPanel">
<div class="d-flex justify-content-between align-items-center mb-3">
<h5 class="mb-0">
<i class="bi bi-funnel me-2"></i>{% trans "Filters" %}
</h5>
<button class="btn btn-sm btn-outline-secondary" onclick="toggleFilters()">
<i class="bi bi-chevron-up" id="filterToggleIcon"></i>
</button>
</div>
<div class="filter-body">
<form method="get" action="{% url 'observations:observation_list' %}" id="filterForm">
<div class="row g-3">
<!-- Search -->
<div class="col-md-4">
<label class="form-label">{% trans "Search" %}</label>
<input type="text" class="form-control" name="search"
placeholder="{% trans 'Tracking code, description...' %}"
value="{{ filters.search }}">
</div>
<!-- Status -->
<div class="col-md-4">
<label class="form-label">{% trans "Status" %}</label>
<select class="form-select" name="status">
<option value="">{% trans "All Statuses" %}</option>
{% for value, label in status_choices %}
<option value="{{ value }}" {% if filters.status == value %}selected{% endif %}>
{{ label }}
</option>
{% endfor %}
</select>
</div>
<!-- Severity -->
<div class="col-md-4">
<label class="form-label">{% trans "Severity" %}</label>
<select class="form-select" name="severity">
<option value="">{% trans "All Severities" %}</option>
<option value="low" {% if filters.severity == 'low' %}selected{% endif %}>{% trans "Low" %}</option>
<option value="medium" {% if filters.severity == 'medium' %}selected{% endif %}>{% trans "Medium" %}</option>
<option value="high" {% if filters.severity == 'high' %}selected{% endif %}>{% trans "High" %}</option>
<option value="critical" {% if filters.severity == 'critical' %}selected{% endif %}>{% trans "Critical" %}</option>
</select>
</div>
<!-- Category -->
<div class="col-md-4">
<label class="form-label">{% trans "Category" %}</label>
<select class="form-select" name="category">
<option value="">{% trans "All Categories" %}</option>
{% for cat in categories %}
<option value="{{ cat.id }}" {% if filters.category == cat.id|stringformat:"s" %}selected{% endif %}>
{{ cat.name_en }}
</option>
{% endfor %}
</select>
</div>
<!-- Department -->
<div class="col-md-4">
<label class="form-label">{% trans "Department" %}</label>
<select class="form-select" name="assigned_department">
<option value="">{% trans "All Departments" %}</option>
{% for dept in departments %}
<option value="{{ dept.id }}" {% if filters.assigned_department == dept.id|stringformat:"s" %}selected{% endif %}>
{{ dept.name }}
</option>
{% endfor %}
</select>
</div>
<!-- Assigned To -->
<div class="col-md-4">
<label class="form-label">{% trans "Assigned To" %}</label>
<select class="form-select" name="assigned_to">
<option value="">{% trans "All Users" %}</option>
{% for user_obj in assignable_users %}
<option value="{{ user_obj.id }}" {% if filters.assigned_to == user_obj.id|stringformat:"s" %}selected{% endif %}>
{{ user_obj.get_full_name }}
</option>
{% endfor %}
</select>
</div>
<!-- Anonymous Filter -->
<div class="col-md-4">
<label class="form-label">{% trans "Reporter Type" %}</label>
<select class="form-select" name="is_anonymous">
<option value="">{% trans "All" %}</option>
<option value="yes" {% if filters.is_anonymous == 'yes' %}selected{% endif %}>{% trans "Anonymous Only" %}</option>
<option value="no" {% if filters.is_anonymous == 'no' %}selected{% endif %}>{% trans "Identified Only" %}</option>
</select>
</div>
<!-- Date Range -->
<div class="col-md-4">
<label class="form-label">{% trans "Date From" %}</label>
<input type="date" class="form-control" name="date_from" value="{{ filters.date_from }}">
</div>
<div class="col-md-4">
<label class="form-label">{% trans "Date To" %}</label>
<input type="date" class="form-control" name="date_to" value="{{ filters.date_to }}">
</div>
</div>
<div class="mt-3 d-flex gap-2">
<button type="submit" class="btn btn-primary">
<i class="bi bi-search me-1"></i> {% trans "Apply Filters" %}
</button>
<a href="{% url 'observations:observation_list' %}" class="btn btn-outline-secondary">
<i class="bi bi-x-circle me-1"></i> {% trans "Clear" %}
</a>
</div>
</form>
</div>
</div>
<!-- Table Toolbar -->
<div class="table-toolbar">
<div>
<span class="text-muted">
{% trans "Showing" %} {{ page_obj.start_index }} {% trans "to" %} {{ page_obj.end_index }} {% trans "of" %} {{ page_obj.paginator.count }} {% trans "observations" %}
</span>
</div>
</div>
<!-- Observations Table -->
<div class="card">
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light">
<tr>
<th>{% trans "Tracking Code" %}</th>
<th>{% trans "Date" %}</th>
<th>{% trans "Category" %}</th>
<th>{% trans "Description" %}</th>
<th>{% trans "Severity" %}</th>
<th>{% trans "Status" %}</th>
<th>{% trans "Reporter" %}</th>
<th>{% trans "Department" %}</th>
<th>{% trans "Assigned To" %}</th>
<th>{% trans "Actions" %}</th>
</tr>
</thead>
<tbody>
{% for observation in observations %}
<tr class="observation-row" onclick="window.location='{% url 'observations:observation_detail' observation.id %}'">
<td>
<span class="tracking-code">{{ observation.tracking_code }}</span>
</td>
<td>
<small>{{ observation.created_at|date:"M d, Y" }}</small><br>
<small class="text-muted">{{ observation.created_at|time:"H:i" }}</small>
</td>
<td>
{% if observation.category %}
<span class="badge bg-secondary">{{ observation.category.name_en }}</span>
{% else %}
<span class="text-muted">-</span>
{% endif %}
</td>
<td>
<div style="max-width: 200px;">
{% if observation.title %}
<strong>{{ observation.title|truncatewords:5 }}</strong><br>
{% endif %}
<small class="text-muted">{{ observation.description|truncatewords:10 }}</small>
</div>
</td>
<td>
<span class="severity-badge severity-{{ observation.severity }}">
{{ observation.get_severity_display }}
</span>
</td>
<td>
<span class="status-badge status-{{ observation.status }}">
{{ observation.get_status_display }}
</span>
</td>
<td>
{% if observation.is_anonymous %}
<span class="anonymous-badge">{% trans "Anonymous" %}</span>
{% else %}
<small>{{ observation.reporter_display }}</small>
{% endif %}
</td>
<td>
{% if observation.assigned_department %}
<small>{{ observation.assigned_department.name|truncatewords:3 }}</small>
{% else %}
<span class="text-muted">-</span>
{% endif %}
</td>
<td>
{% if observation.assigned_to %}
<small>{{ observation.assigned_to.get_full_name }}</small>
{% else %}
<span class="text-muted"><em>{% trans "Unassigned" %}</em></span>
{% endif %}
</td>
<td onclick="event.stopPropagation();">
<div class="btn-group btn-group-sm">
<a href="{% url 'observations:observation_detail' observation.id %}"
class="btn btn-outline-primary" title="{% trans 'View' %}">
<i class="bi bi-eye"></i>
</a>
</div>
</td>
</tr>
{% empty %}
<tr>
<td colspan="10" class="text-center py-5">
<i class="bi bi-inbox" style="font-size: 3rem; color: #ccc;"></i>
<p class="text-muted mt-3">{% trans "No observations found" %}</p>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
<!-- Pagination -->
{% if page_obj.has_other_pages %}
<nav aria-label="Observations pagination" class="mt-4">
<ul class="pagination justify-content-center">
{% if page_obj.has_previous %}
<li class="page-item">
<a class="page-link" href="?page=1{% for key, value in filters.items %}&{{ key }}={{ value }}{% endfor %}">
<i class="bi bi-chevron-double-left"></i>
</a>
</li>
<li class="page-item">
<a class="page-link" href="?page={{ page_obj.previous_page_number }}{% for key, value in filters.items %}&{{ key }}={{ value }}{% endfor %}">
<i class="bi bi-chevron-left"></i>
</a>
</li>
{% endif %}
{% for num in page_obj.paginator.page_range %}
{% if page_obj.number == num %}
<li class="page-item active"><span class="page-link">{{ num }}</span></li>
{% elif num > page_obj.number|add:'-3' and num < page_obj.number|add:'3' %}
<li class="page-item">
<a class="page-link" href="?page={{ num }}{% for key, value in filters.items %}&{{ key }}={{ value }}{% endfor %}">
{{ num }}
</a>
</li>
{% endif %}
{% endfor %}
{% if page_obj.has_next %}
<li class="page-item">
<a class="page-link" href="?page={{ page_obj.next_page_number }}{% for key, value in filters.items %}&{{ key }}={{ value }}{% endfor %}">
<i class="bi bi-chevron-right"></i>
</a>
</li>
<li class="page-item">
<a class="page-link" href="?page={{ page_obj.paginator.num_pages }}{% for key, value in filters.items %}&{{ key }}={{ value }}{% endfor %}">
<i class="bi bi-chevron-double-right"></i>
</a>
</li>
{% endif %}
</ul>
</nav>
{% endif %}
</div>
{% endblock %}
{% block extra_js %}
<script>
function toggleFilters() {
const panel = document.getElementById('filterPanel');
const icon = document.getElementById('filterToggleIcon');
panel.classList.toggle('collapsed');
icon.classList.toggle('bi-chevron-up');
icon.classList.toggle('bi-chevron-down');
}
</script>
{% endblock %}

View File

@ -0,0 +1,339 @@
{% load i18n %}
{% load static %}
<!DOCTYPE html>
<html lang="en" dir="ltr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% trans "Report an Observation" %} - Al Hammadi</title>
<!-- Bootstrap CSS -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.0/font/bootstrap-icons.css" rel="stylesheet">
<style>
body {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
padding: 20px 0;
}
.form-container {
background: white;
border-radius: 16px;
box-shadow: 0 10px 40px rgba(0,0,0,0.2);
padding: 40px;
max-width: 700px;
margin: 0 auto;
}
.logo-header {
text-align: center;
margin-bottom: 30px;
}
.logo-header h1 {
color: #333;
font-size: 1.8rem;
margin-bottom: 5px;
}
.logo-header p {
color: #666;
margin-bottom: 0;
}
.section-title {
font-size: 1.1rem;
font-weight: 600;
color: #333;
margin-bottom: 15px;
padding-bottom: 10px;
border-bottom: 2px solid #667eea;
}
.severity-option {
cursor: pointer;
padding: 10px 15px;
border: 2px solid #dee2e6;
border-radius: 8px;
text-align: center;
transition: all 0.2s;
}
.severity-option:hover {
border-color: #667eea;
}
.severity-option.selected {
border-color: #667eea;
background: #f0f3ff;
}
.severity-low { border-left: 4px solid #28a745; }
.severity-medium { border-left: 4px solid #ffc107; }
.severity-high { border-left: 4px solid #dc3545; }
.severity-critical { border-left: 4px solid #343a40; }
.anonymous-notice {
background: #e8f4fd;
border: 1px solid #b8daff;
border-radius: 8px;
padding: 15px;
margin-bottom: 20px;
}
.anonymous-notice i {
color: #0056b3;
}
.btn-submit {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border: none;
padding: 12px 40px;
font-size: 1.1rem;
border-radius: 8px;
}
.btn-submit:hover {
opacity: 0.9;
}
.optional-badge {
font-size: 0.75rem;
color: #6c757d;
font-weight: normal;
}
.file-upload-area {
border: 2px dashed #dee2e6;
border-radius: 8px;
padding: 30px;
text-align: center;
cursor: pointer;
transition: all 0.2s;
}
.file-upload-area:hover {
border-color: #667eea;
background: #f8f9ff;
}
.file-upload-area i {
font-size: 2rem;
color: #667eea;
}
</style>
</head>
<body>
<div class="container">
<div class="form-container">
<!-- Header -->
<div class="logo-header">
<h1><i class="bi bi-eye-fill text-primary me-2"></i>{% trans "Report an Observation" %}</h1>
<p class="text-muted">{% trans "Help us improve by reporting issues you notice" %}</p>
</div>
<!-- Anonymous Notice -->
<div class="anonymous-notice">
<i class="bi bi-shield-check me-2"></i>
<strong>{% trans "Anonymous Reporting" %}</strong>
<p class="mb-0 mt-1 small">
{% trans "You can submit this report anonymously. Providing your information is optional but may help us follow up if needed." %}
</p>
</div>
<!-- Messages -->
{% if messages %}
{% for message in messages %}
<div class="alert alert-{{ message.tags }} alert-dismissible fade show" role="alert">
{{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
{% endfor %}
{% endif %}
<!-- Form -->
<form method="post" enctype="multipart/form-data" id="observationForm">
{% csrf_token %}
<!-- Honeypot field (hidden) -->
{{ form.website }}
<!-- Category -->
<div class="mb-4">
<h5 class="section-title">
<i class="bi bi-tag me-2"></i>{% trans "Category" %}
<span class="optional-badge">({% trans "optional" %})</span>
</h5>
{{ form.category }}
{% if form.category.errors %}
<div class="text-danger small mt-1">{{ form.category.errors.0 }}</div>
{% endif %}
</div>
<!-- Severity -->
<div class="mb-4">
<h5 class="section-title">
<i class="bi bi-exclamation-triangle me-2"></i>{% trans "Severity" %}
</h5>
<div class="row g-2">
<div class="col-6 col-md-3">
<div class="severity-option severity-low" data-value="low">
<i class="bi bi-info-circle text-success"></i>
<div class="small mt-1">{% trans "Low" %}</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="severity-option severity-medium selected" data-value="medium">
<i class="bi bi-exclamation-circle text-warning"></i>
<div class="small mt-1">{% trans "Medium" %}</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="severity-option severity-high" data-value="high">
<i class="bi bi-exclamation-triangle text-danger"></i>
<div class="small mt-1">{% trans "High" %}</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="severity-option severity-critical" data-value="critical">
<i class="bi bi-x-octagon text-dark"></i>
<div class="small mt-1">{% trans "Critical" %}</div>
</div>
</div>
</div>
<input type="hidden" name="severity" id="severityInput" value="medium">
</div>
<!-- Title -->
<div class="mb-4">
<h5 class="section-title">
<i class="bi bi-card-heading me-2"></i>{% trans "Title" %}
<span class="optional-badge">({% trans "optional" %})</span>
</h5>
{{ form.title }}
{% if form.title.errors %}
<div class="text-danger small mt-1">{{ form.title.errors.0 }}</div>
{% endif %}
</div>
<!-- Description -->
<div class="mb-4">
<h5 class="section-title">
<i class="bi bi-card-text me-2"></i>{% trans "Description" %}
<span class="text-danger">*</span>
</h5>
{{ form.description }}
{% if form.description.errors %}
<div class="text-danger small mt-1">{{ form.description.errors.0 }}</div>
{% endif %}
<div class="form-text">{% trans "Please describe what you observed in detail." %}</div>
</div>
<!-- Location -->
<div class="mb-4">
<h5 class="section-title">
<i class="bi bi-geo-alt me-2"></i>{% trans "Location" %}
<span class="optional-badge">({% trans "optional" %})</span>
</h5>
{{ form.location_text }}
{% if form.location_text.errors %}
<div class="text-danger small mt-1">{{ form.location_text.errors.0 }}</div>
{% endif %}
</div>
<!-- Incident Date/Time -->
<div class="mb-4">
<h5 class="section-title">
<i class="bi bi-calendar-event me-2"></i>{% trans "When did this occur?" %}
</h5>
{{ form.incident_datetime }}
{% if form.incident_datetime.errors %}
<div class="text-danger small mt-1">{{ form.incident_datetime.errors.0 }}</div>
{% endif %}
</div>
<!-- Attachments -->
<div class="mb-4">
<h5 class="section-title">
<i class="bi bi-paperclip me-2"></i>{% trans "Attachments" %}
<span class="optional-badge">({% trans "optional" %})</span>
</h5>
<div class="file-upload-area" onclick="document.getElementById('attachments').click()">
<i class="bi bi-cloud-upload"></i>
<p class="mb-0 mt-2">{% trans "Click to upload files" %}</p>
<p class="small text-muted mb-0">{% trans "Images, PDF, Word, Excel (max 10MB each)" %}</p>
</div>
<input type="file" id="attachments" name="attachments" multiple
accept=".jpg,.jpeg,.png,.gif,.pdf,.doc,.docx,.xls,.xlsx"
style="display: none;" onchange="updateFileList()">
<div id="fileList" class="mt-2"></div>
</div>
<!-- Reporter Information (Optional) -->
<div class="mb-4">
<h5 class="section-title">
<i class="bi bi-person me-2"></i>{% trans "Your Information" %}
<span class="optional-badge">({% trans "optional" %})</span>
</h5>
<p class="text-muted small mb-3">
{% trans "Providing your information helps us follow up if needed. Leave blank to submit anonymously." %}
</p>
<div class="row g-3">
<div class="col-md-6">
<label class="form-label">{% trans "Staff ID" %}</label>
{{ form.reporter_staff_id }}
</div>
<div class="col-md-6">
<label class="form-label">{% trans "Name" %}</label>
{{ form.reporter_name }}
</div>
<div class="col-md-6">
<label class="form-label">{% trans "Phone" %}</label>
{{ form.reporter_phone }}
</div>
<div class="col-md-6">
<label class="form-label">{% trans "Email" %}</label>
{{ form.reporter_email }}
</div>
</div>
</div>
<!-- Submit -->
<div class="text-center mt-4">
<button type="submit" class="btn btn-primary btn-submit btn-lg">
<i class="bi bi-send me-2"></i>{% trans "Submit Observation" %}
</button>
</div>
<!-- Track Link -->
<div class="text-center mt-4">
<a href="{% url 'observations:observation_track' %}" class="text-muted">
<i class="bi bi-search me-1"></i>{% trans "Track an existing observation" %}
</a>
</div>
</form>
</div>
</div>
<!-- Bootstrap JS -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<script>
// Severity selection
document.querySelectorAll('.severity-option').forEach(option => {
option.addEventListener('click', function() {
document.querySelectorAll('.severity-option').forEach(o => o.classList.remove('selected'));
this.classList.add('selected');
document.getElementById('severityInput').value = this.dataset.value;
});
});
// File list display
function updateFileList() {
const input = document.getElementById('attachments');
const fileList = document.getElementById('fileList');
fileList.innerHTML = '';
if (input.files.length > 0) {
const list = document.createElement('ul');
list.className = 'list-unstyled mb-0';
for (let i = 0; i < input.files.length; i++) {
const li = document.createElement('li');
li.className = 'small text-muted';
li.innerHTML = `<i class="bi bi-file-earmark me-1"></i>${input.files[i].name}`;
list.appendChild(li);
}
fileList.appendChild(list);
}
}
</script>
</body>
</html>

View File

@ -0,0 +1,191 @@
{% load i18n %}
{% load static %}
<!DOCTYPE html>
<html lang="en" dir="ltr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% trans "Observation Submitted" %} - Al Hammadi</title>
<!-- Bootstrap CSS -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.0/font/bootstrap-icons.css" rel="stylesheet">
<style>
body {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.success-container {
background: white;
border-radius: 16px;
box-shadow: 0 10px 40px rgba(0,0,0,0.2);
padding: 50px;
max-width: 600px;
text-align: center;
}
.success-icon {
width: 100px;
height: 100px;
background: linear-gradient(135deg, #28a745 0%, #20c997 100%);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 30px;
animation: pulse 2s infinite;
}
.success-icon i {
font-size: 50px;
color: white;
}
@keyframes pulse {
0% { transform: scale(1); }
50% { transform: scale(1.05); }
100% { transform: scale(1); }
}
.tracking-code {
background: #f8f9fa;
border: 2px dashed #667eea;
border-radius: 12px;
padding: 20px;
margin: 30px 0;
}
.tracking-code h3 {
font-family: 'Courier New', monospace;
font-size: 2rem;
color: #667eea;
margin-bottom: 5px;
letter-spacing: 2px;
}
.copy-btn {
background: #667eea;
border: none;
padding: 8px 20px;
border-radius: 6px;
color: white;
cursor: pointer;
transition: all 0.2s;
}
.copy-btn:hover {
background: #5a6fd6;
}
.copy-btn.copied {
background: #28a745;
}
.info-card {
background: #e8f4fd;
border-radius: 8px;
padding: 15px;
margin-top: 20px;
text-align: left;
}
.info-card i {
color: #0056b3;
}
</style>
</head>
<body>
<div class="success-container">
<!-- Success Icon -->
<div class="success-icon">
<i class="bi bi-check-lg"></i>
</div>
<!-- Title -->
<h1 class="mb-3">{% trans "Thank You!" %}</h1>
<p class="text-muted lead">
{% trans "Your observation has been submitted successfully." %}
</p>
<!-- Tracking Code -->
<div class="tracking-code">
<p class="text-muted mb-2">{% trans "Your Tracking Code" %}</p>
<h3 id="trackingCode">{{ tracking_code }}</h3>
<button class="copy-btn mt-2" onclick="copyTrackingCode()">
<i class="bi bi-clipboard me-1"></i>
<span id="copyText">{% trans "Copy Code" %}</span>
</button>
</div>
<!-- Info -->
<div class="info-card">
<p class="mb-2">
<i class="bi bi-info-circle me-2"></i>
<strong>{% trans "Important" %}</strong>
</p>
<ul class="mb-0 small">
<li>{% trans "Save this tracking code to check the status of your observation." %}</li>
<li>{% trans "Our team will review your observation and take appropriate action." %}</li>
<li>{% trans "You can track your observation status anytime using the tracking code." %}</li>
</ul>
</div>
<!-- Observation Summary -->
<div class="mt-4 text-start">
<h6 class="text-muted mb-3">{% trans "Observation Summary" %}</h6>
<table class="table table-sm">
<tr>
<td class="text-muted">{% trans "Category" %}</td>
<td>{{ observation.category.name_en|default:"Not specified" }}</td>
</tr>
<tr>
<td class="text-muted">{% trans "Severity" %}</td>
<td>
<span class="badge bg-{{ observation.get_severity_color }}">
{{ observation.get_severity_display }}
</span>
</td>
</tr>
<tr>
<td class="text-muted">{% trans "Status" %}</td>
<td>
<span class="badge bg-{{ observation.get_status_color }}">
{{ observation.get_status_display }}
</span>
</td>
</tr>
<tr>
<td class="text-muted">{% trans "Submitted" %}</td>
<td>{{ observation.created_at|date:"M d, Y H:i" }}</td>
</tr>
</table>
</div>
<!-- Actions -->
<div class="mt-4 d-flex gap-3 justify-content-center">
<a href="{% url 'observations:observation_track' %}?tracking_code={{ tracking_code }}"
class="btn btn-outline-primary">
<i class="bi bi-search me-1"></i>{% trans "Track Status" %}
</a>
<a href="{% url 'observations:observation_create_public' %}" class="btn btn-primary">
<i class="bi bi-plus-circle me-1"></i>{% trans "Submit Another" %}
</a>
</div>
</div>
<!-- Bootstrap JS -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<script>
function copyTrackingCode() {
const code = document.getElementById('trackingCode').textContent;
navigator.clipboard.writeText(code).then(() => {
const btn = document.querySelector('.copy-btn');
const text = document.getElementById('copyText');
btn.classList.add('copied');
text.textContent = '{% trans "Copied!" %}';
setTimeout(() => {
btn.classList.remove('copied');
text.textContent = '{% trans "Copy Code" %}';
}, 2000);
});
}
</script>
</body>
</html>

View File

@ -0,0 +1,274 @@
{% load i18n %}
{% load static %}
<!DOCTYPE html>
<html lang="en" dir="ltr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% trans "Track Observation" %} - Al Hammadi</title>
<!-- Bootstrap CSS -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.0/font/bootstrap-icons.css" rel="stylesheet">
<style>
body {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.track-container {
background: white;
border-radius: 16px;
box-shadow: 0 10px 40px rgba(0,0,0,0.2);
padding: 40px;
max-width: 600px;
width: 100%;
}
.logo-header {
text-align: center;
margin-bottom: 30px;
}
.logo-header h1 {
color: #333;
font-size: 1.8rem;
margin-bottom: 5px;
}
.search-form {
display: flex;
gap: 10px;
}
.search-form input {
flex: 1;
font-size: 1.1rem;
padding: 12px 20px;
border: 2px solid #dee2e6;
border-radius: 8px;
}
.search-form input:focus {
border-color: #667eea;
outline: none;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.2);
}
.search-form button {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border: none;
padding: 12px 30px;
border-radius: 8px;
color: white;
font-size: 1.1rem;
}
.search-form button:hover {
opacity: 0.9;
}
.result-card {
background: #f8f9fa;
border-radius: 12px;
padding: 25px;
margin-top: 30px;
}
.status-timeline {
position: relative;
padding-left: 30px;
margin-top: 20px;
}
.status-timeline::before {
content: '';
position: absolute;
left: 10px;
top: 0;
bottom: 0;
width: 2px;
background: #dee2e6;
}
.timeline-item {
position: relative;
padding-bottom: 20px;
}
.timeline-item:last-child {
padding-bottom: 0;
}
.timeline-item::before {
content: '';
position: absolute;
left: -24px;
top: 5px;
width: 12px;
height: 12px;
border-radius: 50%;
background: #dee2e6;
border: 2px solid white;
}
.timeline-item.active::before {
background: #667eea;
}
.timeline-item.completed::before {
background: #28a745;
}
.status-badge {
display: inline-block;
padding: 8px 20px;
border-radius: 20px;
font-weight: 600;
font-size: 1rem;
}
.status-new { background: #e3f2fd; color: #1976d2; }
.status-triaged { background: #e0f7fa; color: #00838f; }
.status-assigned { background: #e0f7fa; color: #00838f; }
.status-in_progress { background: #fff3e0; color: #f57c00; }
.status-resolved { background: #e8f5e9; color: #388e3c; }
.status-closed { background: #f5f5f5; color: #616161; }
.status-rejected { background: #ffebee; color: #d32f2f; }
.status-duplicate { background: #f5f5f5; color: #616161; }
</style>
</head>
<body>
<div class="track-container">
<!-- Header -->
<div class="logo-header">
<h1><i class="bi bi-search text-primary me-2"></i>{% trans "Track Your Observation" %}</h1>
<p class="text-muted">{% trans "Enter your tracking code to check the status" %}</p>
</div>
<!-- Messages -->
{% if messages %}
{% for message in messages %}
<div class="alert alert-{{ message.tags }} alert-dismissible fade show" role="alert">
{{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
{% endfor %}
{% endif %}
<!-- Search Form -->
<form method="get" class="search-form">
<input type="text" name="tracking_code"
placeholder="{% trans 'e.g., OBS-ABC123' %}"
value="{{ form.tracking_code.value|default:'' }}"
autofocus>
<button type="submit">
<i class="bi bi-search me-1"></i>{% trans "Track" %}
</button>
</form>
{% if observation %}
<!-- Result Card -->
<div class="result-card">
<!-- Tracking Code -->
<div class="d-flex justify-content-between align-items-start mb-3">
<div>
<small class="text-muted">{% trans "Tracking Code" %}</small>
<h4 class="mb-0" style="font-family: 'Courier New', monospace; color: #667eea;">
{{ observation.tracking_code }}
</h4>
</div>
<span class="status-badge status-{{ observation.status }}">
{{ observation.get_status_display }}
</span>
</div>
<hr>
<!-- Details -->
<div class="row g-3">
<div class="col-6">
<small class="text-muted d-block">{% trans "Category" %}</small>
<strong>{{ observation.category.name_en|default:"Not specified" }}</strong>
</div>
<div class="col-6">
<small class="text-muted d-block">{% trans "Severity" %}</small>
<span class="badge bg-{{ observation.get_severity_color }}">
{{ observation.get_severity_display }}
</span>
</div>
<div class="col-6">
<small class="text-muted d-block">{% trans "Submitted" %}</small>
<strong>{{ observation.created_at|date:"M d, Y" }}</strong>
</div>
<div class="col-6">
<small class="text-muted d-block">{% trans "Last Updated" %}</small>
<strong>{{ observation.updated_at|date:"M d, Y" }}</strong>
</div>
</div>
<!-- Status Timeline -->
<h6 class="mt-4 mb-3">{% trans "Status Progress" %}</h6>
<div class="status-timeline">
<div class="timeline-item {% if observation.status == 'new' %}active{% elif observation.status != 'new' %}completed{% endif %}">
<strong>{% trans "Submitted" %}</strong>
<small class="text-muted d-block">{{ observation.created_at|date:"M d, Y H:i" }}</small>
</div>
{% if observation.triaged_at %}
<div class="timeline-item {% if observation.status == 'triaged' %}active{% elif observation.status in 'assigned,in_progress,resolved,closed' %}completed{% endif %}">
<strong>{% trans "Triaged" %}</strong>
<small class="text-muted d-block">{{ observation.triaged_at|date:"M d, Y H:i" }}</small>
</div>
{% endif %}
{% if observation.assigned_to %}
<div class="timeline-item {% if observation.status == 'assigned' %}active{% elif observation.status in 'in_progress,resolved,closed' %}completed{% endif %}">
<strong>{% trans "Assigned" %}</strong>
<small class="text-muted d-block">{% trans "Being reviewed by our team" %}</small>
</div>
{% endif %}
{% if observation.status == 'in_progress' %}
<div class="timeline-item active">
<strong>{% trans "In Progress" %}</strong>
<small class="text-muted d-block">{% trans "Action is being taken" %}</small>
</div>
{% endif %}
{% if observation.resolved_at %}
<div class="timeline-item {% if observation.status == 'resolved' %}active{% elif observation.status == 'closed' %}completed{% endif %}">
<strong>{% trans "Resolved" %}</strong>
<small class="text-muted d-block">{{ observation.resolved_at|date:"M d, Y H:i" }}</small>
</div>
{% endif %}
{% if observation.closed_at %}
<div class="timeline-item completed">
<strong>{% trans "Closed" %}</strong>
<small class="text-muted d-block">{{ observation.closed_at|date:"M d, Y H:i" }}</small>
</div>
{% endif %}
{% if observation.status == 'rejected' %}
<div class="timeline-item active">
<strong class="text-danger">{% trans "Rejected" %}</strong>
<small class="text-muted d-block">{% trans "This observation was not accepted" %}</small>
</div>
{% endif %}
{% if observation.status == 'duplicate' %}
<div class="timeline-item active">
<strong class="text-secondary">{% trans "Duplicate" %}</strong>
<small class="text-muted d-block">{% trans "This observation was marked as duplicate" %}</small>
</div>
{% endif %}
</div>
<!-- Note about privacy -->
<div class="alert alert-info mt-4 mb-0 small">
<i class="bi bi-info-circle me-1"></i>
{% trans "For privacy reasons, detailed notes and internal communications are not shown here." %}
</div>
</div>
{% endif %}
<!-- Back Link -->
<div class="text-center mt-4">
<a href="{% url 'observations:observation_create_public' %}" class="text-muted">
<i class="bi bi-plus-circle me-1"></i>{% trans "Submit a new observation" %}
</a>
</div>
</div>
<!-- Bootstrap JS -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>