thadillo Claude commited on
Commit
dfe9748
·
1 Parent(s): b08ba59

Add PDF export for dashboard with charts and maps

Browse files

Features:
- Export dashboard data as professional PDF report
- Includes summary statistics
- Category distribution pie chart
- Contributor type bar chart
- Category breakdown table
- Geographic distribution map
- Supports both submission-level and sentence-level modes
- Export button in dashboard UI

Dependencies added:
- reportlab (PDF generation)
- plotly (charts)
- kaleido (chart rendering)

Files added:
- app/utils/pdf_export.py (PDF generator)
- app/utils/__init__.py

Files updated:
- requirements.txt
- app/routes/admin.py (export endpoint)
- app/templates/admin/dashboard.html (export button)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

app/routes/admin.py CHANGED
@@ -2,6 +2,7 @@ from flask import Blueprint, render_template, request, redirect, url_for, sessio
2
  from app.models.models import Token, Submission, Settings, TrainingExample, FineTuningRun, SubmissionSentence
3
  from app import db
4
  from app.analyzer import get_analyzer
 
5
  from functools import wraps
6
  from typing import Dict
7
  import json
@@ -180,6 +181,99 @@ def dashboard():
180
  breakdown=breakdown,
181
  view_mode=view_mode)
182
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
183
  # API Endpoints
184
 
185
  @bp.route('/api/toggle-submissions', methods=['POST'])
 
2
  from app.models.models import Token, Submission, Settings, TrainingExample, FineTuningRun, SubmissionSentence
3
  from app import db
4
  from app.analyzer import get_analyzer
5
+ from app.utils.pdf_export import DashboardPDFExporter
6
  from functools import wraps
7
  from typing import Dict
8
  import json
 
181
  breakdown=breakdown,
182
  view_mode=view_mode)
183
 
184
+ @bp.route('/dashboard/export-pdf')
185
+ @admin_required
186
+ def export_dashboard_pdf():
187
+ """Export dashboard data as PDF"""
188
+ try:
189
+ # Get view mode
190
+ view_mode = request.args.get('mode', 'submissions')
191
+
192
+ # Gather same data as dashboard
193
+ submissions = Submission.query.filter(Submission.category != None).all()
194
+
195
+ # Contributor stats
196
+ contributor_stats = db.session.query(
197
+ Submission.contributor_type,
198
+ db.func.count(Submission.id)
199
+ ).group_by(Submission.contributor_type).all()
200
+
201
+ # Category stats - mode dependent
202
+ if view_mode == 'sentences':
203
+ category_stats = db.session.query(
204
+ SubmissionSentence.category,
205
+ db.func.count(SubmissionSentence.id)
206
+ ).filter(SubmissionSentence.category != None).group_by(SubmissionSentence.category).all()
207
+
208
+ # Breakdown by contributor
209
+ breakdown = {}
210
+ for cat in CATEGORIES:
211
+ breakdown[cat] = {}
212
+ for ctype in CONTRIBUTOR_TYPES:
213
+ count = db.session.query(db.func.count(SubmissionSentence.id)).join(
214
+ Submission
215
+ ).filter(
216
+ SubmissionSentence.category == cat,
217
+ Submission.contributor_type == ctype['value']
218
+ ).scalar()
219
+ breakdown[cat][ctype['value']] = count
220
+ else:
221
+ category_stats = db.session.query(
222
+ Submission.category,
223
+ db.func.count(Submission.id)
224
+ ).filter(Submission.category != None).group_by(Submission.category).all()
225
+
226
+ breakdown = {}
227
+ for cat in CATEGORIES:
228
+ breakdown[cat] = {}
229
+ for ctype in CONTRIBUTOR_TYPES:
230
+ count = Submission.query.filter_by(
231
+ category=cat,
232
+ contributor_type=ctype['value']
233
+ ).count()
234
+ breakdown[cat][ctype['value']] = count
235
+
236
+ # Geotagged submissions
237
+ geotagged_submissions = Submission.query.filter(
238
+ Submission.latitude != None,
239
+ Submission.longitude != None,
240
+ Submission.category != None
241
+ ).all()
242
+
243
+ # Prepare data for PDF
244
+ pdf_data = {
245
+ 'submissions': submissions,
246
+ 'category_stats': category_stats,
247
+ 'contributor_stats': contributor_stats,
248
+ 'breakdown': breakdown,
249
+ 'geotagged_submissions': geotagged_submissions,
250
+ 'view_mode': view_mode,
251
+ 'categories': CATEGORIES,
252
+ 'contributor_types': CONTRIBUTOR_TYPES
253
+ }
254
+
255
+ # Generate PDF
256
+ buffer = io.BytesIO()
257
+ exporter = DashboardPDFExporter()
258
+ exporter.generate_pdf(buffer, pdf_data)
259
+ buffer.seek(0)
260
+
261
+ # Generate filename
262
+ mode_label = "sentence" if view_mode == 'sentences' else "submission"
263
+ filename = f"dashboard_{mode_label}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.pdf"
264
+
265
+ return send_file(
266
+ buffer,
267
+ mimetype='application/pdf',
268
+ as_attachment=True,
269
+ download_name=filename
270
+ )
271
+
272
+ except Exception as e:
273
+ logger.error(f"Error exporting dashboard PDF: {str(e)}")
274
+ flash(f'Error exporting PDF: {str(e)}', 'danger')
275
+ return redirect(url_for('admin.dashboard'))
276
+
277
  # API Endpoints
278
 
279
  @bp.route('/api/toggle-submissions', methods=['POST'])
app/templates/admin/dashboard.html CHANGED
@@ -15,21 +15,29 @@
15
  <div class="d-flex justify-content-between align-items-center mb-4">
16
  <h2>Analytics Dashboard</h2>
17
 
18
- <!-- View Mode Selector -->
19
- <div class="btn-group" role="group" aria-label="View mode">
20
- <input type="radio" class="btn-check" name="viewMode" id="viewSubmissions"
21
- {% if view_mode == 'submissions' %}checked{% endif %}
22
- onchange="window.location.href='{{ url_for('admin.dashboard', mode='submissions') }}'">
23
- <label class="btn btn-outline-primary" for="viewSubmissions">
24
- By Submissions
25
- </label>
26
-
27
- <input type="radio" class="btn-check" name="viewMode" id="viewSentences"
28
- {% if view_mode == 'sentences' %}checked{% endif %}
29
- onchange="window.location.href='{{ url_for('admin.dashboard', mode='sentences') }}'">
30
- <label class="btn btn-outline-primary" for="viewSentences">
31
- By Sentences
32
- </label>
 
 
 
 
 
 
 
 
33
  </div>
34
  </div>
35
 
 
15
  <div class="d-flex justify-content-between align-items-center mb-4">
16
  <h2>Analytics Dashboard</h2>
17
 
18
+ <div class="d-flex gap-3">
19
+ <!-- View Mode Selector -->
20
+ <div class="btn-group" role="group" aria-label="View mode">
21
+ <input type="radio" class="btn-check" name="viewMode" id="viewSubmissions"
22
+ {% if view_mode == 'submissions' %}checked{% endif %}
23
+ onchange="window.location.href='{{ url_for('admin.dashboard', mode='submissions') }}'">
24
+ <label class="btn btn-outline-primary" for="viewSubmissions">
25
+ By Submissions
26
+ </label>
27
+
28
+ <input type="radio" class="btn-check" name="viewMode" id="viewSentences"
29
+ {% if view_mode == 'sentences' %}checked{% endif %}
30
+ onchange="window.location.href='{{ url_for('admin.dashboard', mode='sentences') }}'">
31
+ <label class="btn btn-outline-primary" for="viewSentences">
32
+ By Sentences
33
+ </label>
34
+ </div>
35
+
36
+ <!-- Export PDF Button -->
37
+ <a href="{{ url_for('admin.export_dashboard_pdf', mode=view_mode) }}"
38
+ class="btn btn-success">
39
+ <i class="bi bi-file-earmark-pdf"></i> Export PDF
40
+ </a>
41
  </div>
42
  </div>
43
 
app/utils/pdf_export.py ADDED
@@ -0,0 +1,304 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ PDF export utility for dashboard data
3
+ Generates professional PDF reports with charts and maps
4
+ """
5
+ import io
6
+ from datetime import datetime
7
+ from reportlab.lib import colors
8
+ from reportlab.lib.pagesizes import letter, A4
9
+ from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
10
+ from reportlab.lib.units import inch
11
+ from reportlab.platypus import SimpleDocTemplate, Table, TableStyle, Paragraph, Spacer, PageBreak, Image
12
+ from reportlab.lib.enums import TA_CENTER, TA_LEFT, TA_RIGHT
13
+ import plotly.graph_objects as go
14
+ import plotly.express as px
15
+
16
+
17
+ class DashboardPDFExporter:
18
+ """Export dashboard data to PDF with charts and maps"""
19
+
20
+ def __init__(self, pagesize=letter):
21
+ self.pagesize = pagesize
22
+ self.styles = getSampleStyleSheet()
23
+ self._setup_custom_styles()
24
+
25
+ def _setup_custom_styles(self):
26
+ """Setup custom paragraph styles"""
27
+ self.styles.add(ParagraphStyle(
28
+ name='CustomTitle',
29
+ parent=self.styles['Heading1'],
30
+ fontSize=24,
31
+ textColor=colors.HexColor('#2c3e50'),
32
+ spaceAfter=30,
33
+ alignment=TA_CENTER
34
+ ))
35
+
36
+ self.styles.add(ParagraphStyle(
37
+ name='SectionHeader',
38
+ parent=self.styles['Heading2'],
39
+ fontSize=16,
40
+ textColor=colors.HexColor('#34495e'),
41
+ spaceAfter=12,
42
+ spaceBefore=12
43
+ ))
44
+
45
+ self.styles.add(ParagraphStyle(
46
+ name='MetricLabel',
47
+ fontSize=10,
48
+ textColor=colors.HexColor('#7f8c8d'),
49
+ spaceAfter=4
50
+ ))
51
+
52
+ self.styles.add(ParagraphStyle(
53
+ name='MetricValue',
54
+ fontSize=20,
55
+ textColor=colors.HexColor('#2c3e50'),
56
+ spaceAfter=12,
57
+ fontName='Helvetica-Bold'
58
+ ))
59
+
60
+ def generate_pdf(self, buffer, data):
61
+ """
62
+ Generate PDF report
63
+
64
+ Args:
65
+ buffer: BytesIO buffer to write PDF to
66
+ data: Dictionary containing dashboard data with keys:
67
+ - submissions: list of Submission objects
68
+ - category_stats: list of (category, count) tuples
69
+ - contributor_stats: list of (type, count) tuples
70
+ - breakdown: dict of {category: {contributor_type: count}}
71
+ - geotagged_submissions: list of geotagged Submission objects
72
+ - view_mode: 'submissions' or 'sentences'
73
+ - categories: list of category names
74
+ - contributor_types: list of contributor type dicts
75
+ """
76
+ doc = SimpleDocTemplate(buffer, pagesize=self.pagesize,
77
+ rightMargin=72, leftMargin=72,
78
+ topMargin=72, bottomMargin=18)
79
+
80
+ story = []
81
+
82
+ # Title
83
+ title = Paragraph("Participatory Planning Dashboard Report", self.styles['CustomTitle'])
84
+ story.append(title)
85
+ story.append(Spacer(1, 12))
86
+
87
+ # Metadata
88
+ view_mode_label = "Sentence-Level" if data['view_mode'] == 'sentences' else "Submission-Level"
89
+ metadata = Paragraph(
90
+ f"<font size=10>Generated: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}<br/>"
91
+ f"Analysis Mode: {view_mode_label}</font>",
92
+ self.styles['Normal']
93
+ )
94
+ story.append(metadata)
95
+ story.append(Spacer(1, 24))
96
+
97
+ # Summary Statistics
98
+ story.append(Paragraph("Summary Statistics", self.styles['SectionHeader']))
99
+ story.extend(self._create_summary_stats(data))
100
+ story.append(Spacer(1, 24))
101
+
102
+ # Category Distribution Chart
103
+ story.append(Paragraph("Category Distribution", self.styles['SectionHeader']))
104
+ category_chart = self._create_category_chart(data['category_stats'], data['categories'])
105
+ if category_chart:
106
+ story.append(category_chart)
107
+ story.append(Spacer(1, 24))
108
+
109
+ # Contributor Type Distribution
110
+ story.append(Paragraph("Contributor Type Distribution", self.styles['SectionHeader']))
111
+ contributor_chart = self._create_contributor_chart(data['contributor_stats'])
112
+ if contributor_chart:
113
+ story.append(contributor_chart)
114
+ story.append(PageBreak())
115
+
116
+ # Breakdown Table
117
+ story.append(Paragraph("Category Breakdown by Contributor Type", self.styles['SectionHeader']))
118
+ breakdown_table = self._create_breakdown_table(data['breakdown'], data['contributor_types'])
119
+ story.append(breakdown_table)
120
+ story.append(Spacer(1, 24))
121
+
122
+ # Map
123
+ if data['geotagged_submissions']:
124
+ story.append(PageBreak())
125
+ story.append(Paragraph("Geographic Distribution", self.styles['SectionHeader']))
126
+ map_image = self._create_map(data['geotagged_submissions'], data['categories'])
127
+ if map_image:
128
+ story.append(map_image)
129
+
130
+ # Build PDF
131
+ doc.build(story)
132
+
133
+ return buffer
134
+
135
+ def _create_summary_stats(self, data):
136
+ """Create summary statistics section"""
137
+ elements = []
138
+
139
+ total_items = sum(count for _, count in data['category_stats'])
140
+ total_submissions = len(data['submissions'])
141
+ total_geotagged = len(data['geotagged_submissions'])
142
+
143
+ # Create metrics table
144
+ metrics_data = [
145
+ ['Total Submissions', str(total_submissions)],
146
+ ['Total Items Analyzed', str(total_items)],
147
+ ['Geotagged Items', str(total_geotagged)],
148
+ ['Categories', str(len([c for c, count in data['category_stats'] if count > 0]))]
149
+ ]
150
+
151
+ metrics_table = Table(metrics_data, colWidths=[3*inch, 2*inch])
152
+ metrics_table.setStyle(TableStyle([
153
+ ('FONTNAME', (0, 0), (0, -1), 'Helvetica-Bold'),
154
+ ('FONTNAME', (1, 0), (1, -1), 'Helvetica'),
155
+ ('FONTSIZE', (0, 0), (-1, -1), 12),
156
+ ('TEXTCOLOR', (0, 0), (0, -1), colors.HexColor('#2c3e50')),
157
+ ('TEXTCOLOR', (1, 0), (1, -1), colors.HexColor('#3498db')),
158
+ ('ALIGN', (1, 0), (1, -1), 'RIGHT'),
159
+ ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
160
+ ('BOTTOMPADDING', (0, 0), (-1, -1), 12),
161
+ ]))
162
+
163
+ elements.append(metrics_table)
164
+
165
+ return elements
166
+
167
+ def _create_category_chart(self, category_stats, categories):
168
+ """Create category distribution pie chart"""
169
+ if not category_stats:
170
+ return None
171
+
172
+ # Prepare data
173
+ labels = [cat for cat, _ in category_stats]
174
+ values = [count for _, count in category_stats]
175
+
176
+ # Create plotly figure
177
+ fig = go.Figure(data=[go.Pie(
178
+ labels=labels,
179
+ values=values,
180
+ hole=0.3,
181
+ marker=dict(colors=['#3498db', '#2ecc71', '#f39c12', '#e74c3c', '#9b59b6', '#1abc9c'])
182
+ )])
183
+
184
+ fig.update_layout(
185
+ title='Category Distribution',
186
+ width=500,
187
+ height=400,
188
+ showlegend=True,
189
+ font=dict(size=10)
190
+ )
191
+
192
+ # Convert to image
193
+ img_bytes = fig.to_image(format="png")
194
+ img = Image(io.BytesIO(img_bytes), width=5*inch, height=4*inch)
195
+
196
+ return img
197
+
198
+ def _create_contributor_chart(self, contributor_stats):
199
+ """Create contributor type bar chart"""
200
+ if not contributor_stats:
201
+ return None
202
+
203
+ # Prepare data
204
+ types = [ctype for ctype, _ in contributor_stats]
205
+ counts = [count for _, count in contributor_stats]
206
+
207
+ # Create plotly figure
208
+ fig = go.Figure(data=[go.Bar(
209
+ x=types,
210
+ y=counts,
211
+ marker=dict(color='#3498db')
212
+ )])
213
+
214
+ fig.update_layout(
215
+ title='Submissions by Contributor Type',
216
+ xaxis_title='Contributor Type',
217
+ yaxis_title='Count',
218
+ width=500,
219
+ height=400,
220
+ font=dict(size=10)
221
+ )
222
+
223
+ # Convert to image
224
+ img_bytes = fig.to_image(format="png")
225
+ img = Image(io.BytesIO(img_bytes), width=5*inch, height=4*inch)
226
+
227
+ return img
228
+
229
+ def _create_breakdown_table(self, breakdown, contributor_types):
230
+ """Create category breakdown table"""
231
+ # Prepare table data
232
+ headers = ['Category'] + [ct['label'] for ct in contributor_types]
233
+ data = [headers]
234
+
235
+ for category, counts in breakdown.items():
236
+ row = [category]
237
+ for ct in contributor_types:
238
+ row.append(str(counts.get(ct['value'], 0)))
239
+ data.append(row)
240
+
241
+ # Calculate column widths
242
+ num_cols = len(headers)
243
+ col_width = 6.5 * inch / num_cols
244
+
245
+ table = Table(data, colWidths=[col_width] * num_cols)
246
+ table.setStyle(TableStyle([
247
+ ('BACKGROUND', (0, 0), (-1, 0), colors.HexColor('#3498db')),
248
+ ('TEXTCOLOR', (0, 0), (-1, 0), colors.whitesmoke),
249
+ ('ALIGN', (0, 0), (-1, -1), 'CENTER'),
250
+ ('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'),
251
+ ('FONTSIZE', (0, 0), (-1, -1), 10),
252
+ ('BOTTOMPADDING', (0, 0), (-1, 0), 12),
253
+ ('GRID', (0, 0), (-1, -1), 1, colors.grey),
254
+ ('ROWBACKGROUNDS', (0, 1), (-1, -1), [colors.white, colors.HexColor('#ecf0f1')])
255
+ ]))
256
+
257
+ return table
258
+
259
+ def _create_map(self, geotagged_submissions, categories):
260
+ """Create geographic distribution map"""
261
+ if not geotagged_submissions:
262
+ return None
263
+
264
+ # Prepare data
265
+ lats = [s.latitude for s in geotagged_submissions]
266
+ lons = [s.longitude for s in geotagged_submissions]
267
+ cats = [s.category for s in geotagged_submissions]
268
+ texts = [f"{s.category}<br>{s.text[:100]}..." for s in geotagged_submissions]
269
+
270
+ # Create plotly figure
271
+ fig = go.Figure(data=go.Scattermapbox(
272
+ lat=lats,
273
+ lon=lons,
274
+ mode='markers',
275
+ marker=dict(
276
+ size=10,
277
+ color=[categories.index(c) if c in categories else 0 for c in cats],
278
+ colorscale='Viridis',
279
+ showscale=True
280
+ ),
281
+ text=texts,
282
+ hoverinfo='text'
283
+ ))
284
+
285
+ # Calculate center
286
+ center_lat = sum(lats) / len(lats)
287
+ center_lon = sum(lons) / len(lons)
288
+
289
+ fig.update_layout(
290
+ mapbox=dict(
291
+ style='open-street-map',
292
+ center=dict(lat=center_lat, lon=center_lon),
293
+ zoom=10
294
+ ),
295
+ width=600,
296
+ height=500,
297
+ margin=dict(l=0, r=0, t=0, b=0)
298
+ )
299
+
300
+ # Convert to image
301
+ img_bytes = fig.to_image(format="png")
302
+ img = Image(io.BytesIO(img_bytes), width=6*inch, height=5*inch)
303
+
304
+ return img
requirements.txt CHANGED
@@ -19,3 +19,8 @@ evaluate>=0.4.0
19
 
20
  # Text processing (for sentence segmentation)
21
  nltk>=3.8.0
 
 
 
 
 
 
19
 
20
  # Text processing (for sentence segmentation)
21
  nltk>=3.8.0
22
+
23
+ # PDF generation
24
+ reportlab>=4.0.0
25
+ plotly>=5.18.0
26
+ kaleido>=0.2.1