363 lines
12 KiB
Python
363 lines
12 KiB
Python
"""
|
|
Reports models - Custom Report Builder for PX360.
|
|
|
|
Supports creating, saving, and scheduling custom reports
|
|
across multiple data sources (Complaints, Inquiries, Observations, etc.)
|
|
"""
|
|
from django.conf import settings
|
|
from django.db import models
|
|
from django.utils import timezone
|
|
from apps.core.models import TimeStampedModel, UUIDModel
|
|
|
|
|
|
class DataSource(models.TextChoices):
|
|
"""Available data sources for reports."""
|
|
COMPLAINTS = 'complaints', 'Complaints'
|
|
INQUIRIES = 'inquiries', 'Inquiries'
|
|
OBSERVATIONS = 'observations', 'Observations'
|
|
PX_ACTIONS = 'px_actions', 'PX Actions'
|
|
SURVEYS = 'surveys', 'Surveys'
|
|
PHYSICIANS = 'physicians', 'Physician Ratings'
|
|
|
|
|
|
class ReportFrequency(models.TextChoices):
|
|
"""Report schedule frequency options."""
|
|
ONCE = 'once', 'One Time'
|
|
DAILY = 'daily', 'Daily'
|
|
WEEKLY = 'weekly', 'Weekly'
|
|
MONTHLY = 'monthly', 'Monthly'
|
|
QUARTERLY = 'quarterly', 'Quarterly'
|
|
|
|
|
|
class ReportFormat(models.TextChoices):
|
|
"""Export format options."""
|
|
HTML = 'html', 'View Online'
|
|
PDF = 'pdf', 'PDF'
|
|
EXCEL = 'excel', 'Excel'
|
|
CSV = 'csv', 'CSV'
|
|
|
|
|
|
class ChartType(models.TextChoices):
|
|
"""Chart type options."""
|
|
BAR = 'bar', 'Bar Chart'
|
|
LINE = 'line', 'Line Chart'
|
|
PIE = 'pie', 'Pie Chart'
|
|
DONUT = 'donut', 'Donut Chart'
|
|
AREA = 'area', 'Area Chart'
|
|
TABLE = 'table', 'Table Only'
|
|
|
|
|
|
class SavedReport(UUIDModel, TimeStampedModel):
|
|
"""
|
|
Saved report configuration.
|
|
|
|
Stores all settings for a custom report that can be
|
|
regenerated on demand or scheduled.
|
|
"""
|
|
name = models.CharField(max_length=200)
|
|
description = models.TextField(blank=True)
|
|
|
|
# Data source
|
|
data_source = models.CharField(
|
|
max_length=50,
|
|
choices=DataSource.choices,
|
|
default=DataSource.COMPLAINTS
|
|
)
|
|
|
|
# Filter configuration (JSON)
|
|
# Example: {"date_range": "30d", "hospital": "uuid", "status": ["open", "in_progress"]}
|
|
filter_config = models.JSONField(default=dict, blank=True)
|
|
|
|
# Column configuration (JSON)
|
|
# Example: ["title", "status", "severity", "department__name", "created_at"]
|
|
column_config = models.JSONField(default=list, blank=True)
|
|
|
|
# Grouping configuration (JSON)
|
|
# Example: {"field": "department__name", "aggregation": "count"}
|
|
grouping_config = models.JSONField(default=dict, blank=True)
|
|
|
|
# Sort configuration (JSON)
|
|
# Example: [{"field": "created_at", "direction": "desc"}]
|
|
sort_config = models.JSONField(default=list, blank=True)
|
|
|
|
# Chart configuration (JSON)
|
|
# Example: {"type": "bar", "x_axis": "department__name", "y_axis": "count"}
|
|
chart_config = models.JSONField(default=dict, blank=True)
|
|
|
|
# Owner and sharing
|
|
created_by = models.ForeignKey(
|
|
settings.AUTH_USER_MODEL,
|
|
on_delete=models.CASCADE,
|
|
related_name='saved_reports'
|
|
)
|
|
is_shared = models.BooleanField(
|
|
default=False,
|
|
help_text="Share with other users in the same hospital"
|
|
)
|
|
is_template = models.BooleanField(
|
|
default=False,
|
|
help_text="Available as a template for others to use"
|
|
)
|
|
|
|
# Organization scope
|
|
hospital = models.ForeignKey(
|
|
'organizations.Hospital',
|
|
on_delete=models.CASCADE,
|
|
related_name='saved_reports',
|
|
null=True,
|
|
blank=True
|
|
)
|
|
|
|
# Metadata
|
|
last_run_at = models.DateTimeField(null=True, blank=True)
|
|
last_run_count = models.IntegerField(default=0)
|
|
|
|
class Meta:
|
|
ordering = ['-created_at']
|
|
indexes = [
|
|
models.Index(fields=['data_source', '-created_at']),
|
|
models.Index(fields=['created_by', '-created_at']),
|
|
models.Index(fields=['hospital', 'is_shared']),
|
|
]
|
|
|
|
def __str__(self):
|
|
return self.name
|
|
|
|
|
|
class ReportSchedule(UUIDModel, TimeStampedModel):
|
|
"""
|
|
Schedule for automated report generation and delivery.
|
|
"""
|
|
report = models.ForeignKey(
|
|
SavedReport,
|
|
on_delete=models.CASCADE,
|
|
related_name='schedules'
|
|
)
|
|
|
|
# Schedule settings
|
|
frequency = models.CharField(
|
|
max_length=20,
|
|
choices=ReportFrequency.choices,
|
|
default=ReportFrequency.WEEKLY
|
|
)
|
|
|
|
# Delivery time (hour:minute in 24h format)
|
|
# Stored as "HH:MM" string
|
|
delivery_time = models.CharField(
|
|
max_length=5,
|
|
default='09:00',
|
|
help_text="Time to send report (HH:MM format)"
|
|
)
|
|
|
|
# Day of week for weekly reports (0=Monday, 6=Sunday)
|
|
day_of_week = models.IntegerField(
|
|
null=True,
|
|
blank=True,
|
|
help_text="For weekly reports: 0=Monday, 6=Sunday"
|
|
)
|
|
|
|
# Day of month for monthly reports (1-31)
|
|
day_of_month = models.IntegerField(
|
|
null=True,
|
|
blank=True,
|
|
help_text="For monthly reports: 1-31"
|
|
)
|
|
|
|
# Export format for scheduled reports
|
|
export_format = models.CharField(
|
|
max_length=20,
|
|
choices=ReportFormat.choices,
|
|
default=ReportFormat.PDF
|
|
)
|
|
|
|
# Recipients
|
|
recipients = models.JSONField(
|
|
default=list,
|
|
help_text="List of email addresses to receive the report"
|
|
)
|
|
|
|
# Status
|
|
is_active = models.BooleanField(default=True)
|
|
last_run_at = models.DateTimeField(null=True, blank=True)
|
|
next_run_at = models.DateTimeField(null=True, blank=True)
|
|
last_error = models.TextField(blank=True)
|
|
|
|
class Meta:
|
|
ordering = ['next_run_at']
|
|
|
|
def __str__(self):
|
|
return f"{self.report.name} - {self.get_frequency_display()}"
|
|
|
|
def calculate_next_run(self):
|
|
"""Calculate the next run time based on frequency."""
|
|
from datetime import datetime, timedelta
|
|
|
|
now = timezone.now()
|
|
hour, minute = map(int, self.delivery_time.split(':'))
|
|
|
|
if self.frequency == ReportFrequency.DAILY:
|
|
next_run = now.replace(hour=hour, minute=minute, second=0, microsecond=0)
|
|
if next_run <= now:
|
|
next_run += timedelta(days=1)
|
|
return next_run
|
|
|
|
elif self.frequency == ReportFrequency.WEEKLY and self.day_of_week is not None:
|
|
days_ahead = self.day_of_week - now.weekday()
|
|
if days_ahead <= 0:
|
|
days_ahead += 7
|
|
next_run = now.replace(hour=hour, minute=minute, second=0, microsecond=0)
|
|
next_run += timedelta(days=days_ahead)
|
|
return next_run
|
|
|
|
elif self.frequency == ReportFrequency.MONTHLY and self.day_of_month:
|
|
next_run = now.replace(day=min(self.day_of_month, 28), hour=hour, minute=minute, second=0, microsecond=0)
|
|
if next_run <= now:
|
|
if now.month == 12:
|
|
next_run = next_run.replace(year=now.year + 1, month=1)
|
|
else:
|
|
next_run = next_run.replace(month=now.month + 1)
|
|
return next_run
|
|
|
|
return None
|
|
|
|
|
|
class GeneratedReport(UUIDModel, TimeStampedModel):
|
|
"""
|
|
Record of a generated report instance.
|
|
|
|
Stores the actual data and metadata for each report run.
|
|
"""
|
|
saved_report = models.ForeignKey(
|
|
SavedReport,
|
|
on_delete=models.CASCADE,
|
|
related_name='generated_reports',
|
|
null=True,
|
|
blank=True
|
|
)
|
|
|
|
# Report name (copied from saved report for standalone reports)
|
|
name = models.CharField(max_length=200)
|
|
data_source = models.CharField(max_length=50, choices=DataSource.choices)
|
|
|
|
# Configuration snapshots
|
|
filter_config = models.JSONField(default=dict)
|
|
column_config = models.JSONField(default=list)
|
|
grouping_config = models.JSONField(default=dict)
|
|
chart_config = models.JSONField(default=dict)
|
|
|
|
# Generated data
|
|
data = models.JSONField(default=dict, help_text="The actual report data")
|
|
summary = models.JSONField(default=dict, help_text="Summary statistics")
|
|
chart_data = models.JSONField(default=dict, help_text="Chart-formatted data")
|
|
|
|
# Row count
|
|
row_count = models.IntegerField(default=0)
|
|
|
|
# Generation metadata
|
|
generated_by = models.ForeignKey(
|
|
settings.AUTH_USER_MODEL,
|
|
on_delete=models.SET_NULL,
|
|
null=True,
|
|
blank=True,
|
|
related_name='generated_reports'
|
|
)
|
|
|
|
# Export files
|
|
pdf_file = models.FileField(upload_to='reports/pdf/', null=True, blank=True)
|
|
excel_file = models.FileField(upload_to='reports/excel/', null=True, blank=True)
|
|
csv_file = models.FileField(upload_to='reports/csv/', null=True, blank=True)
|
|
|
|
# Organization scope
|
|
hospital = models.ForeignKey(
|
|
'organizations.Hospital',
|
|
on_delete=models.CASCADE,
|
|
related_name='generated_reports',
|
|
null=True,
|
|
blank=True
|
|
)
|
|
|
|
# Expiry (auto-delete old reports)
|
|
expires_at = models.DateTimeField(null=True, blank=True)
|
|
|
|
class Meta:
|
|
ordering = ['-created_at']
|
|
indexes = [
|
|
models.Index(fields=['data_source', '-created_at']),
|
|
models.Index(fields=['generated_by', '-created_at']),
|
|
models.Index(fields=['expires_at']),
|
|
]
|
|
|
|
def __str__(self):
|
|
return f"{self.name} - {self.created_at.strftime('%Y-%m-%d %H:%M')}"
|
|
|
|
def is_expired(self):
|
|
"""Check if the report has expired."""
|
|
if self.expires_at:
|
|
return timezone.now() > self.expires_at
|
|
return False
|
|
|
|
|
|
class ReportTemplate(UUIDModel, TimeStampedModel):
|
|
"""
|
|
Pre-built report templates for quick access.
|
|
|
|
These are system-provided templates that users can use
|
|
as starting points for their custom reports.
|
|
"""
|
|
name = models.CharField(max_length=200)
|
|
description = models.TextField(blank=True)
|
|
category = models.CharField(max_length=100, blank=True)
|
|
|
|
# Data source
|
|
data_source = models.CharField(
|
|
max_length=50,
|
|
choices=DataSource.choices,
|
|
default=DataSource.COMPLAINTS
|
|
)
|
|
|
|
# Template configuration
|
|
filter_config = models.JSONField(default=dict)
|
|
column_config = models.JSONField(default=list)
|
|
grouping_config = models.JSONField(default=dict)
|
|
sort_config = models.JSONField(default=list)
|
|
chart_config = models.JSONField(default=dict)
|
|
|
|
# Display
|
|
icon = models.CharField(max_length=50, blank=True, help_text="Bootstrap icon class")
|
|
sort_order = models.IntegerField(default=0)
|
|
is_active = models.BooleanField(default=True)
|
|
|
|
class Meta:
|
|
ordering = ['sort_order', 'name']
|
|
|
|
def __str__(self):
|
|
return self.name
|
|
|
|
def create_report(self, user, overrides=None):
|
|
"""Create a SavedReport from this template."""
|
|
from apps.reports.services import ReportBuilderService
|
|
|
|
config = {
|
|
'filter_config': self.filter_config.copy(),
|
|
'column_config': self.column_config.copy(),
|
|
'grouping_config': self.grouping_config.copy(),
|
|
'sort_config': self.sort_config.copy(),
|
|
'chart_config': self.chart_config.copy(),
|
|
}
|
|
|
|
if overrides:
|
|
for key, value in overrides.items():
|
|
if key in config and isinstance(config[key], dict):
|
|
config[key].update(value)
|
|
else:
|
|
config[key] = value
|
|
|
|
report = SavedReport.objects.create(
|
|
name=f"{self.name} - {timezone.now().strftime('%Y-%m-%d')}",
|
|
description=self.description,
|
|
data_source=self.data_source,
|
|
created_by=user,
|
|
hospital=user.hospital,
|
|
**config
|
|
)
|
|
return report |