574 lines
23 KiB
Python
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
|