610 lines
29 KiB
Python
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)
|