thadillo Claude commited on
Commit
e60b22c
·
1 Parent(s): af68c84

Fix HuggingFace deployment errors: database locking, matplotlib permissions, and deprecation warnings

Browse files

This commit addresses three critical issues encountered on HuggingFace Spaces:

1. **Fixed TRANSFORMERS_CACHE deprecation warning**
- Removed deprecated TRANSFORMERS_CACHE environment variable
- Using only HF_HOME as recommended by transformers v5

2. **Fixed matplotlib permission errors**
- Added MPLCONFIGDIR=/tmp/matplotlib in Dockerfile
- Set config directory in pdf_export.py before matplotlib import
- Prevents "Permission denied: /.config" errors on HuggingFace

3. **Fixed SQLite database locking errors (CRITICAL)**
- Optimized DELETE operations with synchronize_session=False
- Added retry logic with exponential backoff (3 retries)
- Implemented batch commits (every 10 submissions) to reduce lock duration
- Increased SQLite timeouts from 30s to 60s
- Increased PRAGMA busy_timeout from 30000ms to 60000ms
- Better transaction isolation and error handling

These changes significantly improve concurrent request handling on HuggingFace Spaces
and eliminate the "database is locked" errors during sentence-level analysis.

Files modified:
- Dockerfile: Environment variables and matplotlib config
- app/__init__.py: Increased SQLite timeouts
- app/routes/admin.py: Optimized analyze_submissions with retry logic
- app/utils/pdf_export.py: Matplotlib config directory

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

Co-Authored-By: Claude <[email protected]>

Dockerfile CHANGED
@@ -40,9 +40,12 @@ RUN mkdir -p /data/models/finetuned && chmod -R 777 /data/models
40
  # Create model cache in container (not in /data) to save persistent storage
41
  RUN mkdir -p /app/.cache && chmod -R 777 /app/.cache
42
 
 
 
 
 
43
  # Pre-download models into container image to avoid using /data storage
44
  ENV HF_HOME=/app/.cache/huggingface
45
- ENV TRANSFORMERS_CACHE=/app/.cache/huggingface
46
 
47
  # Download zero-shot models (for immediate analysis capability)
48
  # These are loaded on first analysis, pre-downloading saves time and /data space
@@ -65,7 +68,6 @@ ENV PORT=7860
65
  ENV DATABASE_PATH=/data/app.db
66
  # Keep model cache in container, only store database and fine-tuned models in /data
67
  ENV HF_HOME=/app/.cache/huggingface
68
- ENV TRANSFORMERS_CACHE=/app/.cache/huggingface
69
  ENV HUGGINGFACE_HUB_CACHE=/app/.cache/huggingface
70
 
71
  # Health check
 
40
  # Create model cache in container (not in /data) to save persistent storage
41
  RUN mkdir -p /app/.cache && chmod -R 777 /app/.cache
42
 
43
+ # Create matplotlib config directory (prevent permission errors)
44
+ RUN mkdir -p /tmp/matplotlib && chmod 777 /tmp/matplotlib
45
+ ENV MPLCONFIGDIR=/tmp/matplotlib
46
+
47
  # Pre-download models into container image to avoid using /data storage
48
  ENV HF_HOME=/app/.cache/huggingface
 
49
 
50
  # Download zero-shot models (for immediate analysis capability)
51
  # These are loaded on first analysis, pre-downloading saves time and /data space
 
68
  ENV DATABASE_PATH=/data/app.db
69
  # Keep model cache in container, only store database and fine-tuned models in /data
70
  ENV HF_HOME=/app/.cache/huggingface
 
71
  ENV HUGGINGFACE_HUB_CACHE=/app/.cache/huggingface
72
 
73
  # Health check
app/__init__.py CHANGED
@@ -32,7 +32,7 @@ def create_app():
32
  # SQLite-specific settings to reduce locking issues
33
  app.config['SQLALCHEMY_ENGINE_OPTIONS'] = {
34
  'connect_args': {
35
- 'timeout': 30, # Increase timeout to 30 seconds
36
  'check_same_thread': False # Allow multi-threaded access
37
  },
38
  'pool_pre_ping': True, # Verify connections before using
@@ -51,7 +51,7 @@ def create_app():
51
  cursor = dbapi_conn.cursor()
52
  cursor.execute("PRAGMA journal_mode=WAL") # Write-Ahead Logging
53
  cursor.execute("PRAGMA synchronous=NORMAL") # Balance safety/performance
54
- cursor.execute("PRAGMA busy_timeout=30000") # 30 second timeout
55
  cursor.close()
56
 
57
  # Import models
 
32
  # SQLite-specific settings to reduce locking issues
33
  app.config['SQLALCHEMY_ENGINE_OPTIONS'] = {
34
  'connect_args': {
35
+ 'timeout': 60, # Increase timeout to 60 seconds for HuggingFace
36
  'check_same_thread': False # Allow multi-threaded access
37
  },
38
  'pool_pre_ping': True, # Verify connections before using
 
51
  cursor = dbapi_conn.cursor()
52
  cursor.execute("PRAGMA journal_mode=WAL") # Write-Ahead Logging
53
  cursor.execute("PRAGMA synchronous=NORMAL") # Balance safety/performance
54
+ cursor.execute("PRAGMA busy_timeout=60000") # 60 second timeout for HuggingFace
55
  cursor.close()
56
 
57
  # Import models
app/routes/admin.py CHANGED
@@ -594,6 +594,9 @@ def delete_submission(submission_id):
594
  @bp.route('/api/analyze', methods=['POST'])
595
  @admin_required
596
  def analyze_submissions():
 
 
 
597
  data = request.json
598
  analyze_all = data.get('analyze_all', False)
599
  use_sentences = data.get('use_sentences', True) # NEW: sentence-level flag (default: True)
@@ -616,45 +619,79 @@ def analyze_submissions():
616
 
617
  success_count = 0
618
  error_count = 0
 
619
 
620
- for submission in to_analyze:
621
- try:
622
- if use_sentences:
623
- # NEW: Sentence-level analysis
624
- sentence_results = analyzer.analyze_with_sentences(submission.message)
625
-
626
- # Clear old sentences for this submission
627
- SubmissionSentence.query.filter_by(submission_id=submission.id).delete()
628
-
629
- # Create new sentence records
630
- for idx, result in enumerate(sentence_results):
631
- sentence = SubmissionSentence(
632
- submission_id=submission.id,
633
- sentence_index=idx,
634
- text=result['text'],
635
- category=result['category'],
636
- confidence=result.get('confidence')
637
- )
638
- db.session.add(sentence)
639
-
640
- submission.sentence_analysis_done = True
641
- # Set primary category for backward compatibility
642
- submission.category = submission.get_primary_category()
643
-
644
- logger.info(f"Analyzed submission {submission.id} into {len(sentence_results)} sentences")
645
- else:
646
- # OLD: Submission-level analysis (backward compatible)
647
- category = analyzer.analyze(submission.message)
648
- submission.category = category
649
-
650
- success_count += 1
651
 
652
- except Exception as e:
653
- logger.error(f"Error analyzing submission {submission.id}: {e}")
654
- error_count += 1
655
- continue
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
656
 
657
- db.session.commit()
 
 
 
 
 
 
 
 
 
 
 
 
658
 
659
  return jsonify({
660
  'success': True,
 
594
  @bp.route('/api/analyze', methods=['POST'])
595
  @admin_required
596
  def analyze_submissions():
597
+ import time
598
+ from sqlalchemy.exc import OperationalError
599
+
600
  data = request.json
601
  analyze_all = data.get('analyze_all', False)
602
  use_sentences = data.get('use_sentences', True) # NEW: sentence-level flag (default: True)
 
619
 
620
  success_count = 0
621
  error_count = 0
622
+ batch_size = 10 # Commit every 10 submissions to reduce lock time
623
 
624
+ for idx, submission in enumerate(to_analyze):
625
+ max_retries = 3
626
+ retry_delay = 1 # seconds
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
627
 
628
+ for attempt in range(max_retries):
629
+ try:
630
+ if use_sentences:
631
+ # NEW: Sentence-level analysis
632
+ sentence_results = analyzer.analyze_with_sentences(submission.message)
633
+
634
+ # Optimized DELETE: Use synchronize_session=False for better performance
635
+ SubmissionSentence.query.filter_by(submission_id=submission.id).delete(synchronize_session=False)
636
+
637
+ # Create new sentence records
638
+ for sent_idx, result in enumerate(sentence_results):
639
+ sentence = SubmissionSentence(
640
+ submission_id=submission.id,
641
+ sentence_index=sent_idx,
642
+ text=result['text'],
643
+ category=result['category'],
644
+ confidence=result.get('confidence')
645
+ )
646
+ db.session.add(sentence)
647
+
648
+ submission.sentence_analysis_done = True
649
+ # Set primary category for backward compatibility
650
+ submission.category = submission.get_primary_category()
651
+
652
+ logger.info(f"Analyzed submission {submission.id} into {len(sentence_results)} sentences")
653
+ else:
654
+ # OLD: Submission-level analysis (backward compatible)
655
+ category = analyzer.analyze(submission.message)
656
+ submission.category = category
657
+
658
+ success_count += 1
659
+
660
+ # Commit in batches to reduce lock duration
661
+ if (idx + 1) % batch_size == 0:
662
+ db.session.commit()
663
+ logger.info(f"Committed batch of {batch_size} submissions")
664
+
665
+ break # Success, exit retry loop
666
+
667
+ except OperationalError as e:
668
+ # Database locked error - retry with exponential backoff
669
+ if 'database is locked' in str(e) and attempt < max_retries - 1:
670
+ db.session.rollback()
671
+ wait_time = retry_delay * (2 ** attempt) # Exponential backoff
672
+ logger.warning(f"Database locked for submission {submission.id}, retrying in {wait_time}s (attempt {attempt + 1}/{max_retries})")
673
+ time.sleep(wait_time)
674
+ continue
675
+ else:
676
+ # Max retries reached or different error
677
+ db.session.rollback()
678
+ logger.error(f"Error analyzing submission {submission.id}: {e}")
679
+ error_count += 1
680
+ break
681
 
682
+ except Exception as e:
683
+ db.session.rollback()
684
+ logger.error(f"Error analyzing submission {submission.id}: {e}")
685
+ error_count += 1
686
+ break
687
+
688
+ # Final commit for remaining items
689
+ try:
690
+ db.session.commit()
691
+ logger.info(f"Final commit completed")
692
+ except Exception as e:
693
+ db.session.rollback()
694
+ logger.error(f"Error in final commit: {e}")
695
 
696
  return jsonify({
697
  'success': True,
app/utils/pdf_export.py CHANGED
@@ -3,6 +3,7 @@ PDF export utility for dashboard data
3
  Generates PDF reports matching the Analytics Dashboard exactly
4
  """
5
  import io
 
6
  from datetime import datetime
7
  from reportlab.lib import colors
8
  from reportlab.lib.pagesizes import letter
@@ -10,6 +11,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
 
 
 
13
  import matplotlib
14
  matplotlib.use('Agg')
15
  import matplotlib.pyplot as plt
 
3
  Generates PDF reports matching the Analytics Dashboard exactly
4
  """
5
  import io
6
+ import os
7
  from datetime import datetime
8
  from reportlab.lib import colors
9
  from reportlab.lib.pagesizes import letter
 
11
  from reportlab.lib.units import inch
12
  from reportlab.platypus import SimpleDocTemplate, Table, TableStyle, Paragraph, Spacer, PageBreak, Image
13
  from reportlab.lib.enums import TA_CENTER
14
+
15
+ # Set matplotlib config directory before import (prevent permission errors on HuggingFace)
16
+ os.environ.setdefault('MPLCONFIGDIR', '/tmp/matplotlib')
17
  import matplotlib
18
  matplotlib.use('Agg')
19
  import matplotlib.pyplot as plt
app/utils/pdf_export.py.backup ADDED
@@ -0,0 +1,336 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ PDF export utility for dashboard data
3
+ Generates professional PDF reports with charts and maps using matplotlib
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 matplotlib
14
+ matplotlib.use('Agg') # Use non-interactive backend
15
+ import matplotlib.pyplot as plt
16
+ import numpy as np
17
+ try:
18
+ import contextily as cx
19
+ HAS_CONTEXTILY = True
20
+ except ImportError:
21
+ HAS_CONTEXTILY = False
22
+
23
+
24
+ class DashboardPDFExporter:
25
+ """Export dashboard data to PDF with charts and maps"""
26
+
27
+ def __init__(self, pagesize=letter):
28
+ self.pagesize = pagesize
29
+ self.styles = getSampleStyleSheet()
30
+ self._setup_custom_styles()
31
+
32
+ def _setup_custom_styles(self):
33
+ """Setup custom paragraph styles"""
34
+ self.styles.add(ParagraphStyle(
35
+ name='CustomTitle',
36
+ parent=self.styles['Heading1'],
37
+ fontSize=24,
38
+ textColor=colors.HexColor('#2c3e50'),
39
+ spaceAfter=30,
40
+ alignment=TA_CENTER
41
+ ))
42
+
43
+ self.styles.add(ParagraphStyle(
44
+ name='SectionHeader',
45
+ parent=self.styles['Heading2'],
46
+ fontSize=16,
47
+ textColor=colors.HexColor('#34495e'),
48
+ spaceAfter=12,
49
+ spaceBefore=12
50
+ ))
51
+
52
+ def generate_pdf(self, buffer, data):
53
+ """
54
+ Generate PDF report
55
+
56
+ Args:
57
+ buffer: BytesIO buffer to write PDF to
58
+ data: Dictionary containing dashboard data
59
+ """
60
+ doc = SimpleDocTemplate(buffer, pagesize=self.pagesize,
61
+ rightMargin=72, leftMargin=72,
62
+ topMargin=72, bottomMargin=18)
63
+
64
+ story = []
65
+
66
+ # Title
67
+ title = Paragraph("Participatory Planning Dashboard Report", self.styles['CustomTitle'])
68
+ story.append(title)
69
+ story.append(Spacer(1, 12))
70
+
71
+ # Metadata
72
+ view_mode_label = "Sentence-Level" if data['view_mode'] == 'sentences' else "Submission-Level"
73
+ metadata = Paragraph(
74
+ f"<font size=10>Generated: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}<br/>"
75
+ f"Analysis Mode: {view_mode_label}</font>",
76
+ self.styles['Normal']
77
+ )
78
+ story.append(metadata)
79
+ story.append(Spacer(1, 24))
80
+
81
+ # Summary Statistics
82
+ story.append(Paragraph("Summary Statistics", self.styles['SectionHeader']))
83
+ story.extend(self._create_summary_stats(data))
84
+ story.append(Spacer(1, 24))
85
+
86
+ # Category Distribution Chart
87
+ story.append(Paragraph("Category Distribution", self.styles['SectionHeader']))
88
+ category_chart = self._create_category_chart(data['category_stats'])
89
+ if category_chart:
90
+ story.append(category_chart)
91
+ story.append(Spacer(1, 24))
92
+
93
+ # Contributor Type Distribution
94
+ story.append(Paragraph("Contributor Type Distribution", self.styles['SectionHeader']))
95
+ contributor_chart = self._create_contributor_chart(data['contributor_stats'])
96
+ if contributor_chart:
97
+ story.append(contributor_chart)
98
+ story.append(PageBreak())
99
+
100
+ # Breakdown Table
101
+ story.append(Paragraph("Category Breakdown by Contributor Type", self.styles['SectionHeader']))
102
+ breakdown_table = self._create_breakdown_table(data['breakdown'], data['contributor_types'])
103
+ story.append(breakdown_table)
104
+ story.append(Spacer(1, 24))
105
+
106
+ # Map
107
+ if data['geotagged_submissions']:
108
+ story.append(PageBreak())
109
+ story.append(Paragraph("Geographic Distribution", self.styles['SectionHeader']))
110
+ map_image = self._create_map(data['geotagged_submissions'], data['categories'])
111
+ if map_image:
112
+ story.append(map_image)
113
+
114
+ # Build PDF
115
+ doc.build(story)
116
+
117
+ return buffer
118
+
119
+ def _create_summary_stats(self, data):
120
+ """Create summary statistics section"""
121
+ elements = []
122
+
123
+ total_items = sum(count for _, count in data['category_stats'])
124
+ total_submissions = len(data['submissions'])
125
+ total_geotagged = len(data['geotagged_submissions'])
126
+
127
+ # Create metrics table
128
+ metrics_data = [
129
+ ['Total Submissions', str(total_submissions)],
130
+ ['Total Items Analyzed', str(total_items)],
131
+ ['Geotagged Items', str(total_geotagged)],
132
+ ['Categories', str(len([c for c, count in data['category_stats'] if count > 0]))]
133
+ ]
134
+
135
+ metrics_table = Table(metrics_data, colWidths=[3*inch, 2*inch])
136
+ metrics_table.setStyle(TableStyle([
137
+ ('FONTNAME', (0, 0), (0, -1), 'Helvetica-Bold'),
138
+ ('FONTNAME', (1, 0), (1, -1), 'Helvetica'),
139
+ ('FONTSIZE', (0, 0), (-1, -1), 12),
140
+ ('TEXTCOLOR', (0, 0), (0, -1), colors.HexColor('#2c3e50')),
141
+ ('TEXTCOLOR', (1, 0), (1, -1), colors.HexColor('#3498db')),
142
+ ('ALIGN', (1, 0), (1, -1), 'RIGHT'),
143
+ ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
144
+ ('BOTTOMPADDING', (0, 0), (-1, -1), 12),
145
+ ]))
146
+
147
+ elements.append(metrics_table)
148
+
149
+ return elements
150
+
151
+ def _create_category_chart(self, category_stats):
152
+ """Create category distribution pie chart using matplotlib"""
153
+ if not category_stats:
154
+ return None
155
+
156
+ try:
157
+ # Prepare data
158
+ labels = [cat for cat, _ in category_stats]
159
+ values = [count for _, count in category_stats]
160
+
161
+ # Create matplotlib figure
162
+ fig, ax = plt.subplots(figsize=(6, 5))
163
+ colors_list = ['#3498db', '#2ecc71', '#f39c12', '#e74c3c', '#9b59b6', '#1abc9c']
164
+
165
+ wedges, texts, autotexts = ax.pie(values, labels=labels, autopct='%1.1f%%',
166
+ colors=colors_list[:len(labels)],
167
+ startangle=90)
168
+
169
+ # Make percentage text more readable
170
+ for autotext in autotexts:
171
+ autotext.set_color('white')
172
+ autotext.set_fontsize(10)
173
+ autotext.set_weight('bold')
174
+
175
+ ax.set_title('Category Distribution', fontsize=14, fontweight='bold')
176
+
177
+ # Convert to image
178
+ img_buffer = io.BytesIO()
179
+ plt.tight_layout()
180
+ plt.savefig(img_buffer, format='png', dpi=150, bbox_inches='tight')
181
+ plt.close(fig)
182
+ img_buffer.seek(0)
183
+
184
+ img = Image(img_buffer, width=5*inch, height=4*inch)
185
+ return img
186
+
187
+ except Exception as e:
188
+ print(f"Error creating category chart: {e}")
189
+ return None
190
+
191
+ def _create_contributor_chart(self, contributor_stats):
192
+ """Create contributor type bar chart using matplotlib"""
193
+ if not contributor_stats:
194
+ return None
195
+
196
+ try:
197
+ # Prepare data
198
+ types = [ctype for ctype, _ in contributor_stats]
199
+ counts = [count for _, count in contributor_stats]
200
+
201
+ # Create matplotlib figure
202
+ fig, ax = plt.subplots(figsize=(6, 4))
203
+ bars = ax.bar(types, counts, color='#3498db', edgecolor='#2980b9', linewidth=1.5)
204
+
205
+ # Add value labels on bars
206
+ for bar in bars:
207
+ height = bar.get_height()
208
+ ax.text(bar.get_x() + bar.get_width()/2., height,
209
+ f'{int(height)}',
210
+ ha='center', va='bottom', fontsize=10, fontweight='bold')
211
+
212
+ ax.set_xlabel('Contributor Type', fontsize=11, fontweight='bold')
213
+ ax.set_ylabel('Count', fontsize=11, fontweight='bold')
214
+ ax.set_title('Submissions by Contributor Type', fontsize=14, fontweight='bold')
215
+ ax.grid(axis='y', alpha=0.3)
216
+ plt.xticks(rotation=45, ha='right')
217
+
218
+ # Convert to image
219
+ img_buffer = io.BytesIO()
220
+ plt.tight_layout()
221
+ plt.savefig(img_buffer, format='png', dpi=150, bbox_inches='tight')
222
+ plt.close(fig)
223
+ img_buffer.seek(0)
224
+
225
+ img = Image(img_buffer, width=5*inch, height=3.5*inch)
226
+ return img
227
+
228
+ except Exception as e:
229
+ print(f"Error creating contributor chart: {e}")
230
+ return None
231
+
232
+ def _create_breakdown_table(self, breakdown, contributor_types):
233
+ """Create category breakdown table"""
234
+ # Prepare table data
235
+ headers = ['Category'] + [ct['label'] for ct in contributor_types]
236
+ data = [headers]
237
+
238
+ for category, counts in breakdown.items():
239
+ row = [category]
240
+ for ct in contributor_types:
241
+ row.append(str(counts.get(ct['value'], 0)))
242
+ data.append(row)
243
+
244
+ # Calculate column widths
245
+ num_cols = len(headers)
246
+ col_width = 6.5 * inch / num_cols
247
+
248
+ table = Table(data, colWidths=[col_width] * num_cols)
249
+ table.setStyle(TableStyle([
250
+ ('BACKGROUND', (0, 0), (-1, 0), colors.HexColor('#3498db')),
251
+ ('TEXTCOLOR', (0, 0), (-1, 0), colors.whitesmoke),
252
+ ('ALIGN', (0, 0), (-1, -1), 'CENTER'),
253
+ ('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'),
254
+ ('FONTSIZE', (0, 0), (-1, -1), 10),
255
+ ('BOTTOMPADDING', (0, 0), (-1, 0), 12),
256
+ ('GRID', (0, 0), (-1, -1), 1, colors.grey),
257
+ ('ROWBACKGROUNDS', (0, 1), (-1, -1), [colors.white, colors.HexColor('#ecf0f1')])
258
+ ]))
259
+
260
+ return table
261
+
262
+ def _create_map(self, geotagged_submissions, categories):
263
+ """Create geographic distribution map with real OpenStreetMap tiles"""
264
+ if not geotagged_submissions:
265
+ return None
266
+
267
+ try:
268
+ # Prepare data
269
+ lats = [s.latitude for s in geotagged_submissions]
270
+ lons = [s.longitude for s in geotagged_submissions]
271
+ cats = [s.category for s in geotagged_submissions]
272
+
273
+ # Create matplotlib figure
274
+ fig, ax = plt.subplots(figsize=(10, 8))
275
+
276
+ # Color map for categories
277
+ category_colors = {
278
+ 'Vision': '#3498db',
279
+ 'Problem': '#e74c3c',
280
+ 'Objectives': '#2ecc71',
281
+ 'Directives': '#f39c12',
282
+ 'Values': '#9b59b6',
283
+ 'Actions': '#1abc9c'
284
+ }
285
+
286
+ # Plot points by category
287
+ for category in set(cats):
288
+ cat_lats = [lat for lat, cat in zip(lats, cats) if cat == category]
289
+ cat_lons = [lon for lon, cat in zip(lons, cats) if cat == category]
290
+ color = category_colors.get(category, '#95a5a6')
291
+ ax.scatter(cat_lons, cat_lats, c=color, label=category,
292
+ s=150, alpha=0.8, edgecolors='white', linewidths=2, zorder=5)
293
+
294
+ # Add OpenStreetMap basemap if contextily is available
295
+ if HAS_CONTEXTILY:
296
+ try:
297
+ # Add map tiles
298
+ cx.add_basemap(ax, crs='EPSG:4326', source=cx.providers.OpenStreetMap.Mapnik,
299
+ attribution=False, alpha=0.8)
300
+ except Exception as e:
301
+ print(f"Could not add basemap: {e}")
302
+ # Fallback to grid
303
+ ax.grid(True, alpha=0.3)
304
+ else:
305
+ # Fallback: simple grid
306
+ ax.grid(True, alpha=0.3)
307
+
308
+ ax.set_xlabel('Longitude', fontsize=12, fontweight='bold')
309
+ ax.set_ylabel('Latitude', fontsize=12, fontweight='bold')
310
+ ax.set_title('Geographic Distribution of Submissions',
311
+ fontsize=16, fontweight='bold', pad=20)
312
+
313
+ # Legend outside plot area
314
+ ax.legend(loc='upper left', bbox_to_anchor=(1.02, 1),
315
+ fontsize=10, frameon=True, fancybox=True, shadow=True)
316
+
317
+ # Add attribution text if using OpenStreetMap
318
+ if HAS_CONTEXTILY:
319
+ fig.text(0.99, 0.01, '© OpenStreetMap contributors',
320
+ ha='right', va='bottom', fontsize=7, style='italic', alpha=0.7)
321
+
322
+ # Convert to image
323
+ img_buffer = io.BytesIO()
324
+ plt.tight_layout()
325
+ plt.savefig(img_buffer, format='png', dpi=200, bbox_inches='tight')
326
+ plt.close(fig)
327
+ img_buffer.seek(0)
328
+
329
+ img = Image(img_buffer, width=7*inch, height=5.5*inch)
330
+ return img
331
+
332
+ except Exception as e:
333
+ print(f"Error creating map: {e}")
334
+ import traceback
335
+ traceback.print_exc()
336
+ return None