HH/apps/analytics/services/export_service.py
2026-01-05 19:40:24 +03:00

574 lines
23 KiB
Python

"""
Export Service for Command Center Dashboard
Provides functionality to export dashboard data to Excel and PDF formats.
"""
from datetime import date, datetime
from typing import Dict, Any, List, Optional
import io
import base64
from django.conf import settings
from django.http import HttpResponse
from django.utils import timezone
import json
class ExportService:
"""
Service for exporting dashboard data to Excel and PDF.
"""
@staticmethod
def export_to_excel(
data: Dict[str, Any],
filename: str = None,
include_charts: bool = True
) -> HttpResponse:
"""
Export dashboard data to Excel format.
Args:
data: Dashboard data containing KPIs, charts, and tables
filename: Optional custom filename (without extension)
include_charts: Whether to include chart data
Returns:
HttpResponse with Excel file
"""
try:
import openpyxl
from openpyxl.styles import Font, PatternFill, Alignment, Border, Side
from openpyxl.utils import get_column_letter
except ImportError:
raise ImportError("openpyxl is required for Excel export. Install with: pip install openpyxl")
if not filename:
filename = f"px360_dashboard_{timezone.now().strftime('%Y%m%d_%H%M%S')}"
# Create workbook
wb = openpyxl.Workbook()
ws = wb.active
ws.title = "Dashboard Summary"
# Styles
header_font = Font(bold=True, size=12, color="FFFFFF")
header_fill = PatternFill(start_color="4472C4", end_color="4472C4", fill_type="solid")
title_font = Font(bold=True, size=14, color="000000")
border = Border(
left=Side(style='thin'),
right=Side(style='thin'),
top=Side(style='thin'),
bottom=Side(style='thin')
)
# Title
ws['A1'] = "PX360 Dashboard Export"
ws['A1'].font = title_font
ws['A2'] = f"Generated: {timezone.now().strftime('%Y-%m-%d %H:%M:%S')}"
ws['A2'].font = Font(italic=True, color="666666")
row = 4
# KPIs Section
if 'kpis' in data:
ws[f'A{row}'] = "Key Performance Indicators"
ws[f'A{row}'].font = title_font
row += 2
# Header
headers = ['KPI', 'Value', 'Description']
for col, header in enumerate(headers, 1):
cell = ws.cell(row=row, column=col, value=header)
cell.font = header_font
cell.fill = header_fill
cell.border = border
row += 1
# KPI Data
kpi_descriptions = {
'total_complaints': 'Total number of complaints',
'open_complaints': 'Currently open complaints',
'overdue_complaints': 'Complaints past due date',
'high_severity_complaints': 'High and critical severity complaints',
'resolved_complaints': 'Complaints marked as resolved or closed',
'total_actions': 'Total PX actions created',
'open_actions': 'Currently open PX actions',
'overdue_actions': 'PX actions past due date',
'escalated_actions': 'Actions that have been escalated',
'resolved_actions': 'Actions marked as completed',
'total_surveys': 'Total completed surveys',
'negative_surveys': 'Surveys with negative sentiment',
'avg_survey_score': 'Average survey score (out of 5)',
'negative_social_mentions': 'Negative social media mentions',
'low_call_ratings': 'Low call center ratings',
'total_sentiment_analyses': 'Total sentiment analyses performed',
}
for kpi_key, kpi_value in data['kpis'].items():
if kpi_key == 'complaints_trend':
continue
ws.cell(row=row, column=1, value=kpi_key.replace('_', ' ').title())
ws.cell(row=row, column=2, value=kpi_value)
ws.cell(row=row, column=3, value=kpi_descriptions.get(kpi_key, ''))
for col in range(1, 4):
ws.cell(row=row, column=col).border = border
row += 1
row += 2
# Charts Section
if include_charts and 'charts' in data:
ws[f'A{row}'] = "Chart Data"
ws[f'A{row}'].font = title_font
row += 2
for chart_name, chart_data in data['charts'].items():
ws[f'A{row}'] = f"Chart: {chart_name}"
ws[f'A{row}'].font = Font(bold=True, size=11)
row += 1
# Chart metadata
if 'type' in chart_data:
ws[f'A{row}'] = f"Type: {chart_data['type']}"
row += 1
# Labels
if 'labels' in chart_data:
ws[f'A{row}'] = "Labels"
ws[f'A{row}'].font = header_font
ws[f'A{row}'].fill = header_fill
for i, label in enumerate(chart_data['labels'], row + 1):
ws.cell(row=i, column=1, value=label)
ws.cell(row=i, column=1).border = border
row = row + len(chart_data['labels']) + 1
# Series Data
if 'series' in chart_data:
ws[f'A{row}'] = "Series"
ws[f'A{row}'].font = header_font
ws[f'A{row}'].fill = header_fill
series_list = chart_data['series']
if isinstance(series_list, list):
for series in series_list:
row += 1
if isinstance(series, dict):
series_name = series.get('name', 'Series')
series_data = series.get('data', [])
else:
series_name = 'Series'
series_data = []
ws.cell(row=row, column=1, value=series_name)
ws.cell(row=row, column=1).font = Font(bold=True)
if isinstance(series_data, list) and series_data:
for i, value in enumerate(series_data, row + 1):
ws.cell(row=i, column=1, value=value)
ws.cell(row=i, column=1).border = border
row = row + len(series_data) + 1
row += 2
# Tables Section
if 'tables' in data:
# Create new sheet for tables
ws_tables = wb.create_sheet("Tables")
ws_tables_row = 1
for table_name, table_data in data['tables'].items():
ws_tables[f'A{ws_tables_row}'] = table_name
ws_tables[f'A{ws_tables_row}'].font = title_font
ws_tables_row += 2
# Headers
if 'headers' in table_data and 'rows' in table_data:
headers = table_data['headers']
for col, header in enumerate(headers, 1):
cell = ws_tables.cell(row=ws_tables_row, column=col, value=header)
cell.font = header_font
cell.fill = header_fill
cell.border = border
ws_tables_row += 1
# Data rows
for table_row in table_data['rows']:
for col, value in enumerate(table_row, 1):
# Convert UUID to string if needed
if isinstance(value, str):
pass # Already a string
elif hasattr(value, '__class__') and value.__class__.__name__ == 'UUID':
value = str(value)
# Strip timezone from datetime objects
elif isinstance(value, datetime):
if value.tzinfo is not None:
value = timezone.make_naive(value)
cell = ws_tables.cell(row=ws_tables_row, column=col, value=value)
cell.border = border
ws_tables_row += 1
ws_tables_row += 2
# Adjust column widths
for sheet in wb.worksheets:
for column in sheet.columns:
max_length = 0
column_letter = get_column_letter(column[0].column)
for cell in column:
try:
if len(str(cell.value)) > max_length:
max_length = len(str(cell.value))
except:
pass
adjusted_width = min(max_length + 2, 50)
sheet.column_dimensions[column_letter].width = adjusted_width
# Prepare response
response = HttpResponse(
content_type='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
)
response['Content-Disposition'] = f'attachment; filename="{filename}.xlsx"'
# Save workbook to response
output = io.BytesIO()
wb.save(output)
response.write(output.getvalue())
return response
@staticmethod
def _generate_chart_image(chart_name: str, chart_data: Dict[str, Any]) -> Optional[bytes]:
"""
Generate a chart image from chart data using Matplotlib.
Args:
chart_name: Name of the chart
chart_data: Chart data dictionary
Returns:
bytes: PNG image data or None
"""
try:
import matplotlib
matplotlib.use('Agg') # Use non-interactive backend
import matplotlib.pyplot as plt
import numpy as np
except ImportError:
return None
chart_type = chart_data.get('type', 'bar')
labels = chart_data.get('labels', [])
series_list = chart_data.get('series', [])
if not labels or not series_list:
return None
# Create figure
fig, ax = plt.subplots(figsize=(10, 6))
fig.patch.set_facecolor('white')
# Extract data from series
if isinstance(series_list, list) and len(series_list) > 0:
series = series_list[0]
if isinstance(series, dict):
data = series.get('data', [])
series_name = series.get('name', 'Value')
else:
data = series_list
series_name = 'Value'
else:
data = []
series_name = 'Value'
# Generate chart based on type
if chart_type == 'line':
ax.plot(labels, data, marker='o', linewidth=2, markersize=6, color='#4472C4')
ax.set_xlabel('Date', fontsize=10)
ax.set_ylabel(series_name, fontsize=10)
ax.tick_params(axis='x', rotation=45, labelsize=8)
elif chart_type == 'bar':
colors = ['#4472C4', '#ED7D31', '#A5A5A5', '#FFC000', '#5B9BD5', '#70AD47']
bars = ax.bar(labels, data, color=colors[:len(labels)], edgecolor='black', linewidth=0.5)
ax.set_ylabel(series_name, fontsize=10)
ax.tick_params(axis='x', rotation=45, labelsize=8)
# Add value labels on bars
for bar in bars:
height = bar.get_height()
ax.text(bar.get_x() + bar.get_width()/2., height,
f'{height:.2f}' if isinstance(height, float) else f'{int(height)}',
ha='center', va='bottom', fontsize=8)
elif chart_type in ['donut', 'pie']:
colors = ['#4472C4', '#ED7D31', '#A5A5A5', '#FFC000', '#5B9BD5', '#70AD47']
wedges, texts, autotexts = ax.pie(data, labels=labels, autopct='%1.1f%%',
colors=colors[:len(labels)],
startangle=90)
if chart_type == 'donut':
centre_circle = plt.Circle((0, 0), 0.70, fc='white')
fig.gca().add_artist(centre_circle)
# Improve text readability
for text in texts:
text.set_fontsize(9)
for autotext in autotexts:
autotext.set_color('white')
autotext.set_fontsize(9)
autotext.set_weight('bold')
# Set title
ax.set_title(chart_name.replace('_', ' ').title(), fontsize=12, fontweight='bold', pad=20)
# Tight layout
plt.tight_layout()
# Save to bytes
img_buffer = io.BytesIO()
plt.savefig(img_buffer, format='png', dpi=150, bbox_inches='tight')
plt.close()
return img_buffer.getvalue()
@staticmethod
def export_to_pdf(
data: Dict[str, Any],
filename: str = None,
title: str = "PX360 Dashboard Report"
) -> HttpResponse:
"""
Export dashboard data to PDF format.
Args:
data: Dashboard data containing KPIs, charts, and tables
filename: Optional custom filename (without extension)
title: Custom report title
Returns:
HttpResponse with PDF file
"""
try:
from reportlab.lib import colors
from reportlab.lib.pagesizes import letter, A4
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
from reportlab.lib.units import inch
from reportlab.platypus import SimpleDocTemplate, Table, TableStyle, Paragraph, Spacer, PageBreak, Image
from reportlab.lib.enums import TA_CENTER, TA_LEFT
except ImportError:
raise ImportError("reportlab is required for PDF export. Install with: pip install reportlab")
if not filename:
filename = f"px360_dashboard_{timezone.now().strftime('%Y%m%d_%H%M%S')}"
# Create PDF buffer
buffer = io.BytesIO()
doc = SimpleDocTemplate(
buffer,
pagesize=A4,
rightMargin=72,
leftMargin=72,
topMargin=72,
bottomMargin=18
)
# Styles
styles = getSampleStyleSheet()
title_style = ParagraphStyle(
'CustomTitle',
parent=styles['Heading1'],
fontSize=24,
spaceAfter=30,
textColor=colors.HexColor('#4472C4'),
alignment=TA_CENTER
)
heading_style = ParagraphStyle(
'CustomHeading',
parent=styles['Heading2'],
fontSize=16,
spaceAfter=12,
spaceBefore=20,
textColor=colors.HexColor('#333333')
)
normal_style = ParagraphStyle(
'CustomNormal',
parent=styles['Normal'],
fontSize=10,
spaceAfter=6
)
# Build content
story = []
# Title
story.append(Paragraph(title, title_style))
story.append(Paragraph(
f"Generated: {timezone.now().strftime('%Y-%m-%d %H:%M:%S')}",
styles['Normal']
))
story.append(Spacer(1, 12))
# KPIs Section
if 'kpis' in data:
story.append(Paragraph("Key Performance Indicators", heading_style))
kpi_data = []
kpi_descriptions = {
'total_complaints': 'Total number of complaints',
'open_complaints': 'Currently open complaints',
'overdue_complaints': 'Complaints past due date',
'high_severity_complaints': 'High and critical severity complaints',
'resolved_complaints': 'Complaints marked as resolved or closed',
'total_actions': 'Total PX actions created',
'open_actions': 'Currently open PX actions',
'overdue_actions': 'PX actions past due date',
'escalated_actions': 'Actions that have been escalated',
'resolved_actions': 'Actions marked as completed',
'total_surveys': 'Total completed surveys',
'negative_surveys': 'Surveys with negative sentiment',
'avg_survey_score': 'Average survey score (out of 5)',
'negative_social_mentions': 'Negative social media mentions',
'low_call_ratings': 'Low call center ratings',
'total_sentiment_analyses': 'Total sentiment analyses performed',
}
for kpi_key, kpi_value in data['kpis'].items():
if kpi_key == 'complaints_trend':
continue
kpi_name = kpi_key.replace('_', ' ').title()
kpi_desc = kpi_descriptions.get(kpi_key, '')
kpi_data.append([kpi_name, str(kpi_value), kpi_desc])
# Create KPI table
kpi_table = Table(kpi_data, colWidths=[2.5*inch, 1*inch, 2.5*inch])
kpi_table.setStyle(TableStyle([
('BACKGROUND', (0, 0), (-1, 0), colors.HexColor('#4472C4')),
('TEXTCOLOR', (0, 0), (-1, 0), colors.whitesmoke),
('ALIGN', (0, 0), (-1, -1), 'LEFT'),
('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'),
('FONTSIZE', (0, 0), (-1, 0), 10),
('BOTTOMPADDING', (0, 0), (-1, 0), 12),
('BACKGROUND', (0, 1), (-1, -1), colors.beige),
('GRID', (0, 0), (-1, -1), 1, colors.black),
('ROWBACKGROUNDS', (0, 1), (-1, -1), [colors.white, colors.lightgrey]),
]))
story.append(kpi_table)
story.append(Spacer(1, 12))
# Charts Section (with actual chart images)
if 'charts' in data:
story.append(PageBreak())
story.append(Paragraph("Charts", heading_style))
story.append(Spacer(1, 12))
for chart_name, chart_data in data['charts'].items():
# Add chart title
chart_title = chart_name.replace('_', ' ').title()
story.append(Paragraph(f"<b>{chart_title}</b>", normal_style))
story.append(Spacer(1, 6))
# Generate and add chart image
img_data = ExportService._generate_chart_image(chart_name, chart_data)
if img_data:
try:
# Create image from bytes
img_buffer = io.BytesIO(img_data)
img = Image(img_buffer, width=6*inch, height=3.6*inch)
story.append(img)
story.append(Spacer(1, 12))
except Exception as e:
# Fallback to text representation if image fails
story.append(Paragraph("Chart Type: " + chart_data.get('type', 'Unknown'), normal_style))
if 'labels' in chart_data:
labels_str = ", ".join(str(l) for l in chart_data['labels'][:10])
if len(chart_data['labels']) > 10:
labels_str += f" ... ({len(chart_data['labels'])} total)"
story.append(Paragraph(f"Labels: {labels_str}", normal_style))
story.append(Spacer(1, 6))
else:
# Fallback to text representation if image generation fails
story.append(Paragraph("Chart Type: " + chart_data.get('type', 'Unknown'), normal_style))
if 'labels' in chart_data:
labels_str = ", ".join(str(l) for l in chart_data['labels'][:10])
if len(chart_data['labels']) > 10:
labels_str += f" ... ({len(chart_data['labels'])} total)"
story.append(Paragraph(f"Labels: {labels_str}", normal_style))
story.append(Spacer(1, 12))
# Tables Section
if 'tables' in data:
story.append(PageBreak())
story.append(Paragraph("Detailed Tables", heading_style))
for table_name, table_data in data['tables'].items():
story.append(Paragraph(f"<b>{table_name}</b>", normal_style))
story.append(Spacer(1, 6))
if 'headers' in table_data and 'rows' in table_data:
table_rows = [table_data['headers']] + table_data['rows']
table = Table(table_rows)
table.setStyle(TableStyle([
('BACKGROUND', (0, 0), (-1, 0), colors.HexColor('#4472C4')),
('TEXTCOLOR', (0, 0), (-1, 0), colors.whitesmoke),
('ALIGN', (0, 0), (-1, -1), 'LEFT'),
('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'),
('FONTSIZE', (0, 0), (-1, 0), 9),
('GRID', (0, 0), (-1, -1), 1, colors.black),
('ROWBACKGROUNDS', (0, 1), (-1, -1), [colors.white, colors.lightgrey]),
]))
story.append(table)
story.append(Spacer(1, 12))
# Build PDF
doc.build(story)
# Prepare response
response = HttpResponse(content_type='application/pdf')
response['Content-Disposition'] = f'attachment; filename="{filename}.pdf"'
response.write(buffer.getvalue())
return response
@staticmethod
def prepare_dashboard_data(
user,
kpis: Dict[str, Any],
charts: Dict[str, Any],
tables: Dict[str, Any]
) -> Dict[str, Any]:
"""
Prepare dashboard data for export by consolidating all components.
Args:
user: Current user
kpis: KPI data
charts: Chart data dictionary
tables: Tables data dictionary
Returns:
dict: Consolidated export data
"""
export_data = {
'metadata': {
'generated_at': timezone.now().isoformat(),
'user': user.get_full_name() or user.username,
'user_role': user.get_role_display() if hasattr(user, 'get_role_display') else 'Unknown',
},
'kpis': kpis,
'charts': charts,
'tables': tables
}
return export_data