added-observations
This commit is contained in:
parent
4841e92aa8
commit
6e829f1573
195
apps/observations/README.md
Normal file
195
apps/observations/README.md
Normal 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
|
||||
11
apps/observations/__init__.py
Normal file
11
apps/observations/__init__.py
Normal 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
221
apps/observations/admin.py
Normal 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
18
apps/observations/apps.py
Normal 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
413
apps/observations/forms.py
Normal 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
|
||||
1
apps/observations/management/__init__.py
Normal file
1
apps/observations/management/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
# Management commands
|
||||
1
apps/observations/management/commands/__init__.py
Normal file
1
apps/observations/management/commands/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
# Management commands
|
||||
@ -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'
|
||||
)
|
||||
)
|
||||
147
apps/observations/migrations/0001_initial.py
Normal file
147
apps/observations/migrations/0001_initial.py
Normal 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'),
|
||||
),
|
||||
]
|
||||
1
apps/observations/migrations/__init__.py
Normal file
1
apps/observations/migrations/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
# Observations migrations
|
||||
415
apps/observations/models.py
Normal file
415
apps/observations/models.py
Normal 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}"
|
||||
599
apps/observations/services.py
Normal file
599
apps/observations/services.py
Normal 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),
|
||||
}
|
||||
40
apps/observations/signals.py
Normal file
40
apps/observations/signals.py
Normal 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
488
apps/observations/tests.py
Normal 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
82
apps/observations/urls.py
Normal 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
672
apps/observations/views.py
Normal 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
|
||||
@ -64,6 +64,7 @@ LOCAL_APPS = [
|
||||
'apps.ai_engine',
|
||||
'apps.dashboard',
|
||||
'apps.appreciation',
|
||||
'apps.observations',
|
||||
]
|
||||
|
||||
INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + LOCAL_APPS
|
||||
|
||||
@ -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')),
|
||||
|
||||
@ -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 %}"
|
||||
|
||||
103
templates/observations/category_form.html
Normal file
103
templates/observations/category_form.html
Normal 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 %}
|
||||
111
templates/observations/category_list.html
Normal file
111
templates/observations/category_list.html
Normal 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 %}
|
||||
103
templates/observations/convert_to_action.html
Normal file
103
templates/observations/convert_to_action.html
Normal 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 %}
|
||||
462
templates/observations/observation_detail.html
Normal file
462
templates/observations/observation_detail.html
Normal 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 %}
|
||||
453
templates/observations/observation_list.html
Normal file
453
templates/observations/observation_list.html
Normal 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 %}
|
||||
339
templates/observations/public_new.html
Normal file
339
templates/observations/public_new.html
Normal 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>
|
||||
191
templates/observations/public_success.html
Normal file
191
templates/observations/public_success.html
Normal 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>
|
||||
274
templates/observations/public_track.html
Normal file
274
templates/observations/public_track.html
Normal 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>
|
||||
Loading…
x
Reference in New Issue
Block a user