HH/apps/reports/services.py
2026-03-09 16:10:24 +03:00

610 lines
29 KiB
Python

"""
Report generation services for PX360 - Simplified Version
Handles data fetching, filtering, aggregation, and export
for custom reports across all data sources. No chart functionality.
"""
import csv
import io
from datetime import datetime, timedelta
from django.db.models import (
Count, Sum, Avg, Min, Max, F, Q, Value,
FloatField, IntegerField, CharField, ExpressionWrapper, DurationField
)
from django.db.models.functions import TruncDate, TruncMonth, TruncWeek, TruncYear, Extract
from django.http import HttpResponse
from django.utils import timezone
from django.conf import settings
class ReportBuilderService:
"""
Service for building custom reports from various data sources.
Provides:
- Data fetching with dynamic filters
- Column selection
- Grouping and aggregation
- Summary statistics
"""
# Available fields for each data source
SOURCE_FIELDS = {
'complaints': {
'id': {'label': 'ID', 'field': 'id', 'type': 'string'},
'reference_number': {'label': 'Reference Number', 'field': 'reference_number', 'type': 'string'},
'title': {'label': 'Title', 'field': 'title', 'type': 'string'},
'description': {'label': 'Description', 'field': 'description', 'type': 'text'},
'status': {'label': 'Status', 'field': 'status', 'type': 'choice'},
'severity': {'label': 'Severity', 'field': 'severity', 'type': 'choice'},
'priority': {'label': 'Priority', 'field': 'priority', 'type': 'choice'},
'source': {'label': 'Source', 'field': 'complaint_source_type', 'type': 'choice'},
'hospital': {'label': 'Hospital', 'field': 'hospital__name', 'type': 'string'},
'department': {'label': 'Department', 'field': 'department__name', 'type': 'string'},
'section': {'label': 'Section', 'field': 'section__name', 'type': 'string'},
'patient_name': {'label': 'Patient Name', 'field': 'patient__first_name', 'type': 'string'},
'patient_mobile': {'label': 'Patient Mobile', 'field': 'patient__mobile_number', 'type': 'string'},
'created_at': {'label': 'Created Date', 'field': 'created_at', 'type': 'datetime'},
'updated_at': {'label': 'Updated Date', 'field': 'updated_at', 'type': 'datetime'},
'due_at': {'label': 'Due Date', 'field': 'due_at', 'type': 'datetime'},
'resolved_at': {'label': 'Resolved Date', 'field': 'resolved_at', 'type': 'datetime'},
'is_overdue': {'label': 'Is Overdue', 'field': 'is_overdue', 'type': 'boolean'},
'resolution_time_hours': {'label': 'Resolution Time (Hours)', 'field': 'resolution_time_hours', 'type': 'number'},
'journey_type': {'label': 'Journey Type', 'field': 'journey__journey_type', 'type': 'string'},
},
'inquiries': {
'id': {'label': 'ID', 'field': 'id', 'type': 'string'},
'reference_number': {'label': 'Reference Number', 'field': 'reference_number', 'type': 'string'},
'title': {'label': 'Title', 'field': 'title', 'type': 'string'},
'description': {'label': 'Description', 'field': 'description', 'type': 'text'},
'status': {'label': 'Status', 'field': 'status', 'type': 'choice'},
'category': {'label': 'Category', 'field': 'category__name_en', 'type': 'string'},
'hospital': {'label': 'Hospital', 'field': 'hospital__name', 'type': 'string'},
'department': {'label': 'Department', 'field': 'department__name', 'type': 'string'},
'created_at': {'label': 'Created Date', 'field': 'created_at', 'type': 'datetime'},
'updated_at': {'label': 'Updated Date', 'field': 'updated_at', 'type': 'datetime'},
},
'observations': {
'id': {'label': 'ID', 'field': 'id', 'type': 'string'},
'tracking_code': {'label': 'Tracking Code', 'field': 'tracking_code', 'type': 'string'},
'title': {'label': 'Title', 'field': 'title', 'type': 'string'},
'description': {'label': 'Description', 'field': 'description', 'type': 'text'},
'status': {'label': 'Status', 'field': 'status', 'type': 'choice'},
'severity': {'label': 'Severity', 'field': 'severity', 'type': 'choice'},
'category': {'label': 'Category', 'field': 'category__name_en', 'type': 'string'},
'hospital': {'label': 'Hospital', 'field': 'hospital__name', 'type': 'string'},
'department': {'label': 'Department', 'field': 'assigned_department__name', 'type': 'string'},
'location': {'label': 'Location', 'field': 'location_text', 'type': 'string'},
'created_at': {'label': 'Created Date', 'field': 'created_at', 'type': 'datetime'},
'incident_datetime': {'label': 'Incident Date', 'field': 'incident_datetime', 'type': 'datetime'},
},
'px_actions': {
'id': {'label': 'ID', 'field': 'id', 'type': 'string'},
'title': {'label': 'Title', 'field': 'title', 'type': 'string'},
'description': {'label': 'Description', 'field': 'description', 'type': 'text'},
'status': {'label': 'Status', 'field': 'status', 'type': 'choice'},
'priority': {'label': 'Priority', 'field': 'priority', 'type': 'choice'},
'action_type': {'label': 'Action Type', 'field': 'action_type', 'type': 'choice'},
'hospital': {'label': 'Hospital', 'field': 'hospital__name', 'type': 'string'},
'department': {'label': 'Department', 'field': 'department__name', 'type': 'string'},
'created_at': {'label': 'Created Date', 'field': 'created_at', 'type': 'datetime'},
'due_at': {'label': 'Due Date', 'field': 'due_at', 'type': 'datetime'},
'completed_at': {'label': 'Completed Date', 'field': 'completed_at', 'type': 'datetime'},
'is_overdue': {'label': 'Is Overdue', 'field': 'is_overdue', 'type': 'boolean'},
},
'surveys': {
'id': {'label': 'ID', 'field': 'id', 'type': 'string'},
'survey_template': {'label': 'Survey Template', 'field': 'survey_template__name', 'type': 'string'},
'status': {'label': 'Status', 'field': 'status', 'type': 'choice'},
'total_score': {'label': 'Total Score', 'field': 'total_score', 'type': 'number'},
'is_negative': {'label': 'Is Negative', 'field': 'is_negative', 'type': 'boolean'},
'patient_type': {'label': 'Patient Type', 'field': 'journey__patient_type', 'type': 'string'},
'journey_type': {'label': 'Journey Type', 'field': 'journey__journey_type', 'type': 'string'},
'hospital': {'label': 'Hospital', 'field': 'survey_template__hospital__name', 'type': 'string'},
'department': {'label': 'Department', 'field': 'journey__department__name', 'type': 'string'},
'created_at': {'label': 'Created Date', 'field': 'created_at', 'type': 'datetime'},
'completed_at': {'label': 'Completed Date', 'field': 'completed_at', 'type': 'datetime'},
},
'physicians': {
'id': {'label': 'ID', 'field': 'id', 'type': 'string'},
'physician_name': {'label': 'Physician Name', 'field': 'physician__full_name', 'type': 'string'},
'department': {'label': 'Department', 'field': 'department__name', 'type': 'string'},
'month': {'label': 'Month', 'field': 'month', 'type': 'string'},
'year': {'label': 'Year', 'field': 'year', 'type': 'number'},
'total_surveys': {'label': 'Total Surveys', 'field': 'total_surveys', 'type': 'number'},
'avg_rating': {'label': 'Average Rating', 'field': 'avg_rating', 'type': 'number'},
'positive_count': {'label': 'Positive', 'field': 'positive_count', 'type': 'number'},
'neutral_count': {'label': 'Neutral', 'field': 'neutral_count', 'type': 'number'},
'negative_count': {'label': 'Negative', 'field': 'negative_count', 'type': 'number'},
},
}
# Filter options for each data source
SOURCE_FILTERS = {
'complaints': [
{'name': 'date_range', 'label': 'Date Range', 'type': 'daterange'},
{'name': 'status', 'label': 'Status', 'type': 'multiselect'},
{'name': 'severity', 'label': 'Severity', 'type': 'multiselect'},
{'name': 'priority', 'label': 'Priority', 'type': 'multiselect'},
{'name': 'hospital', 'label': 'Hospital', 'type': 'select'},
{'name': 'department', 'label': 'Department', 'type': 'select'},
{'name': 'section', 'label': 'Section', 'type': 'select'},
{'name': 'source', 'label': 'Source', 'type': 'multiselect'},
{'name': 'is_overdue', 'label': 'Is Overdue', 'type': 'boolean'},
{'name': 'journey_type', 'label': 'Journey Type', 'type': 'select'},
],
'inquiries': [
{'name': 'date_range', 'label': 'Date Range', 'type': 'daterange'},
{'name': 'status', 'label': 'Status', 'type': 'multiselect'},
{'name': 'hospital', 'label': 'Hospital', 'type': 'select'},
{'name': 'department', 'label': 'Department', 'type': 'select'},
{'name': 'category', 'label': 'Category', 'type': 'select'},
],
'observations': [
{'name': 'date_range', 'label': 'Date Range', 'type': 'daterange'},
{'name': 'status', 'label': 'Status', 'type': 'multiselect'},
{'name': 'severity', 'label': 'Severity', 'type': 'multiselect'},
{'name': 'hospital', 'label': 'Hospital', 'type': 'select'},
{'name': 'department', 'label': 'Department', 'type': 'select'},
{'name': 'category', 'label': 'Category', 'type': 'select'},
],
'px_actions': [
{'name': 'date_range', 'label': 'Date Range', 'type': 'daterange'},
{'name': 'status', 'label': 'Status', 'type': 'multiselect'},
{'name': 'priority', 'label': 'Priority', 'type': 'multiselect'},
{'name': 'action_type', 'label': 'Action Type', 'type': 'multiselect'},
{'name': 'hospital', 'label': 'Hospital', 'type': 'select'},
{'name': 'department', 'label': 'Department', 'type': 'select'},
{'name': 'is_overdue', 'label': 'Is Overdue', 'type': 'boolean'},
],
'surveys': [
{'name': 'date_range', 'label': 'Date Range', 'type': 'daterange'},
{'name': 'status', 'label': 'Status', 'type': 'multiselect'},
{'name': 'is_negative', 'label': 'Is Negative', 'type': 'boolean'},
{'name': 'hospital', 'label': 'Hospital', 'type': 'select'},
{'name': 'department', 'label': 'Department', 'type': 'select'},
{'name': 'patient_type', 'label': 'Patient Type', 'type': 'select'},
{'name': 'journey_type', 'label': 'Journey Type', 'type': 'select'},
],
'physicians': [
{'name': 'month_range', 'label': 'Month Range', 'type': 'monthrange'},
{'name': 'hospital', 'label': 'Hospital', 'type': 'select'},
{'name': 'department', 'label': 'Department', 'type': 'select'},
],
}
@classmethod
def get_queryset(cls, data_source):
"""Get the base queryset for a data source."""
from apps.complaints.models import Complaint
from apps.observations.models import Observation
from apps.px_action_center.models import PXAction
from apps.surveys.models import SurveyInstance
from apps.physicians.models import PhysicianMonthlyRating
querysets = {
'complaints': Complaint.objects.all(),
'inquiries': Complaint.objects.filter(complaint_type='inquiry'),
'observations': Observation.objects.all(),
'px_actions': PXAction.objects.all(),
'surveys': SurveyInstance.objects.all(),
'physicians': PhysicianMonthlyRating.objects.all(),
}
return querysets.get(data_source)
@classmethod
def apply_filters(cls, queryset, filters, data_source):
"""Apply filters to a queryset."""
# Date range filter
if 'date_range' in filters:
date_range = filters['date_range']
date_field = 'created_at'
if date_range == '7d':
start_date = timezone.now() - timedelta(days=7)
elif date_range == '30d':
start_date = timezone.now() - timedelta(days=30)
elif date_range == '90d':
start_date = timezone.now() - timedelta(days=90)
elif date_range == 'ytd':
start_date = timezone.now().replace(month=1, day=1)
elif date_range == 'custom' and 'start_date' in filters and 'end_date' in filters:
start_date = filters['start_date']
end_date = filters['end_date']
queryset = queryset.filter(**{f'{date_field}__gte': start_date, f'{date_field}__lte': end_date})
return queryset
else:
start_date = timezone.now() - timedelta(days=30)
queryset = queryset.filter(**{f'{date_field}__gte': start_date})
# Hospital filter
if 'hospital' in filters and filters['hospital']:
queryset = queryset.filter(hospital_id=filters['hospital'])
# Department filter
if 'department' in filters and filters['department']:
if data_source == 'observations':
queryset = queryset.filter(assigned_department_id=filters['department'])
elif data_source == 'surveys':
queryset = queryset.filter(journey__department_id=filters['department'])
else:
queryset = queryset.filter(department_id=filters['department'])
# Section filter
if 'section' in filters and filters['section']:
queryset = queryset.filter(section_id=filters['section'])
# Status filter
if 'status' in filters and filters['status']:
if isinstance(filters['status'], list):
queryset = queryset.filter(status__in=filters['status'])
else:
queryset = queryset.filter(status=filters['status'])
# Severity filter
if 'severity' in filters and filters['severity']:
if isinstance(filters['severity'], list):
queryset = queryset.filter(severity__in=filters['severity'])
else:
queryset = queryset.filter(severity=filters['severity'])
# Priority filter
if 'priority' in filters and filters['priority']:
if isinstance(filters['priority'], list):
queryset = queryset.filter(priority__in=filters['priority'])
else:
queryset = queryset.filter(priority=filters['priority'])
# Source filter (for complaints)
if 'source' in filters and filters['source']:
if isinstance(filters['source'], list):
queryset = queryset.filter(complaint_source_type__in=filters['source'])
else:
queryset = queryset.filter(complaint_source_type=filters['source'])
# Is overdue filter
if 'is_overdue' in filters:
if filters['is_overdue'] == 'true' or filters['is_overdue'] is True:
queryset = queryset.filter(is_overdue=True)
elif filters['is_overdue'] == 'false' or filters['is_overdue'] is False:
queryset = queryset.filter(is_overdue=False)
# Is negative filter (for surveys)
if 'is_negative' in filters:
if filters['is_negative'] == 'true' or filters['is_negative'] is True:
queryset = queryset.filter(is_negative=True)
elif filters['is_negative'] == 'false' or filters['is_negative'] is False:
queryset = queryset.filter(is_negative=False)
# Journey type filter
if 'journey_type' in filters and filters['journey_type']:
if data_source == 'complaints':
queryset = queryset.filter(journey__journey_type=filters['journey_type'])
elif data_source == 'surveys':
queryset = queryset.filter(journey__journey_type=filters['journey_type'])
# Patient type filter
if 'patient_type' in filters and filters['patient_type']:
queryset = queryset.filter(journey__patient_type=filters['patient_type'])
return queryset
@classmethod
def apply_grouping(cls, queryset, grouping_config, data_source):
"""Apply grouping and aggregation to a queryset."""
if not grouping_config or 'field' not in grouping_config:
return queryset
field = grouping_config['field']
aggregation = grouping_config.get('aggregation', 'count')
# Determine truncation for date fields
if 'created_at' in field or 'date' in field.lower():
trunc_by = grouping_config.get('trunc_by', 'day')
if trunc_by == 'day':
queryset = queryset.annotate(period=TruncDate(field))
elif trunc_by == 'week':
queryset = queryset.annotate(period=TruncWeek(field))
elif trunc_by == 'month':
queryset = queryset.annotate(period=TruncMonth(field))
elif trunc_by == 'year':
queryset = queryset.annotate(period=TruncYear(field))
field = 'period'
# Apply aggregation
if aggregation == 'count':
return queryset.values(field).annotate(count=Count('id')).order_by(field)
elif aggregation == 'sum':
sum_field = grouping_config.get('sum_field', 'id')
return queryset.values(field).annotate(total=Sum(sum_field)).order_by(field)
elif aggregation == 'avg':
avg_field = grouping_config.get('avg_field', 'total_score')
return queryset.values(field).annotate(average=Avg(avg_field)).order_by(field)
return queryset
@classmethod
def get_field_value(cls, obj, field_path):
"""Get a value from an object using dot notation."""
parts = field_path.split('__')
value = obj
for part in parts:
if value is None:
return None
if isinstance(value, dict):
value = value.get(part)
else:
value = getattr(value, part, None)
return value
@classmethod
def format_value(cls, value, field_type):
"""Format a value for display."""
if value is None:
return ''
if field_type == 'datetime':
if isinstance(value, str):
return value
return value.strftime('%Y-%m-%d %H:%M')
elif field_type == 'date':
if isinstance(value, str):
return value
return value.strftime('%Y-%m-%d')
elif field_type == 'boolean':
return 'Yes' if value else 'No'
elif field_type == 'number':
if isinstance(value, (int, float)):
return round(value, 2) if isinstance(value, float) else value
return value
return str(value) if value else ''
@classmethod
def generate_report_data(cls, data_source, filter_config, column_config, grouping_config, sort_config=None):
"""Generate report data with filters, columns, and grouping."""
queryset = cls.get_queryset(data_source)
queryset = cls.apply_filters(queryset, filter_config, data_source)
# Determine columns to select
if not column_config:
column_config = list(cls.SOURCE_FIELDS.get(data_source, {}).keys())[:10]
fields_info = cls.SOURCE_FIELDS.get(data_source, {})
if grouping_config and 'field' in grouping_config:
# Grouped data
grouped_data = cls.apply_grouping(queryset, grouping_config, data_source)
rows = []
for item in grouped_data:
row = {}
for key, value in item.items():
row[key] = cls.format_value(value, 'number' if key == 'count' else 'string')
rows.append(row)
return {
'rows': rows,
'columns': list(grouped_data[0].keys()) if grouped_data else ['field', 'count'],
'grouped': True,
}
else:
# Regular data
select_fields = []
for col in column_config:
if col in fields_info:
select_fields.append(fields_info[col]['field'])
# Apply sorting
if sort_config:
for sort_item in sort_config:
field = sort_item.get('field')
direction = sort_item.get('direction', 'asc')
if field in fields_info:
order_field = fields_info[field]['field']
if direction == 'desc':
order_field = f'-{order_field}'
queryset = queryset.order_by(order_field)
# Limit results for performance
queryset = queryset[:1000]
rows = []
for obj in queryset:
row = {}
for col in column_config:
if col in fields_info:
field_info = fields_info[col]
value = cls.get_field_value(obj, field_info['field'])
row[col] = cls.format_value(value, field_info['type'])
rows.append(row)
# Return both keys (for data access) and labels (for display)
column_labels = [fields_info.get(col, {'label': col})['label'] for col in column_config]
return {
'rows': rows,
'columns': column_labels,
'column_keys': column_config, # Add field keys for data access
'grouped': False,
}
@classmethod
def generate_summary(cls, data_source, filter_config):
"""Generate summary statistics for a data source."""
queryset = cls.get_queryset(data_source)
queryset = cls.apply_filters(queryset, filter_config, data_source)
summary = {
'total_count': queryset.count(),
}
if data_source == 'complaints':
summary['open_count'] = queryset.filter(status='open').count()
summary['resolved_count'] = queryset.filter(status='resolved').count()
summary['overdue_count'] = queryset.filter(is_overdue=True).count()
# Calculate average resolution time in hours (SQLite-compatible)
resolved_complaints = queryset.filter(resolved_at__isnull=False)
if resolved_complaints.exists():
# Calculate in Python to avoid SQLite DurationField limitation
total_hours = 0
count = 0
for complaint in resolved_complaints.values('created_at', 'resolved_at'):
if complaint['created_at'] and complaint['resolved_at']:
delta = complaint['resolved_at'] - complaint['created_at']
total_hours += delta.total_seconds() / 3600.0
count += 1
summary['avg_resolution_time'] = round(total_hours / count, 2) if count > 0 else 0
else:
summary['avg_resolution_time'] = 0
elif data_source == 'surveys':
summary['completed_count'] = queryset.filter(status='completed').count()
summary['pending_count'] = queryset.filter(status='pending').count()
summary['negative_count'] = queryset.filter(is_negative=True).count()
summary['avg_score'] = queryset.filter(
status='completed'
).aggregate(avg=Avg('total_score'))['avg'] or 0
elif data_source == 'px_actions':
summary['open_count'] = queryset.filter(status='open').count()
summary['completed_count'] = queryset.filter(status='completed').count()
summary['overdue_count'] = queryset.filter(is_overdue=True).count()
elif data_source == 'observations':
summary['new_count'] = queryset.filter(status='new').count()
summary['resolved_count'] = queryset.filter(status='resolved').count()
return summary
class ReportExportService:
"""Service for exporting reports to various formats."""
@classmethod
def export_to_csv(cls, data, columns, column_keys=None, filename='report'):
"""Export report data to CSV.
Args:
data: List of row dicts
columns: List of column labels (for header row)
column_keys: List of column keys (for data access). If None, uses columns.
filename: Output filename without extension
"""
response = HttpResponse(content_type='text/csv')
response['Content-Disposition'] = f'attachment; filename="{filename}.csv"'
writer = csv.writer(response)
writer.writerow(columns) # Write header row with labels
# Use column_keys for data access if provided, otherwise use columns
keys = column_keys if column_keys else columns
for row in data:
writer.writerow([row.get(key, '') for key in keys])
return response
@classmethod
def export_to_excel(cls, data, columns, column_keys=None, filename='report'):
"""Export report data to Excel (XLSX).
Args:
data: List of row dicts
columns: List of column labels (for header row)
column_keys: List of column keys (for data access). If None, uses columns.
filename: Output filename without extension
"""
try:
import openpyxl
from openpyxl.styles import Font, PatternFill, Alignment
from openpyxl.utils import get_column_letter
except ImportError:
# Fall back to CSV if openpyxl not available
return cls.export_to_csv(data, columns, column_keys, filename)
wb = openpyxl.Workbook()
ws = wb.active
ws.title = 'Report'
# Header row
header_fill = PatternFill(start_color='1F4E79', end_color='1F4E79', fill_type='solid')
header_font = Font(bold=True, color='FFFFFF')
for col_idx, col_name in enumerate(columns, 1):
cell = ws.cell(row=1, column=col_idx, value=col_name)
cell.fill = header_fill
cell.font = header_font
cell.alignment = Alignment(horizontal='center')
# Use column_keys for data access if provided, otherwise use columns
keys = column_keys if column_keys else columns
# Data rows
for row_idx, row_data in enumerate(data, 2):
for col_idx, key in enumerate(keys, 1):
value = row_data.get(key, '')
ws.cell(row=row_idx, column=col_idx, value=str(value) if value else '')
# Auto-adjust column widths
for col_idx, col_name in enumerate(columns, 1):
max_length = len(str(col_name))
for row in ws.iter_rows(min_row=2, max_row=ws.max_row, min_col=col_idx, max_col=col_idx):
for cell in row:
if cell.value:
max_length = max(max_length, len(str(cell.value)))
ws.column_dimensions[get_column_letter(col_idx)].width = min(max_length + 2, 50)
# Create response
response = HttpResponse(
content_type='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
)
response['Content-Disposition'] = f'attachment; filename="{filename}.xlsx"'
wb.save(response)
return response
@classmethod
def export_to_pdf(cls, data, columns, column_keys=None, title='Report', filename='report'):
"""Export report data to PDF.
Args:
data: List of row dicts
columns: List of column labels (for header row)
column_keys: List of column keys (for data access). If None, uses columns.
title: Report title
filename: Output filename without extension
"""
from django.template.loader import render_to_string
# Use column_keys for data access if provided, otherwise use columns
keys = column_keys if column_keys else columns
# Prepare data with proper column access
formatted_data = []
for row in data:
formatted_row = {col: row.get(key, '') for col, key in zip(columns, keys)}
formatted_data.append(formatted_row)
try:
from weasyprint import HTML
html_content = render_to_string('reports/report_pdf.html', {
'title': title,
'columns': columns,
'data': formatted_data,
'generated_at': timezone.now(),
})
response = HttpResponse(content_type='application/pdf')
response['Content-Disposition'] = f'attachment; filename="{filename}.pdf"'
HTML(string=html_content).write_pdf(response)
return response
except ImportError:
# Fall back to CSV if weasyprint not available
return cls.export_to_csv(data, columns, column_keys, filename)