""" 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"{chart_title}", 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"{table_name}", 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