Spaces:
Sleeping
Sleeping
thadillo
Fix HuggingFace deployment errors: database locking, matplotlib permissions, and deprecation warnings
e60b22c
| """ | |
| PDF export utility for dashboard data | |
| Generates PDF reports matching the Analytics Dashboard exactly | |
| """ | |
| import io | |
| import os | |
| from datetime import datetime | |
| from reportlab.lib import colors | |
| from reportlab.lib.pagesizes import letter | |
| 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 | |
| # Set matplotlib config directory before import (prevent permission errors on HuggingFace) | |
| os.environ.setdefault('MPLCONFIGDIR', '/tmp/matplotlib') | |
| import matplotlib | |
| matplotlib.use('Agg') | |
| import matplotlib.pyplot as plt | |
| try: | |
| import contextily as cx | |
| HAS_CONTEXTILY = True | |
| except ImportError: | |
| HAS_CONTEXTILY = False | |
| class DashboardPDFExporter: | |
| """Export dashboard data to PDF matching the Analytics Dashboard""" | |
| CATEGORY_COLORS = { | |
| 'Vision': '#3b82f6', 'Problem': '#ef4444', 'Objectives': '#10b981', | |
| 'Directives': '#f59e0b', 'Values': '#8b5cf6', 'Actions': '#ec4899' | |
| } | |
| def __init__(self, pagesize=letter): | |
| self.pagesize = pagesize | |
| self.styles = getSampleStyleSheet() | |
| self._setup_custom_styles() | |
| def _setup_custom_styles(self): | |
| self.styles.add(ParagraphStyle( | |
| name='CustomTitle', parent=self.styles['Heading1'], fontSize=20, | |
| textColor=colors.HexColor('#2c3e50'), spaceAfter=20, alignment=TA_CENTER | |
| )) | |
| self.styles.add(ParagraphStyle( | |
| name='SectionHeader', parent=self.styles['Heading2'], fontSize=14, | |
| textColor=colors.HexColor('#34495e'), spaceAfter=10, spaceBefore=15 | |
| )) | |
| self.styles.add(ParagraphStyle( | |
| name='CategoryHeader', fontSize=12, textColor=colors.HexColor('#2c3e50'), | |
| spaceAfter=8, spaceBefore=10, fontName='Helvetica-Bold' | |
| )) | |
| self.styles.add(ParagraphStyle( | |
| name='SubmissionText', fontSize=10, textColor=colors.HexColor('#2c3e50'), | |
| spaceAfter=4, leftIndent=15 | |
| )) | |
| self.styles.add(ParagraphStyle( | |
| name='SubmissionMeta', fontSize=8, textColor=colors.HexColor('#7f8c8d'), | |
| spaceAfter=2, leftIndent=15 | |
| )) | |
| def generate_pdf(self, buffer, data): | |
| doc = SimpleDocTemplate(buffer, pagesize=self.pagesize, | |
| rightMargin=50, leftMargin=50, topMargin=50, bottomMargin=30) | |
| story = [] | |
| # Title | |
| story.append(Paragraph("Participatory Planning - Analytics Dashboard", self.styles['CustomTitle'])) | |
| view_mode_label = "Sentence-Level Analysis" if data['view_mode'] == 'sentences' else "Submission-Level Analysis" | |
| story.append(Paragraph( | |
| f"<font size=9>Generated: {datetime.now().strftime('%B %d, %Y at %H:%M')}<br/>" | |
| f"Analysis Mode: <b>{view_mode_label}</b></font>", self.styles['Normal'] | |
| )) | |
| story.append(Spacer(1, 20)) | |
| # Charts | |
| story.append(Paragraph("Distribution Overview", self.styles['SectionHeader'])) | |
| charts = self._create_charts_side_by_side(data) | |
| if charts: | |
| story.append(charts) | |
| story.append(Spacer(1, 15)) | |
| # Map | |
| if data['geotagged_submissions']: | |
| story.append(Paragraph(f"Geographic Distribution ({len(data['geotagged_submissions'])} geotagged)", self.styles['SectionHeader'])) | |
| map_image = self._create_map(data['geotagged_submissions']) | |
| if map_image: | |
| story.append(map_image) | |
| story.append(Spacer(1, 15)) | |
| # Contributions list | |
| story.append(PageBreak()) | |
| story.append(Paragraph("Contributions by Category", self.styles['SectionHeader'])) | |
| story.extend(self._create_contributions_list(data)) | |
| # Breakdown table | |
| story.append(PageBreak()) | |
| story.append(Paragraph("Category Breakdown by Contributor Type", self.styles['SectionHeader'])) | |
| story.append(self._create_breakdown_table(data['breakdown'], data['contributor_types'])) | |
| doc.build(story) | |
| return buffer | |
| def _create_charts_side_by_side(self, data): | |
| try: | |
| fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(10, 4)) | |
| # Contributor pie | |
| contributor_labels = [ctype for ctype, _ in data['contributor_stats']] | |
| contributor_values = [count for _, count in data['contributor_stats']] | |
| colors1 = ['#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899'] | |
| ax1.pie(contributor_values, labels=contributor_labels, autopct='%1.1f%%', | |
| colors=colors1[:len(contributor_labels)], startangle=90) | |
| ax1.set_title('By Contributor Type', fontsize=11, fontweight='bold') | |
| # Category bar | |
| category_labels = [cat for cat, _ in data['category_stats']] | |
| category_values = [count for _, count in data['category_stats']] | |
| category_colors = [self.CATEGORY_COLORS.get(cat, '#95a5a6') for cat in category_labels] | |
| bars = ax2.bar(range(len(category_labels)), category_values, color=category_colors, edgecolor='#2c3e50') | |
| ax2.set_xticks(range(len(category_labels))) | |
| ax2.set_xticklabels(category_labels, rotation=45, ha='right', fontsize=9) | |
| ax2.set_ylabel('Count', fontsize=10) | |
| ax2.set_title('By Category', fontsize=11, fontweight='bold') | |
| ax2.grid(axis='y', alpha=0.3) | |
| for bar in bars: | |
| height = bar.get_height() | |
| ax2.text(bar.get_x() + bar.get_width()/2., height, f'{int(height)}', ha='center', va='bottom', fontsize=9) | |
| plt.tight_layout() | |
| img_buffer = io.BytesIO() | |
| plt.savefig(img_buffer, format='png', dpi=150, bbox_inches='tight') | |
| plt.close(fig) | |
| img_buffer.seek(0) | |
| return Image(img_buffer, width=7*inch, height=2.8*inch) | |
| except Exception as e: | |
| print(f"Error creating charts: {e}") | |
| return None | |
| def _create_contributions_list(self, data): | |
| elements = [] | |
| submissions_by_category = {} | |
| for sub in data['submissions']: | |
| cat = sub.category | |
| if cat not in submissions_by_category: | |
| submissions_by_category[cat] = [] | |
| submissions_by_category[cat].append(sub) | |
| for category in data['categories']: | |
| if category not in submissions_by_category: | |
| continue | |
| category_subs = submissions_by_category[category] | |
| if not category_subs: | |
| continue | |
| color = self.CATEGORY_COLORS.get(category, '#95a5a6') | |
| count_text = f"({len(category_subs)} contribution{'s' if len(category_subs) != 1 else ''})" | |
| header_text = f'<font color="{color}"><b>{category}</b></font> <font size=9 color="#7f8c8d">{count_text}</font>' | |
| elements.append(Paragraph(header_text, self.styles['CategoryHeader'])) | |
| elements.append(Spacer(1, 5)) | |
| for sub in category_subs: | |
| meta_text = f'<font color="#7f8c8d"><i>{sub.contributor_type.title()}</i></font>' | |
| if sub.timestamp: | |
| meta_text += f' <font color="#95a5a6">• {sub.timestamp.strftime("%Y-%m-%d")}</font>' | |
| elements.append(Paragraph(meta_text, self.styles['SubmissionMeta'])) | |
| text = sub.message.replace('<', '<').replace('>', '>').replace('&', '&') | |
| elements.append(Paragraph(text, self.styles['SubmissionText'])) | |
| if sub.latitude and sub.longitude: | |
| loc_text = f'<font color="#95a5a6" size=8>📍 {sub.latitude:.4f}, {sub.longitude:.4f}</font>' | |
| elements.append(Paragraph(loc_text, self.styles['SubmissionMeta'])) | |
| elements.append(Spacer(1, 8)) | |
| elements.append(Spacer(1, 10)) | |
| return elements | |
| def _create_breakdown_table(self, breakdown, contributor_types): | |
| headers = ['Category'] + [ct['label'] for ct in contributor_types] | |
| data = [headers] | |
| for category, counts in breakdown.items(): | |
| row = [category] | |
| for ct in contributor_types: | |
| count = counts.get(ct['value'], 0) | |
| row.append(str(count) if count > 0 else '-') | |
| data.append(row) | |
| num_cols = len(headers) | |
| col_width = 7 * inch / num_cols | |
| table = Table(data, colWidths=[col_width] * num_cols) | |
| table.setStyle(TableStyle([ | |
| ('BACKGROUND', (0, 0), (-1, 0), colors.HexColor('#3498db')), | |
| ('TEXTCOLOR', (0, 0), (-1, 0), colors.whitesmoke), | |
| ('ALIGN', (0, 0), (-1, -1), 'CENTER'), | |
| ('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'), | |
| ('FONTSIZE', (0, 0), (-1, -1), 9), | |
| ('BOTTOMPADDING', (0, 0), (-1, 0), 10), | |
| ('TOPPADDING', (0, 0), (-1, 0), 10), | |
| ('GRID', (0, 0), (-1, -1), 0.5, colors.grey), | |
| ('ROWBACKGROUNDS', (0, 1), (-1, -1), [colors.white, colors.HexColor('#f8f9fa')]) | |
| ])) | |
| return table | |
| def _create_map(self, geotagged_submissions): | |
| if not geotagged_submissions: | |
| return None | |
| try: | |
| lats = [s.latitude for s in geotagged_submissions] | |
| lons = [s.longitude for s in geotagged_submissions] | |
| cats = [s.category for s in geotagged_submissions] | |
| fig, ax = plt.subplots(figsize=(10, 6)) | |
| for category in set(cats): | |
| cat_lats = [lat for lat, cat in zip(lats, cats) if cat == category] | |
| cat_lons = [lon for lon, cat in zip(lons, cats) if cat == category] | |
| color = self.CATEGORY_COLORS.get(category, '#95a5a6') | |
| ax.scatter(cat_lons, cat_lats, c=color, label=category, | |
| s=150, alpha=0.8, edgecolors='white', linewidths=2.5, zorder=5) | |
| if HAS_CONTEXTILY: | |
| try: | |
| cx.add_basemap(ax, crs='EPSG:4326', source=cx.providers.OpenStreetMap.Mapnik, | |
| attribution=False, alpha=0.8, zoom='auto') | |
| except Exception as e: | |
| print(f"Could not add basemap: {e}") | |
| ax.grid(True, alpha=0.3, linestyle='--') | |
| else: | |
| ax.grid(True, alpha=0.3, linestyle='--') | |
| ax.set_xlabel('Longitude', fontsize=11, fontweight='bold') | |
| ax.set_ylabel('Latitude', fontsize=11, fontweight='bold') | |
| ax.legend(loc='upper left', bbox_to_anchor=(1.02, 1), fontsize=9, | |
| frameon=True, fancybox=True, shadow=True) | |
| if HAS_CONTEXTILY: | |
| fig.text(0.99, 0.01, '© OpenStreetMap contributors', | |
| ha='right', va='bottom', fontsize=7, style='italic', alpha=0.7) | |
| img_buffer = io.BytesIO() | |
| plt.tight_layout() | |
| plt.savefig(img_buffer, format='png', dpi=200, bbox_inches='tight') | |
| plt.close(fig) | |
| img_buffer.seek(0) | |
| return Image(img_buffer, width=7*inch, height=4.5*inch) | |
| except Exception as e: | |
| print(f"Error creating map: {e}") | |
| import traceback | |
| traceback.print_exc() | |
| return None | |