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