Thadillo commited on
Commit
1c4a712
·
verified ·
1 Parent(s): e4c9dcf

First commit.

Browse files
.gitignore ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+ *.so
6
+ .Python
7
+ venv/
8
+ env/
9
+ ENV/
10
+ *.egg-info/
11
+ dist/
12
+ build/
13
+
14
+ # Flask
15
+ instance/
16
+ .webassets-cache
17
+
18
+ # Database
19
+ *.db
20
+ *.sqlite
21
+ *.sqlite3
22
+
23
+ # Environment
24
+ .env
25
+
26
+ # IDE
27
+ .vscode/
28
+ .idea/
29
+ *.swp
30
+ *.swo
31
+ *~
32
+
33
+ # OS
34
+ .DS_Store
35
+ Thumbs.db
Dockerfile ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Hugging Face Spaces Dockerfile
2
+ FROM python:3.11-slim
3
+
4
+ # Set working directory
5
+ WORKDIR /app
6
+
7
+ # Install system dependencies
8
+ RUN apt-get update && apt-get install -y \
9
+ build-essential \
10
+ curl \
11
+ && rm -rf /var/lib/apt/lists/*
12
+
13
+ # Copy requirements
14
+ COPY requirements.txt .
15
+
16
+ # Install Python dependencies
17
+ RUN pip install --no-cache-dir -r requirements.txt
18
+
19
+ # Copy application code
20
+ COPY . .
21
+
22
+ # Create instance directory for database
23
+ RUN mkdir -p instance
24
+
25
+ # Hugging Face Spaces uses port 7860
26
+ EXPOSE 7860
27
+
28
+ # Set environment variables
29
+ ENV FLASK_ENV=production
30
+ ENV PYTHONUNBUFFERED=1
31
+ ENV PORT=7860
32
+
33
+ # Health check
34
+ HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
35
+ CMD curl -f http://localhost:7860/login || exit 1
36
+
37
+ # Run the application
38
+ CMD ["python", "app_hf.py"]
README.md CHANGED
@@ -1,12 +1,56 @@
1
  ---
2
- title: Participatory Planner
3
- emoji: 📈
4
- colorFrom: yellow
5
- colorTo: gray
6
  sdk: docker
7
  pinned: false
8
  license: mit
9
- short_description: Planner Helper APP
10
  ---
11
 
12
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: Participatory Planning Application
3
+ emoji: 🏙️
4
+ colorFrom: blue
5
+ colorTo: purple
6
  sdk: docker
7
  pinned: false
8
  license: mit
 
9
  ---
10
 
11
+ # Participatory Planning Application
12
+
13
+ An AI-powered collaborative urban planning platform for multi-stakeholder engagement sessions.
14
+
15
+ ## Features
16
+
17
+ - 🎯 **Token-based access** - Self-service registration for participants
18
+ - 🤖 **AI categorization** - Automatic classification using Hugging Face models (free & offline)
19
+ - 🗺️ **Geographic mapping** - Interactive visualization of geotagged contributions
20
+ - 📊 **Analytics dashboard** - Real-time charts and category breakdowns
21
+ - 💾 **Session management** - Export/import for pause/resume workflows
22
+ - 👥 **Multi-stakeholder** - Government, Community, Industry, NGO, Academic, Other
23
+
24
+ ## Quick Start
25
+
26
+ 1. Access the application
27
+ 2. Login with admin token: `ADMIN123`
28
+ 3. Go to **Registration** to get the participant signup link
29
+ 4. Share the link with stakeholders
30
+ 5. Collect submissions and analyze with AI
31
+
32
+ ## Default Login
33
+
34
+ - **Admin Token**: `ADMIN123`
35
+ - **Admin Access**: Full dashboard, analytics, moderation
36
+
37
+ ## Tech Stack
38
+
39
+ - Flask (Python web framework)
40
+ - SQLite (database)
41
+ - Hugging Face Transformers (AI classification)
42
+ - Leaflet.js (maps)
43
+ - Chart.js (analytics)
44
+ - Bootstrap 5 (UI)
45
+
46
+ ## Demo Data
47
+
48
+ The app starts empty. You can:
49
+ 1. Generate tokens for test users
50
+ 2. Submit sample contributions
51
+ 3. Run AI analysis
52
+ 4. View analytics dashboard
53
+
54
+ ## License
55
+
56
+ MIT
app/__init__.py ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from flask import Flask
2
+ from flask_sqlalchemy import SQLAlchemy
3
+ from dotenv import load_dotenv
4
+ import os
5
+
6
+ db = SQLAlchemy()
7
+
8
+ def create_app():
9
+ load_dotenv()
10
+
11
+ app = Flask(__name__)
12
+ app.config['SECRET_KEY'] = os.getenv('FLASK_SECRET_KEY', 'dev-secret-key-change-in-production')
13
+ app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///participatory_planner.db'
14
+ app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
15
+
16
+ db.init_app(app)
17
+
18
+ # Import models
19
+ from app.models import models
20
+
21
+ # Import and register blueprints
22
+ from app.routes import auth, submissions, admin
23
+
24
+ app.register_blueprint(auth.bp)
25
+ app.register_blueprint(submissions.bp)
26
+ app.register_blueprint(admin.bp)
27
+
28
+ # Create tables
29
+ with app.app_context():
30
+ db.create_all()
31
+ # Initialize with admin token if not exists
32
+ from app.models.models import Token
33
+ if not Token.query.filter_by(token='ADMIN123').first():
34
+ admin_token = Token(
35
+ token='ADMIN123',
36
+ type='admin',
37
+ name='Administrator'
38
+ )
39
+ db.session.add(admin_token)
40
+ db.session.commit()
41
+
42
+ return app
app/__pycache__/__init__.cpython-313.pyc ADDED
Binary file (2.06 kB). View file
 
app/__pycache__/analyzer.cpython-313.pyc ADDED
Binary file (4.04 kB). View file
 
app/analyzer.py ADDED
@@ -0,0 +1,109 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ AI-powered submission analyzer using Hugging Face zero-shot classification.
3
+ This module provides free, offline classification without requiring API keys.
4
+ """
5
+
6
+ from transformers import pipeline
7
+ import logging
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+ class SubmissionAnalyzer:
12
+ def __init__(self):
13
+ """Initialize the zero-shot classification model."""
14
+ self.classifier = None
15
+ self.categories = [
16
+ 'Vision',
17
+ 'Problem',
18
+ 'Objectives',
19
+ 'Directives',
20
+ 'Values',
21
+ 'Actions'
22
+ ]
23
+
24
+ # Category descriptions for better classification
25
+ self.category_descriptions = {
26
+ 'Vision': 'future aspirations, desired outcomes, what success looks like',
27
+ 'Problem': 'current issues, frustrations, causes of problems',
28
+ 'Objectives': 'specific goals to achieve',
29
+ 'Directives': 'restrictions or requirements for solution design',
30
+ 'Values': 'principles or restrictions for setting objectives',
31
+ 'Actions': 'concrete steps, interventions, or activities to implement'
32
+ }
33
+
34
+ def _load_model(self):
35
+ """Lazy load the model only when needed."""
36
+ if self.classifier is None:
37
+ try:
38
+ logger.info("Loading zero-shot classification model...")
39
+ # Using facebook/bart-large-mnli - good balance of speed and accuracy
40
+ self.classifier = pipeline(
41
+ "zero-shot-classification",
42
+ model="facebook/bart-large-mnli",
43
+ device=-1 # Use CPU (-1), change to 0 for GPU
44
+ )
45
+ logger.info("Model loaded successfully!")
46
+ except Exception as e:
47
+ logger.error(f"Error loading model: {e}")
48
+ raise
49
+
50
+ def analyze(self, message):
51
+ """
52
+ Classify a submission message into one of the predefined categories.
53
+
54
+ Args:
55
+ message (str): The submission message to classify
56
+
57
+ Returns:
58
+ str: The predicted category
59
+ """
60
+ self._load_model()
61
+
62
+ try:
63
+ # Use category descriptions as labels for better accuracy
64
+ candidate_labels = [
65
+ f"{cat}: {self.category_descriptions[cat]}"
66
+ for cat in self.categories
67
+ ]
68
+
69
+ # Run classification
70
+ result = self.classifier(
71
+ message,
72
+ candidate_labels,
73
+ multi_label=False
74
+ )
75
+
76
+ # Extract the category name from the label
77
+ top_label = result['labels'][0]
78
+ category = top_label.split(':')[0]
79
+
80
+ logger.info(f"Classified message as: {category} (confidence: {result['scores'][0]:.2f})")
81
+
82
+ return category
83
+
84
+ except Exception as e:
85
+ logger.error(f"Error analyzing message: {e}")
86
+ # Fallback to Problem category if analysis fails
87
+ return 'Problem'
88
+
89
+ def analyze_batch(self, messages):
90
+ """
91
+ Classify multiple messages at once.
92
+
93
+ Args:
94
+ messages (list): List of submission messages
95
+
96
+ Returns:
97
+ list: List of predicted categories
98
+ """
99
+ return [self.analyze(msg) for msg in messages]
100
+
101
+ # Global analyzer instance
102
+ _analyzer = None
103
+
104
+ def get_analyzer():
105
+ """Get or create the global analyzer instance."""
106
+ global _analyzer
107
+ if _analyzer is None:
108
+ _analyzer = SubmissionAnalyzer()
109
+ return _analyzer
app/models/__pycache__/models.cpython-313.pyc ADDED
Binary file (4.81 kB). View file
 
app/models/models.py ADDED
@@ -0,0 +1,68 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from app import db
2
+ from datetime import datetime
3
+
4
+ class Token(db.Model):
5
+ __tablename__ = 'tokens'
6
+
7
+ id = db.Column(db.Integer, primary_key=True)
8
+ token = db.Column(db.String(50), unique=True, nullable=False)
9
+ type = db.Column(db.String(20), nullable=False) # admin, government, community, industry, ngo, academic, other
10
+ name = db.Column(db.String(100), nullable=False)
11
+ created_at = db.Column(db.DateTime, default=datetime.utcnow)
12
+
13
+ def to_dict(self):
14
+ return {
15
+ 'id': self.id,
16
+ 'token': self.token,
17
+ 'type': self.type,
18
+ 'name': self.name,
19
+ 'created_at': self.created_at.isoformat() if self.created_at else None
20
+ }
21
+
22
+ class Submission(db.Model):
23
+ __tablename__ = 'submissions'
24
+
25
+ id = db.Column(db.Integer, primary_key=True)
26
+ message = db.Column(db.Text, nullable=False)
27
+ contributor_type = db.Column(db.String(20), nullable=False)
28
+ latitude = db.Column(db.Float, nullable=True)
29
+ longitude = db.Column(db.Float, nullable=True)
30
+ timestamp = db.Column(db.DateTime, default=datetime.utcnow)
31
+ category = db.Column(db.String(50), nullable=True) # Vision, Problem, Objectives, Directives, Values, Actions
32
+ flagged_as_offensive = db.Column(db.Boolean, default=False)
33
+
34
+ def to_dict(self):
35
+ return {
36
+ 'id': self.id,
37
+ 'message': self.message,
38
+ 'contributorType': self.contributor_type,
39
+ 'location': {
40
+ 'lat': self.latitude,
41
+ 'lng': self.longitude
42
+ } if self.latitude and self.longitude else None,
43
+ 'timestamp': self.timestamp.isoformat() if self.timestamp else None,
44
+ 'category': self.category,
45
+ 'flaggedAsOffensive': self.flagged_as_offensive
46
+ }
47
+
48
+ class Settings(db.Model):
49
+ __tablename__ = 'settings'
50
+
51
+ id = db.Column(db.Integer, primary_key=True)
52
+ key = db.Column(db.String(50), unique=True, nullable=False)
53
+ value = db.Column(db.String(10), nullable=False) # 'true' or 'false'
54
+
55
+ @staticmethod
56
+ def get_setting(key, default='true'):
57
+ setting = Settings.query.filter_by(key=key).first()
58
+ return setting.value if setting else default
59
+
60
+ @staticmethod
61
+ def set_setting(key, value):
62
+ setting = Settings.query.filter_by(key=key).first()
63
+ if setting:
64
+ setting.value = value
65
+ else:
66
+ setting = Settings(key=key, value=value)
67
+ db.session.add(setting)
68
+ db.session.commit()
app/routes/__pycache__/admin.cpython-313.pyc ADDED
Binary file (21.2 kB). View file
 
app/routes/__pycache__/auth.cpython-313.pyc ADDED
Binary file (5.06 kB). View file
 
app/routes/__pycache__/submissions.cpython-313.pyc ADDED
Binary file (3.5 kB). View file
 
app/routes/admin.py ADDED
@@ -0,0 +1,398 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from flask import Blueprint, render_template, request, redirect, url_for, session, flash, jsonify, send_file
2
+ from app.models.models import Token, Submission, Settings
3
+ from app import db
4
+ from app.analyzer import get_analyzer
5
+ from functools import wraps
6
+ import json
7
+ import csv
8
+ import io
9
+ from datetime import datetime
10
+ import os
11
+
12
+ bp = Blueprint('admin', __name__, url_prefix='/admin')
13
+
14
+ CONTRIBUTOR_TYPES = [
15
+ {'value': 'government', 'label': 'Government Officer', 'description': 'Public sector representatives'},
16
+ {'value': 'community', 'label': 'Community Member', 'description': 'Local residents and community leaders'},
17
+ {'value': 'industry', 'label': 'Industry Representative', 'description': 'Business and industry stakeholders'},
18
+ {'value': 'ngo', 'label': 'NGO/Non-Profit', 'description': 'Civil society organizations'},
19
+ {'value': 'academic', 'label': 'Academic/Researcher', 'description': 'Universities and research institutions'},
20
+ {'value': 'other', 'label': 'Other Stakeholder', 'description': 'Other interested parties'}
21
+ ]
22
+
23
+ CATEGORIES = ['Vision', 'Problem', 'Objectives', 'Directives', 'Values', 'Actions']
24
+
25
+ def admin_required(f):
26
+ @wraps(f)
27
+ def decorated_function(*args, **kwargs):
28
+ if 'token' not in session or session.get('type') != 'admin':
29
+ return redirect(url_for('auth.login'))
30
+ return f(*args, **kwargs)
31
+ return decorated_function
32
+
33
+ @bp.route('/overview')
34
+ @admin_required
35
+ def overview():
36
+ total_submissions = Submission.query.count()
37
+ total_tokens = Token.query.filter(Token.type != 'admin').count()
38
+ flagged_count = Submission.query.filter_by(flagged_as_offensive=True).count()
39
+ unanalyzed_count = Submission.query.filter_by(category=None).count()
40
+
41
+ submission_open = Settings.get_setting('submission_open', 'true') == 'true'
42
+ token_generation_enabled = Settings.get_setting('token_generation_enabled', 'true') == 'true'
43
+
44
+ analyzed = Submission.query.filter(Submission.category != None).count() > 0
45
+
46
+ return render_template('admin/overview.html',
47
+ total_submissions=total_submissions,
48
+ total_tokens=total_tokens,
49
+ flagged_count=flagged_count,
50
+ unanalyzed_count=unanalyzed_count,
51
+ submission_open=submission_open,
52
+ token_generation_enabled=token_generation_enabled,
53
+ analyzed=analyzed)
54
+
55
+ @bp.route('/registration')
56
+ @admin_required
57
+ def registration():
58
+ token_generation_enabled = Settings.get_setting('token_generation_enabled', 'true') == 'true'
59
+ recent_tokens = Token.query.filter(Token.type != 'admin').order_by(Token.created_at.desc()).limit(10).all()
60
+
61
+ registration_url = request.host_url.rstrip('/') + url_for('auth.generate')
62
+
63
+ return render_template('admin/registration.html',
64
+ token_generation_enabled=token_generation_enabled,
65
+ recent_tokens=recent_tokens,
66
+ registration_url=registration_url)
67
+
68
+ @bp.route('/tokens')
69
+ @admin_required
70
+ def tokens():
71
+ all_tokens = Token.query.all()
72
+ return render_template('admin/tokens.html',
73
+ tokens=all_tokens,
74
+ contributor_types=CONTRIBUTOR_TYPES)
75
+
76
+ @bp.route('/submissions')
77
+ @admin_required
78
+ def submissions():
79
+ category_filter = request.args.get('category', 'all')
80
+ flagged_only = request.args.get('flagged', 'false') == 'true'
81
+
82
+ query = Submission.query
83
+
84
+ if category_filter != 'all':
85
+ query = query.filter_by(category=category_filter)
86
+
87
+ if flagged_only:
88
+ query = query.filter_by(flagged_as_offensive=True)
89
+
90
+ all_submissions = query.order_by(Submission.timestamp.desc()).all()
91
+ flagged_count = Submission.query.filter_by(flagged_as_offensive=True).count()
92
+
93
+ analyzed = Submission.query.filter(Submission.category != None).count() > 0
94
+
95
+ return render_template('admin/submissions.html',
96
+ submissions=all_submissions,
97
+ categories=CATEGORIES,
98
+ category_filter=category_filter,
99
+ flagged_only=flagged_only,
100
+ flagged_count=flagged_count,
101
+ analyzed=analyzed)
102
+
103
+ @bp.route('/dashboard')
104
+ @admin_required
105
+ def dashboard():
106
+ # Check if analyzed
107
+ analyzed = Submission.query.filter(Submission.category != None).count() > 0
108
+
109
+ if not analyzed:
110
+ flash('Please analyze submissions first', 'warning')
111
+ return redirect(url_for('admin.overview'))
112
+
113
+ submissions = Submission.query.filter(Submission.category != None).all()
114
+
115
+ # Contributor stats
116
+ contributor_stats = db.session.query(
117
+ Submission.contributor_type,
118
+ db.func.count(Submission.id)
119
+ ).group_by(Submission.contributor_type).all()
120
+
121
+ # Category stats
122
+ category_stats = db.session.query(
123
+ Submission.category,
124
+ db.func.count(Submission.id)
125
+ ).filter(Submission.category != None).group_by(Submission.category).all()
126
+
127
+ # Geotagged submissions
128
+ geotagged_submissions = Submission.query.filter(
129
+ Submission.latitude != None,
130
+ Submission.longitude != None,
131
+ Submission.category != None
132
+ ).all()
133
+
134
+ # Category breakdown by contributor type
135
+ breakdown = {}
136
+ for cat in CATEGORIES:
137
+ breakdown[cat] = {}
138
+ for ctype in CONTRIBUTOR_TYPES:
139
+ count = Submission.query.filter_by(
140
+ category=cat,
141
+ contributor_type=ctype['value']
142
+ ).count()
143
+ breakdown[cat][ctype['value']] = count
144
+
145
+ return render_template('admin/dashboard.html',
146
+ submissions=submissions,
147
+ contributor_stats=contributor_stats,
148
+ category_stats=category_stats,
149
+ geotagged_submissions=geotagged_submissions,
150
+ categories=CATEGORIES,
151
+ contributor_types=CONTRIBUTOR_TYPES,
152
+ breakdown=breakdown)
153
+
154
+ # API Endpoints
155
+
156
+ @bp.route('/api/toggle-submissions', methods=['POST'])
157
+ @admin_required
158
+ def toggle_submissions():
159
+ current = Settings.get_setting('submission_open', 'true')
160
+ new_value = 'false' if current == 'true' else 'true'
161
+ Settings.set_setting('submission_open', new_value)
162
+ return jsonify({'success': True, 'submission_open': new_value == 'true'})
163
+
164
+ @bp.route('/api/toggle-token-generation', methods=['POST'])
165
+ @admin_required
166
+ def toggle_token_generation():
167
+ current = Settings.get_setting('token_generation_enabled', 'true')
168
+ new_value = 'false' if current == 'true' else 'true'
169
+ Settings.set_setting('token_generation_enabled', new_value)
170
+ return jsonify({'success': True, 'token_generation_enabled': new_value == 'true'})
171
+
172
+ @bp.route('/api/create-token', methods=['POST'])
173
+ @admin_required
174
+ def create_token():
175
+ data = request.json
176
+ contributor_type = data.get('type')
177
+ name = data.get('name', '').strip()
178
+
179
+ if not contributor_type or contributor_type not in [t['value'] for t in CONTRIBUTOR_TYPES]:
180
+ return jsonify({'success': False, 'error': 'Invalid contributor type'}), 400
181
+
182
+ import random
183
+ import string
184
+
185
+ prefix = contributor_type[:3].upper()
186
+ random_part = ''.join(random.choices(string.ascii_uppercase + string.digits, k=6))
187
+ timestamp_part = str(int(datetime.now().timestamp()))[-4:]
188
+ token_str = f"{prefix}-{random_part}{timestamp_part}"
189
+
190
+ final_name = name if name else f"{contributor_type.capitalize()} User"
191
+
192
+ new_token = Token(
193
+ token=token_str,
194
+ type=contributor_type,
195
+ name=final_name
196
+ )
197
+
198
+ db.session.add(new_token)
199
+ db.session.commit()
200
+
201
+ return jsonify({'success': True, 'token': new_token.to_dict()})
202
+
203
+ @bp.route('/api/delete-token/<int:token_id>', methods=['DELETE'])
204
+ @admin_required
205
+ def delete_token(token_id):
206
+ token = Token.query.get_or_404(token_id)
207
+
208
+ if token.token == 'ADMIN123':
209
+ return jsonify({'success': False, 'error': 'Cannot delete admin token'}), 400
210
+
211
+ db.session.delete(token)
212
+ db.session.commit()
213
+
214
+ return jsonify({'success': True})
215
+
216
+ @bp.route('/api/update-category/<int:submission_id>', methods=['POST'])
217
+ @admin_required
218
+ def update_category(submission_id):
219
+ submission = Submission.query.get_or_404(submission_id)
220
+ data = request.json
221
+ category = data.get('category')
222
+
223
+ # Validate category
224
+ if category and category not in CATEGORIES:
225
+ return jsonify({'success': False, 'error': 'Invalid category'}), 400
226
+
227
+ submission.category = category
228
+ db.session.commit()
229
+ return jsonify({'success': True, 'category': category})
230
+
231
+ @bp.route('/api/toggle-flag/<int:submission_id>', methods=['POST'])
232
+ @admin_required
233
+ def toggle_flag(submission_id):
234
+ submission = Submission.query.get_or_404(submission_id)
235
+ submission.flagged_as_offensive = not submission.flagged_as_offensive
236
+ db.session.commit()
237
+ return jsonify({'success': True, 'flagged': submission.flagged_as_offensive})
238
+
239
+ @bp.route('/api/delete-submission/<int:submission_id>', methods=['DELETE'])
240
+ @admin_required
241
+ def delete_submission(submission_id):
242
+ submission = Submission.query.get_or_404(submission_id)
243
+ db.session.delete(submission)
244
+ db.session.commit()
245
+ return jsonify({'success': True})
246
+
247
+ @bp.route('/api/analyze', methods=['POST'])
248
+ @admin_required
249
+ def analyze_submissions():
250
+ data = request.json
251
+ analyze_all = data.get('analyze_all', False)
252
+
253
+ # Get submissions to analyze
254
+ if analyze_all:
255
+ to_analyze = Submission.query.all()
256
+ else:
257
+ to_analyze = Submission.query.filter_by(category=None).all()
258
+
259
+ if not to_analyze:
260
+ return jsonify({'success': False, 'error': 'No submissions to analyze'}), 400
261
+
262
+ # Get the analyzer instance
263
+ analyzer = get_analyzer()
264
+
265
+ success_count = 0
266
+ error_count = 0
267
+
268
+ for submission in to_analyze:
269
+ try:
270
+ # Use the free Hugging Face model for classification
271
+ category = analyzer.analyze(submission.message)
272
+ submission.category = category
273
+ success_count += 1
274
+
275
+ except Exception as e:
276
+ print(f"Error analyzing submission {submission.id}: {e}")
277
+ error_count += 1
278
+ continue
279
+
280
+ db.session.commit()
281
+
282
+ return jsonify({
283
+ 'success': True,
284
+ 'analyzed': success_count,
285
+ 'errors': error_count
286
+ })
287
+
288
+ @bp.route('/export/json')
289
+ @admin_required
290
+ def export_json():
291
+ data = {
292
+ 'tokens': [t.to_dict() for t in Token.query.all()],
293
+ 'submissions': [s.to_dict() for s in Submission.query.all()],
294
+ 'submissionOpen': Settings.get_setting('submission_open', 'true') == 'true',
295
+ 'tokenGenerationEnabled': Settings.get_setting('token_generation_enabled', 'true') == 'true',
296
+ 'exportDate': datetime.utcnow().isoformat()
297
+ }
298
+
299
+ json_str = json.dumps(data, indent=2)
300
+
301
+ buffer = io.BytesIO()
302
+ buffer.write(json_str.encode('utf-8'))
303
+ buffer.seek(0)
304
+
305
+ return send_file(
306
+ buffer,
307
+ mimetype='application/json',
308
+ as_attachment=True,
309
+ download_name=f'participatory-planning-{datetime.now().strftime("%Y-%m-%d")}.json'
310
+ )
311
+
312
+ @bp.route('/export/csv')
313
+ @admin_required
314
+ def export_csv():
315
+ submissions = Submission.query.all()
316
+
317
+ output = io.StringIO()
318
+ writer = csv.writer(output)
319
+
320
+ # Header
321
+ writer.writerow(['Timestamp', 'Contributor Type', 'Category', 'Message', 'Latitude', 'Longitude', 'Flagged'])
322
+
323
+ # Rows
324
+ for s in submissions:
325
+ writer.writerow([
326
+ s.timestamp.isoformat() if s.timestamp else '',
327
+ s.contributor_type,
328
+ s.category or 'Not analyzed',
329
+ s.message,
330
+ s.latitude or '',
331
+ s.longitude or '',
332
+ 'Yes' if s.flagged_as_offensive else 'No'
333
+ ])
334
+
335
+ buffer = io.BytesIO()
336
+ buffer.write(output.getvalue().encode('utf-8'))
337
+ buffer.seek(0)
338
+
339
+ return send_file(
340
+ buffer,
341
+ mimetype='text/csv',
342
+ as_attachment=True,
343
+ download_name=f'contributions-{datetime.now().strftime("%Y-%m-%d")}.csv'
344
+ )
345
+
346
+ @bp.route('/import', methods=['POST'])
347
+ @admin_required
348
+ def import_data():
349
+ if 'file' not in request.files:
350
+ return jsonify({'success': False, 'error': 'No file uploaded'}), 400
351
+
352
+ file = request.files['file']
353
+
354
+ if file.filename == '':
355
+ return jsonify({'success': False, 'error': 'No file selected'}), 400
356
+
357
+ try:
358
+ data = json.load(file)
359
+
360
+ # Clear existing data (except admin token)
361
+ Submission.query.delete()
362
+ Token.query.filter(Token.token != 'ADMIN123').delete()
363
+
364
+ # Import tokens
365
+ for token_data in data.get('tokens', []):
366
+ if token_data['token'] != 'ADMIN123': # Skip admin token as it already exists
367
+ token = Token(
368
+ token=token_data['token'],
369
+ type=token_data['type'],
370
+ name=token_data['name']
371
+ )
372
+ db.session.add(token)
373
+
374
+ # Import submissions
375
+ for sub_data in data.get('submissions', []):
376
+ location = sub_data.get('location')
377
+ submission = Submission(
378
+ message=sub_data['message'],
379
+ contributor_type=sub_data['contributorType'],
380
+ latitude=location['lat'] if location else None,
381
+ longitude=location['lng'] if location else None,
382
+ timestamp=datetime.fromisoformat(sub_data['timestamp']) if sub_data.get('timestamp') else datetime.utcnow(),
383
+ category=sub_data.get('category'),
384
+ flagged_as_offensive=sub_data.get('flaggedAsOffensive', False)
385
+ )
386
+ db.session.add(submission)
387
+
388
+ # Import settings
389
+ Settings.set_setting('submission_open', 'true' if data.get('submissionOpen', True) else 'false')
390
+ Settings.set_setting('token_generation_enabled', 'true' if data.get('tokenGenerationEnabled', True) else 'false')
391
+
392
+ db.session.commit()
393
+
394
+ return jsonify({'success': True})
395
+
396
+ except Exception as e:
397
+ db.session.rollback()
398
+ return jsonify({'success': False, 'error': str(e)}), 500
app/routes/auth.py ADDED
@@ -0,0 +1,90 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from flask import Blueprint, render_template, request, redirect, url_for, session, flash
2
+ from app.models.models import Token, Settings
3
+ from app import db
4
+ import random
5
+ import string
6
+ from datetime import datetime
7
+
8
+ bp = Blueprint('auth', __name__)
9
+
10
+ CONTRIBUTOR_TYPES = [
11
+ {'value': 'government', 'label': 'Government Officer', 'description': 'Public sector representatives'},
12
+ {'value': 'community', 'label': 'Community Member', 'description': 'Local residents and community leaders'},
13
+ {'value': 'industry', 'label': 'Industry Representative', 'description': 'Business and industry stakeholders'},
14
+ {'value': 'ngo', 'label': 'NGO/Non-Profit', 'description': 'Civil society organizations'},
15
+ {'value': 'academic', 'label': 'Academic/Researcher', 'description': 'Universities and research institutions'},
16
+ {'value': 'other', 'label': 'Other Stakeholder', 'description': 'Other interested parties'}
17
+ ]
18
+
19
+ def generate_token(contributor_type):
20
+ prefix = contributor_type[:3].upper()
21
+ random_part = ''.join(random.choices(string.ascii_uppercase + string.digits, k=6))
22
+ timestamp_part = str(int(datetime.now().timestamp()))[-4:]
23
+ return f"{prefix}-{random_part}{timestamp_part}"
24
+
25
+ @bp.route('/')
26
+ def index():
27
+ return redirect(url_for('auth.login'))
28
+
29
+ @bp.route('/login', methods=['GET', 'POST'])
30
+ def login():
31
+ if request.method == 'POST':
32
+ token_str = request.form.get('token')
33
+ token = Token.query.filter_by(token=token_str).first()
34
+
35
+ if token:
36
+ session['token'] = token.token
37
+ session['type'] = token.type
38
+
39
+ if token.type == 'admin':
40
+ return redirect(url_for('admin.overview'))
41
+ else:
42
+ return redirect(url_for('submissions.submit'))
43
+ else:
44
+ flash('Invalid token', 'error')
45
+
46
+ return render_template('login.html')
47
+
48
+ @bp.route('/generate', methods=['GET', 'POST'])
49
+ def generate():
50
+ token_generation_enabled = Settings.get_setting('token_generation_enabled', 'true') == 'true'
51
+
52
+ if request.method == 'POST':
53
+ if not token_generation_enabled:
54
+ flash('Token generation is currently disabled', 'error')
55
+ return redirect(url_for('auth.generate'))
56
+
57
+ contributor_type = request.form.get('type')
58
+ user_name = request.form.get('name', '').strip()
59
+
60
+ if not contributor_type or contributor_type not in [t['value'] for t in CONTRIBUTOR_TYPES]:
61
+ flash('Please select a valid role', 'error')
62
+ return redirect(url_for('auth.generate'))
63
+
64
+ # Generate token
65
+ from datetime import datetime
66
+ token_str = generate_token(contributor_type)
67
+ name = user_name if user_name else f"{contributor_type.capitalize()} User"
68
+
69
+ new_token = Token(
70
+ token=token_str,
71
+ type=contributor_type,
72
+ name=name
73
+ )
74
+
75
+ db.session.add(new_token)
76
+ db.session.commit()
77
+
78
+ return render_template('generate.html',
79
+ contributor_types=CONTRIBUTOR_TYPES,
80
+ token_generation_enabled=token_generation_enabled,
81
+ generated_token=token_str)
82
+
83
+ return render_template('generate.html',
84
+ contributor_types=CONTRIBUTOR_TYPES,
85
+ token_generation_enabled=token_generation_enabled)
86
+
87
+ @bp.route('/logout')
88
+ def logout():
89
+ session.clear()
90
+ return redirect(url_for('auth.login'))
app/routes/submissions.py ADDED
@@ -0,0 +1,63 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from flask import Blueprint, render_template, request, redirect, url_for, session, flash, jsonify
2
+ from app.models.models import Submission, Settings
3
+ from app import db
4
+ from functools import wraps
5
+
6
+ bp = Blueprint('submissions', __name__)
7
+
8
+ def login_required(f):
9
+ @wraps(f)
10
+ def decorated_function(*args, **kwargs):
11
+ if 'token' not in session:
12
+ return redirect(url_for('auth.login'))
13
+ return f(*args, **kwargs)
14
+ return decorated_function
15
+
16
+ def contributor_only(f):
17
+ @wraps(f)
18
+ def decorated_function(*args, **kwargs):
19
+ if 'token' not in session or session.get('type') == 'admin':
20
+ return redirect(url_for('auth.login'))
21
+ return f(*args, **kwargs)
22
+ return decorated_function
23
+
24
+ @bp.route('/submit', methods=['GET', 'POST'])
25
+ @login_required
26
+ @contributor_only
27
+ def submit():
28
+ submission_open = Settings.get_setting('submission_open', 'true') == 'true'
29
+ contributor_type = session.get('type')
30
+
31
+ if request.method == 'POST':
32
+ if not submission_open:
33
+ flash('Submission period is currently closed.', 'error')
34
+ return redirect(url_for('submissions.submit'))
35
+
36
+ message = request.form.get('message', '').strip()
37
+ latitude = request.form.get('latitude')
38
+ longitude = request.form.get('longitude')
39
+
40
+ if not message:
41
+ flash('Please enter a message', 'error')
42
+ return redirect(url_for('submissions.submit'))
43
+
44
+ new_submission = Submission(
45
+ message=message,
46
+ contributor_type=contributor_type,
47
+ latitude=float(latitude) if latitude else None,
48
+ longitude=float(longitude) if longitude else None
49
+ )
50
+
51
+ db.session.add(new_submission)
52
+ db.session.commit()
53
+
54
+ flash('Contribution submitted successfully!', 'success')
55
+ return redirect(url_for('submissions.submit'))
56
+
57
+ # Get submission count for this user
58
+ submission_count = Submission.query.filter_by(contributor_type=contributor_type).count()
59
+
60
+ return render_template('submit.html',
61
+ submission_open=submission_open,
62
+ contributor_type=contributor_type,
63
+ submission_count=submission_count)
app/templates/admin/base.html ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends "base.html" %}
2
+
3
+ {% block content %}
4
+ <div class="min-vh-100 bg-light">
5
+ <nav class="navbar navbar-expand-lg navbar-dark bg-dark shadow-sm">
6
+ <div class="container-fluid">
7
+ <a class="navbar-brand" href="{{ url_for('admin.overview') }}">
8
+ <i class="bi bi-speedometer2"></i> Admin Dashboard
9
+ </a>
10
+ <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
11
+ <span class="navbar-toggler-icon"></span>
12
+ </button>
13
+ <div class="collapse navbar-collapse" id="navbarNav">
14
+ <ul class="navbar-nav me-auto">
15
+ <li class="nav-item">
16
+ <a class="nav-link {% if request.endpoint == 'admin.overview' %}active{% endif %}"
17
+ href="{{ url_for('admin.overview') }}">
18
+ <i class="bi bi-bar-chart-fill"></i> Overview
19
+ </a>
20
+ </li>
21
+ <li class="nav-item">
22
+ <a class="nav-link {% if request.endpoint == 'admin.registration' %}active{% endif %}"
23
+ href="{{ url_for('admin.registration') }}">
24
+ <i class="bi bi-key-fill"></i> Registration
25
+ </a>
26
+ </li>
27
+ <li class="nav-item">
28
+ <a class="nav-link {% if request.endpoint == 'admin.tokens' %}active{% endif %}"
29
+ href="{{ url_for('admin.tokens') }}">
30
+ <i class="bi bi-people-fill"></i> Tokens ({{ token_count if token_count is defined else '...' }})
31
+ </a>
32
+ </li>
33
+ <li class="nav-item">
34
+ <a class="nav-link {% if request.endpoint == 'admin.submissions' %}active{% endif %}"
35
+ href="{{ url_for('admin.submissions') }}">
36
+ <i class="bi bi-chat-square-text-fill"></i> Submissions
37
+ </a>
38
+ </li>
39
+ <li class="nav-item">
40
+ <a class="nav-link {% if request.endpoint == 'admin.dashboard' %}active{% endif %}"
41
+ href="{{ url_for('admin.dashboard') }}">
42
+ <i class="bi bi-graph-up"></i> Analytics
43
+ </a>
44
+ </li>
45
+ </ul>
46
+ <div class="d-flex gap-2">
47
+ <a href="{{ url_for('admin.export_json') }}" class="btn btn-success btn-sm">
48
+ <i class="bi bi-download"></i> Save Session
49
+ </a>
50
+ <a href="{{ url_for('auth.logout') }}" class="btn btn-outline-light btn-sm">Logout</a>
51
+ </div>
52
+ </div>
53
+ </div>
54
+ </nav>
55
+
56
+ <div class="container-fluid py-4">
57
+ {% block admin_content %}{% endblock %}
58
+ </div>
59
+ </div>
60
+ {% endblock %}
app/templates/admin/dashboard.html ADDED
@@ -0,0 +1,223 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends "admin/base.html" %}
2
+
3
+ {% block title %}Analytics Dashboard{% endblock %}
4
+
5
+ {% set get_category_color = {
6
+ 'Vision': '#3b82f6',
7
+ 'Problem': '#ef4444',
8
+ 'Objectives': '#10b981',
9
+ 'Directives': '#f59e0b',
10
+ 'Values': '#8b5cf6',
11
+ 'Actions': '#ec4899'
12
+ }.get %}
13
+
14
+ {% block admin_content %}
15
+ <h2 class="mb-4">Analytics Dashboard</h2>
16
+
17
+ <div class="row g-4 mb-4">
18
+ <div class="col-lg-6">
19
+ <div class="card shadow-sm h-100">
20
+ <div class="card-body">
21
+ <h5 class="card-title">By Contributor Type</h5>
22
+ <canvas id="contributorChart"></canvas>
23
+ </div>
24
+ </div>
25
+ </div>
26
+
27
+ <div class="col-lg-6">
28
+ <div class="card shadow-sm h-100">
29
+ <div class="card-body">
30
+ <h5 class="card-title">By Category</h5>
31
+ <canvas id="categoryChart"></canvas>
32
+ </div>
33
+ </div>
34
+ </div>
35
+ </div>
36
+
37
+ {% if geotagged_submissions %}
38
+ <div class="card shadow-sm mb-4">
39
+ <div class="card-body">
40
+ <h5 class="card-title mb-3">
41
+ Geographic Distribution ({{ geotagged_submissions|length }} geotagged)
42
+ </h5>
43
+ <div id="dashboardMap" class="dashboard-map-container border rounded"></div>
44
+ </div>
45
+ </div>
46
+ {% endif %}
47
+
48
+ <div class="card shadow-sm mb-4">
49
+ <div class="card-body">
50
+ <h5 class="card-title mb-4">Contributions by Category</h5>
51
+ {% for category in categories %}
52
+ {% set category_submissions = submissions|selectattr('category', 'equalto', category)|list %}
53
+ {% if category_submissions %}
54
+ <div class="mb-4">
55
+ <h6 class="border-bottom pb-2" style="border-color: {{ get_category_color(category) }}!important; border-width: 2px!important;">
56
+ <span class="badge" style="background-color: {{ get_category_color(category) }};">{{ category }}</span>
57
+ <small class="text-muted">({{ category_submissions|length }} contribution{{ 's' if category_submissions|length != 1 else '' }})</small>
58
+ </h6>
59
+ {% for sub in category_submissions %}
60
+ <div class="border-start border-3 ps-3 mb-3" style="border-color: {{ get_category_color(category) }}!important;">
61
+ <div class="d-flex justify-content-between align-items-start mb-1">
62
+ <small class="text-muted text-capitalize">{{ sub.contributor_type }}</small>
63
+ <small class="text-muted">{{ sub.timestamp.strftime('%Y-%m-%d') if sub.timestamp else '' }}</small>
64
+ </div>
65
+ <p class="mb-0">{{ sub.message }}</p>
66
+ {% if sub.latitude and sub.longitude %}
67
+ <p class="text-muted small mb-0 mt-1">
68
+ <i class="bi bi-geo-alt-fill"></i> {{ sub.latitude|round(4) }}, {{ sub.longitude|round(4) }}
69
+ </p>
70
+ {% endif %}
71
+ </div>
72
+ {% endfor %}
73
+ </div>
74
+ {% endif %}
75
+ {% endfor %}
76
+ </div>
77
+ </div>
78
+
79
+ <div class="card shadow-sm">
80
+ <div class="card-body">
81
+ <h5 class="card-title mb-3">Category Breakdown by Contributor Type</h5>
82
+ <div class="table-responsive">
83
+ <table class="table table-bordered">
84
+ <thead>
85
+ <tr>
86
+ <th>Category</th>
87
+ {% for type in contributor_types %}
88
+ <th class="text-center">{{ type.label }}</th>
89
+ {% endfor %}
90
+ </tr>
91
+ </thead>
92
+ <tbody>
93
+ {% for category in categories %}
94
+ <tr>
95
+ <td class="fw-bold">{{ category }}</td>
96
+ {% for type in contributor_types %}
97
+ <td class="text-center">
98
+ {% set count = breakdown[category][type.value] %}
99
+ {{ count if count > 0 else '-' }}
100
+ </td>
101
+ {% endfor %}
102
+ </tr>
103
+ {% endfor %}
104
+ </tbody>
105
+ </table>
106
+ </div>
107
+ </div>
108
+ </div>
109
+
110
+ <script>
111
+ document.addEventListener('DOMContentLoaded', function() {
112
+ // Category colors
113
+ const categoryColors = {
114
+ 'Vision': '#3b82f6',
115
+ 'Problem': '#ef4444',
116
+ 'Objectives': '#10b981',
117
+ 'Directives': '#f59e0b',
118
+ 'Values': '#8b5cf6',
119
+ 'Actions': '#ec4899'
120
+ };
121
+
122
+ // Contributor Chart
123
+ const contributorData = {
124
+ labels: [{% for stat in contributor_stats %}'{{ stat[0] }}'{% if not loop.last %}, {% endif %}{% endfor %}],
125
+ datasets: [{
126
+ data: [{% for stat in contributor_stats %}{{ stat[1] }}{% if not loop.last %}, {% endif %}{% endfor %}],
127
+ backgroundColor: ['#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899']
128
+ }]
129
+ };
130
+
131
+ new Chart(document.getElementById('contributorChart'), {
132
+ type: 'pie',
133
+ data: contributorData,
134
+ options: {
135
+ responsive: true,
136
+ plugins: {
137
+ legend: {
138
+ position: 'bottom'
139
+ }
140
+ }
141
+ }
142
+ });
143
+
144
+ // Category Chart
145
+ const categoryData = {
146
+ labels: [{% for stat in category_stats %}'{{ stat[0] }}'{% if not loop.last %}, {% endif %}{% endfor %}],
147
+ datasets: [{
148
+ label: 'Submissions',
149
+ data: [{% for stat in category_stats %}{{ stat[1] }}{% if not loop.last %}, {% endif %}{% endfor %}],
150
+ backgroundColor: [{% for stat in category_stats %}categoryColors['{{ stat[0] }}']{% if not loop.last %}, {% endif %}{% endfor %}]
151
+ }]
152
+ };
153
+
154
+ new Chart(document.getElementById('categoryChart'), {
155
+ type: 'bar',
156
+ data: categoryData,
157
+ options: {
158
+ responsive: true,
159
+ plugins: {
160
+ legend: {
161
+ display: false
162
+ }
163
+ },
164
+ scales: {
165
+ y: {
166
+ beginAtZero: true,
167
+ ticks: {
168
+ stepSize: 1
169
+ }
170
+ }
171
+ }
172
+ }
173
+ });
174
+
175
+ {% if geotagged_submissions %}
176
+ // Dashboard Map
177
+ const dashMap = L.map('dashboardMap').setView([0, 0], 2);
178
+
179
+ L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
180
+ attribution: '© OpenStreetMap'
181
+ }).addTo(dashMap);
182
+
183
+ const bounds = [];
184
+
185
+ {% for sub in geotagged_submissions %}
186
+ {
187
+ const color = categoryColors['{{ sub.category }}'] || '#6b7280';
188
+ const customIcon = L.divIcon({
189
+ className: 'custom-marker',
190
+ html: `<div style="background-color: ${color}; width: 20px; height: 20px; border-radius: 50%; border: 2px solid white;"></div>`,
191
+ iconSize: [20, 20]
192
+ });
193
+
194
+ const marker = L.marker([{{ sub.latitude }}, {{ sub.longitude }}], { icon: customIcon })
195
+ .addTo(dashMap)
196
+ .bindPopup(`
197
+ <div>
198
+ <div style="color: ${color}; font-weight: bold;">{{ sub.category }}</div>
199
+ <div class="text-muted small text-capitalize">{{ sub.contributor_type }}</div>
200
+ <div class="mt-1">{{ sub.message[:100] }}{{ '...' if sub.message|length > 100 else '' }}</div>
201
+ </div>
202
+ `);
203
+
204
+ bounds.push([{{ sub.latitude }}, {{ sub.longitude }}]);
205
+ }
206
+ {% endfor %}
207
+
208
+ if (bounds.length > 0) {
209
+ dashMap.fitBounds(bounds, { padding: [50, 50] });
210
+ }
211
+
212
+ setTimeout(() => dashMap.invalidateSize(), 100);
213
+ {% endif %}
214
+ });
215
+ </script>
216
+
217
+ <style>
218
+ .custom-marker {
219
+ background: none;
220
+ border: none;
221
+ }
222
+ </style>
223
+ {% endblock %}
app/templates/admin/overview.html ADDED
@@ -0,0 +1,197 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends "admin/base.html" %}
2
+
3
+ {% block title %}Overview - Admin Dashboard{% endblock %}
4
+
5
+ {% block admin_content %}
6
+ <h2 class="mb-4">Overview</h2>
7
+
8
+ <div class="row g-4 mb-4">
9
+ <div class="col-md-4">
10
+ <div class="card shadow-sm card-hover h-100">
11
+ <div class="card-body">
12
+ <div class="d-flex justify-content-between align-items-center">
13
+ <div>
14
+ <h6 class="text-muted mb-1">Total Submissions</h6>
15
+ <h2 class="mb-0">{{ total_submissions }}</h2>
16
+ {% if flagged_count > 0 %}
17
+ <small class="text-danger">{{ flagged_count }} flagged</small>
18
+ {% endif %}
19
+ {% if unanalyzed_count > 0 %}
20
+ <small class="text-warning d-block">{{ unanalyzed_count }} unanalyzed</small>
21
+ {% endif %}
22
+ </div>
23
+ <i class="bi bi-chat-square-text-fill text-primary" style="font-size: 3rem;"></i>
24
+ </div>
25
+ </div>
26
+ </div>
27
+ </div>
28
+
29
+ <div class="col-md-4">
30
+ <div class="card shadow-sm card-hover h-100">
31
+ <div class="card-body">
32
+ <div class="d-flex justify-content-between align-items-center">
33
+ <div>
34
+ <h6 class="text-muted mb-1">Registered Users</h6>
35
+ <h2 class="mb-0">{{ total_tokens }}</h2>
36
+ </div>
37
+ <i class="bi bi-people-fill text-success" style="font-size: 3rem;"></i>
38
+ </div>
39
+ </div>
40
+ </div>
41
+ </div>
42
+
43
+ <div class="col-md-4">
44
+ <div class="card shadow-sm card-hover h-100">
45
+ <div class="card-body">
46
+ <div class="d-flex justify-content-between align-items-center">
47
+ <div>
48
+ <h6 class="text-muted mb-1">Analysis Status</h6>
49
+ <h2 class="mb-0">{{ 'Complete' if analyzed and unanalyzed_count == 0 else 'Partial' if analyzed else 'Pending' }}</h2>
50
+ {% if analyzed and unanalyzed_count > 0 %}
51
+ <small class="text-warning">{{ unanalyzed_count }} new</small>
52
+ {% endif %}
53
+ </div>
54
+ <i class="bi bi-gear-fill text-info" style="font-size: 3rem;"></i>
55
+ </div>
56
+ </div>
57
+ </div>
58
+ </div>
59
+ </div>
60
+
61
+ <div class="card shadow-sm mb-4">
62
+ <div class="card-body">
63
+ <h5 class="card-title mb-3">Controls</h5>
64
+ <div class="d-flex gap-2 flex-wrap">
65
+ <button class="btn btn-{{ 'danger' if submission_open else 'success' }}" onclick="toggleSubmissions()">
66
+ {{ 'Close Submissions' if submission_open else 'Open Submissions' }}
67
+ </button>
68
+
69
+ <button class="btn btn-{{ 'warning' if token_generation_enabled else 'success' }}" onclick="toggleTokenGeneration()">
70
+ {{ 'Disable Token Generation' if token_generation_enabled else 'Enable Token Generation' }}
71
+ </button>
72
+
73
+ {% if total_submissions > 0 and unanalyzed_count > 0 %}
74
+ <button class="btn btn-primary" onclick="analyzeSubmissions(false)">
75
+ <i class="bi bi-cpu-fill"></i> Analyze {{ unanalyzed_count }} {{ 'Submission' if unanalyzed_count == 1 else 'Submissions' }}
76
+ </button>
77
+ {% endif %}
78
+
79
+ {% if analyzed and total_submissions > 0 and unanalyzed_count == 0 %}
80
+ <button class="btn btn-secondary" onclick="analyzeSubmissions(true)">
81
+ <i class="bi bi-arrow-clockwise"></i> Re-analyze All
82
+ </button>
83
+ {% endif %}
84
+
85
+ {% if analyzed %}
86
+ <a href="{{ url_for('admin.export_csv') }}" class="btn btn-info">
87
+ <i class="bi bi-file-earmark-spreadsheet"></i> Export CSV
88
+ </a>
89
+ {% endif %}
90
+
91
+ <button class="btn btn-warning" onclick="document.getElementById('importFile').click()">
92
+ <i class="bi bi-upload"></i> Import Session
93
+ </button>
94
+ <input type="file" id="importFile" accept=".json" style="display: none;" onchange="importData(this)">
95
+ </div>
96
+
97
+ {% if total_submissions > 0 and unanalyzed_count > 0 %}
98
+ <div class="alert alert-info mt-3 mb-0">
99
+ <i class="bi bi-info-circle-fill"></i>
100
+ <strong>Note:</strong> {{ unanalyzed_count }} submission{{ 's' if unanalyzed_count != 1 else '' }}
101
+ {{ 'are' if unanalyzed_count != 1 else 'is' }} waiting to be analyzed.
102
+ {% if analyzed %} Click "Analyze Submissions" to analyze only the unanalyzed submissions.{% endif %}
103
+ </div>
104
+ {% endif %}
105
+ </div>
106
+ </div>
107
+
108
+ <script>
109
+ function toggleSubmissions() {
110
+ fetch('{{ url_for("admin.toggle_submissions") }}', {
111
+ method: 'POST',
112
+ headers: {'Content-Type': 'application/json'}
113
+ })
114
+ .then(response => response.json())
115
+ .then(data => {
116
+ if (data.success) {
117
+ location.reload();
118
+ }
119
+ });
120
+ }
121
+
122
+ function toggleTokenGeneration() {
123
+ fetch('{{ url_for("admin.toggle_token_generation") }}', {
124
+ method: 'POST',
125
+ headers: {'Content-Type': 'application/json'}
126
+ })
127
+ .then(response => response.json())
128
+ .then(data => {
129
+ if (data.success) {
130
+ location.reload();
131
+ }
132
+ });
133
+ }
134
+
135
+ function analyzeSubmissions(analyzeAll) {
136
+ if (confirm(`Are you sure you want to ${analyzeAll ? 're-analyze all' : 'analyze'} submissions? This may take a few minutes.`)) {
137
+ const btn = event.target;
138
+ btn.disabled = true;
139
+ btn.innerHTML = '<i class="bi bi-hourglass-split"></i> Analyzing...';
140
+
141
+ fetch('{{ url_for("admin.analyze_submissions") }}', {
142
+ method: 'POST',
143
+ headers: {'Content-Type': 'application/json'},
144
+ body: JSON.stringify({analyze_all: analyzeAll})
145
+ })
146
+ .then(response => response.json())
147
+ .then(data => {
148
+ if (data.success) {
149
+ alert(`Successfully analyzed ${data.analyzed} submission${data.analyzed !== 1 ? 's' : ''}!${data.errors > 0 ? ' ' + data.errors + ' failed.' : ''}`);
150
+ location.reload();
151
+ } else {
152
+ alert('Error: ' + data.error);
153
+ btn.disabled = false;
154
+ btn.innerHTML = analyzeAll ? 'Re-analyze All' : 'Analyze Submissions';
155
+ }
156
+ })
157
+ .catch(error => {
158
+ alert('Error analyzing submissions');
159
+ btn.disabled = false;
160
+ btn.innerHTML = analyzeAll ? 'Re-analyze All' : 'Analyze Submissions';
161
+ });
162
+ }
163
+ }
164
+
165
+ function importData(input) {
166
+ if (!input.files || !input.files[0]) return;
167
+
168
+ if (!confirm('WARNING: This will replace ALL current data (except admin token) with the imported data. Are you sure?')) {
169
+ input.value = '';
170
+ return;
171
+ }
172
+
173
+ const formData = new FormData();
174
+ formData.append('file', input.files[0]);
175
+
176
+ fetch('{{ url_for("admin.import_data") }}', {
177
+ method: 'POST',
178
+ body: formData
179
+ })
180
+ .then(response => response.json())
181
+ .then(data => {
182
+ if (data.success) {
183
+ alert('Session data imported successfully!');
184
+ location.reload();
185
+ } else {
186
+ alert('Error importing data: ' + (data.error || 'Unknown error'));
187
+ }
188
+ })
189
+ .catch(error => {
190
+ alert('Error importing data');
191
+ })
192
+ .finally(() => {
193
+ input.value = '';
194
+ });
195
+ }
196
+ </script>
197
+ {% endblock %}
app/templates/admin/registration.html ADDED
@@ -0,0 +1,75 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends "admin/base.html" %}
2
+
3
+ {% block title %}Registration - Admin Dashboard{% endblock %}
4
+
5
+ {% block admin_content %}
6
+ <h2 class="mb-4">Participant Registration</h2>
7
+
8
+ <div class="card shadow-sm mb-4">
9
+ <div class="card-body">
10
+ <div class="alert alert-primary">
11
+ <div class="d-flex align-items-start gap-3">
12
+ <i class="bi bi-link-45deg" style="font-size: 2rem;"></i>
13
+ <div class="flex-grow-1">
14
+ <h5 class="mb-2">Share this link with participants:</h5>
15
+ <div class="bg-white rounded p-3 mb-3">
16
+ <code id="registrationUrl">{{ registration_url }}</code>
17
+ </div>
18
+ <button class="btn btn-primary" onclick="copyUrl()">
19
+ <i class="bi bi-clipboard"></i> Copy Registration Link
20
+ </button>
21
+ </div>
22
+ </div>
23
+ </div>
24
+
25
+ <div class="alert alert-{{ 'success' if token_generation_enabled else 'danger' }} mb-3">
26
+ <strong>Token Generation: {{ 'ENABLED ✓' if token_generation_enabled else 'DISABLED ✗' }}</strong>
27
+ <p class="mb-0 mt-1">
28
+ {{ 'Participants can generate their own tokens using the link above.' if token_generation_enabled
29
+ else 'Participants cannot generate tokens. You must create tokens manually in the Tokens tab.' }}
30
+ </p>
31
+ </div>
32
+
33
+ <div class="bg-light rounded p-3">
34
+ <h6 class="mb-2">How it works:</h6>
35
+ <ol class="mb-0">
36
+ <li>Share the registration link with participants (via email, chat, or display it on screen)</li>
37
+ <li>Each participant selects their role and enters their name</li>
38
+ <li>They receive a unique access token instantly</li>
39
+ <li>Participants use their token to login and submit contributions</li>
40
+ </ol>
41
+ </div>
42
+ </div>
43
+ </div>
44
+
45
+ <div class="card shadow-sm">
46
+ <div class="card-body">
47
+ <h5 class="card-title mb-3">Recent Registrations</h5>
48
+ {% if recent_tokens %}
49
+ <div class="list-group">
50
+ {% for token in recent_tokens %}
51
+ <div class="list-group-item d-flex justify-content-between align-items-center">
52
+ <div>
53
+ <strong>{{ token.name }}</strong>
54
+ <span class="text-muted mx-2">•</span>
55
+ <span class="text-muted text-capitalize">{{ token.type }}</span>
56
+ </div>
57
+ <code class="text-muted">{{ token.token }}</code>
58
+ </div>
59
+ {% endfor %}
60
+ </div>
61
+ {% else %}
62
+ <p class="text-muted mb-0">No registrations yet.</p>
63
+ {% endif %}
64
+ </div>
65
+ </div>
66
+
67
+ <script>
68
+ function copyUrl() {
69
+ const url = document.getElementById('registrationUrl').textContent;
70
+ navigator.clipboard.writeText(url).then(() => {
71
+ alert('Registration link copied to clipboard!');
72
+ });
73
+ }
74
+ </script>
75
+ {% endblock %}
app/templates/admin/submissions.html ADDED
@@ -0,0 +1,176 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends "admin/base.html" %}
2
+
3
+ {% block title %}Submissions - Admin Dashboard{% endblock %}
4
+
5
+ {% block admin_content %}
6
+ <div class="d-flex justify-content-between align-items-center mb-4 flex-wrap gap-3">
7
+ <h2>
8
+ All Submissions ({{ submissions|length }})
9
+ {% if flagged_count > 0 %}
10
+ <span class="badge bg-danger">{{ flagged_count }} flagged</span>
11
+ {% endif %}
12
+ </h2>
13
+
14
+ <div class="d-flex gap-2 align-items-center">
15
+ {% if analyzed %}
16
+ <select class="form-select" onchange="filterCategory(this.value)">
17
+ <option value="all" {% if category_filter == 'all' %}selected{% endif %}>All Categories</option>
18
+ {% for cat in categories %}
19
+ <option value="{{ cat }}" {% if category_filter == cat %}selected{% endif %}>{{ cat }}</option>
20
+ {% endfor %}
21
+ </select>
22
+ {% endif %}
23
+
24
+ <div class="form-check">
25
+ <input class="form-check-input" type="checkbox" id="flaggedOnly"
26
+ {% if flagged_only %}checked{% endif %} onchange="toggleFlagged(this.checked)">
27
+ <label class="form-check-label" for="flaggedOnly">
28
+ <i class="bi bi-flag-fill text-danger"></i> Flagged Only
29
+ </label>
30
+ </div>
31
+ </div>
32
+ </div>
33
+
34
+ {% if category_filter != 'all' or flagged_only %}
35
+ <div class="alert alert-info">
36
+ Showing {{ submissions|length }} submission{{ 's' if submissions|length != 1 else '' }}
37
+ {% if category_filter != 'all' %} in category: {{ category_filter }}{% endif %}
38
+ {% if flagged_only %} (flagged only){% endif %}
39
+ </div>
40
+ {% endif %}
41
+
42
+ <div class="row g-3">
43
+ {% if submissions %}
44
+ {% for sub in submissions %}
45
+ <div class="col-12">
46
+ <div class="card shadow-sm {% if sub.flagged_as_offensive %}border-danger{% endif %}">
47
+ <div class="card-body">
48
+ <div class="d-flex justify-content-between align-items-start mb-2">
49
+ <div class="d-flex gap-2 flex-wrap align-items-center">
50
+ <span class="badge bg-secondary text-capitalize">{{ sub.contributor_type }}</span>
51
+ <select class="form-select form-select-sm" style="width: auto;"
52
+ onchange="updateCategory({{ sub.id }}, this.value)">
53
+ <option value="">Not categorized</option>
54
+ {% for cat in categories %}
55
+ <option value="{{ cat }}" {% if sub.category == cat %}selected{% endif %}>{{ cat }}</option>
56
+ {% endfor %}
57
+ </select>
58
+ {% if sub.flagged_as_offensive %}
59
+ <span class="badge bg-danger">
60
+ <i class="bi bi-exclamation-triangle-fill"></i> Flagged as Offensive
61
+ </span>
62
+ {% endif %}
63
+ </div>
64
+ <small class="text-muted">{{ sub.timestamp.strftime('%Y-%m-%d %H:%M') if sub.timestamp else '' }}</small>
65
+ </div>
66
+
67
+ <p class="mb-3">{{ sub.message }}</p>
68
+
69
+ {% if sub.latitude and sub.longitude %}
70
+ <p class="text-muted small mb-3">
71
+ <i class="bi bi-geo-alt-fill"></i>
72
+ {{ sub.latitude|round(6) }}, {{ sub.longitude|round(6) }}
73
+ </p>
74
+ {% endif %}
75
+
76
+ <div class="d-flex gap-2 pt-3 border-top">
77
+ <button class="btn btn-sm btn-{{ 'success' if sub.flagged_as_offensive else 'warning' }}"
78
+ onclick="toggleFlag({{ sub.id }})">
79
+ <i class="bi bi-flag-fill"></i>
80
+ {{ 'Unflag' if sub.flagged_as_offensive else 'Flag as Offensive' }}
81
+ </button>
82
+ <button class="btn btn-sm btn-danger" onclick="deleteSubmission({{ sub.id }})">
83
+ <i class="bi bi-trash"></i> Delete
84
+ </button>
85
+ </div>
86
+ </div>
87
+ </div>
88
+ </div>
89
+ {% endfor %}
90
+ {% else %}
91
+ <div class="col-12">
92
+ <div class="text-center py-5 text-muted">
93
+ <i class="bi bi-chat-square-text" style="font-size: 4rem;"></i>
94
+ <p class="mt-3">
95
+ {% if flagged_only %}
96
+ No flagged submissions
97
+ {% elif category_filter == 'all' %}
98
+ No submissions yet
99
+ {% else %}
100
+ No submissions in category: {{ category_filter }}
101
+ {% endif %}
102
+ </p>
103
+ </div>
104
+ </div>
105
+ {% endif %}
106
+ </div>
107
+
108
+ <script>
109
+ function filterCategory(category) {
110
+ const url = new URL(window.location);
111
+ url.searchParams.set('category', category);
112
+ window.location = url;
113
+ }
114
+
115
+ function toggleFlagged(checked) {
116
+ const url = new URL(window.location);
117
+ url.searchParams.set('flagged', checked ? 'true' : 'false');
118
+ window.location = url;
119
+ }
120
+
121
+ function updateCategory(submissionId, category) {
122
+ fetch(`{{ url_for("admin.update_category", submission_id=0) }}`.replace('/0', `/${submissionId}`), {
123
+ method: 'POST',
124
+ headers: {
125
+ 'Content-Type': 'application/json'
126
+ },
127
+ body: JSON.stringify({ category: category || null })
128
+ })
129
+ .then(response => response.json())
130
+ .then(data => {
131
+ if (data.success) {
132
+ // Show success feedback
133
+ const select = event.target;
134
+ const originalBg = select.style.backgroundColor;
135
+ select.style.backgroundColor = '#d1fae5';
136
+ setTimeout(() => {
137
+ select.style.backgroundColor = originalBg;
138
+ }, 500);
139
+ } else {
140
+ alert('Failed to update category');
141
+ location.reload();
142
+ }
143
+ })
144
+ .catch(() => {
145
+ alert('Error updating category');
146
+ location.reload();
147
+ });
148
+ }
149
+
150
+ function toggleFlag(submissionId) {
151
+ fetch(`{{ url_for("admin.toggle_flag", submission_id=0) }}`.replace('/0', `/${submissionId}`), {
152
+ method: 'POST'
153
+ })
154
+ .then(response => response.json())
155
+ .then(data => {
156
+ if (data.success) {
157
+ location.reload();
158
+ }
159
+ });
160
+ }
161
+
162
+ function deleteSubmission(submissionId) {
163
+ if (confirm('Are you sure you want to permanently delete this submission?')) {
164
+ fetch(`{{ url_for("admin.delete_submission", submission_id=0) }}`.replace('/0', `/${submissionId}`), {
165
+ method: 'DELETE'
166
+ })
167
+ .then(response => response.json())
168
+ .then(data => {
169
+ if (data.success) {
170
+ location.reload();
171
+ }
172
+ });
173
+ }
174
+ }
175
+ </script>
176
+ {% endblock %}
app/templates/admin/tokens.html ADDED
@@ -0,0 +1,92 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends "admin/base.html" %}
2
+
3
+ {% block title %}Tokens - Admin Dashboard{% endblock %}
4
+
5
+ {% block admin_content %}
6
+ <div class="d-flex justify-content-between align-items-center mb-4">
7
+ <h2>All Access Tokens</h2>
8
+ <div class="dropdown">
9
+ <button class="btn btn-primary dropdown-toggle" type="button" data-bs-toggle="dropdown">
10
+ <i class="bi bi-plus-circle"></i> Create Token Manually
11
+ </button>
12
+ <ul class="dropdown-menu">
13
+ {% for type in contributor_types %}
14
+ <li><a class="dropdown-menu-item" href="#" onclick="createToken('{{ type.value }}', '{{ type.label }}'); return false;">
15
+ {{ type.label }}
16
+ </a></li>
17
+ {% endfor %}
18
+ </ul>
19
+ </div>
20
+ </div>
21
+
22
+ <div class="card shadow-sm">
23
+ <div class="card-body">
24
+ <div class="table-responsive">
25
+ <table class="table table-hover">
26
+ <thead>
27
+ <tr>
28
+ <th>Token</th>
29
+ <th>Type</th>
30
+ <th>Name</th>
31
+ <th>Created</th>
32
+ <th class="text-end">Actions</th>
33
+ </tr>
34
+ </thead>
35
+ <tbody>
36
+ {% for token in tokens %}
37
+ <tr>
38
+ <td><code>{{ token.token }}</code></td>
39
+ <td class="text-capitalize">{{ token.type }}</td>
40
+ <td>{{ token.name }}</td>
41
+ <td>{{ token.created_at.strftime('%Y-%m-%d') if token.created_at else '-' }}</td>
42
+ <td class="text-end">
43
+ {% if token.token != 'ADMIN123' %}
44
+ <button class="btn btn-sm btn-outline-danger" onclick="deleteToken({{ token.id }})">
45
+ <i class="bi bi-trash"></i>
46
+ </button>
47
+ {% endif %}
48
+ </td>
49
+ </tr>
50
+ {% endfor %}
51
+ </tbody>
52
+ </table>
53
+ </div>
54
+ </div>
55
+ </div>
56
+
57
+ <script>
58
+ function createToken(type, label) {
59
+ const name = prompt(`Enter user name for ${label} (optional - leave blank for default):`);
60
+
61
+ fetch('{{ url_for("admin.create_token") }}', {
62
+ method: 'POST',
63
+ headers: {'Content-Type': 'application/json'},
64
+ body: JSON.stringify({type: type, name: name || ''})
65
+ })
66
+ .then(response => response.json())
67
+ .then(data => {
68
+ if (data.success) {
69
+ location.reload();
70
+ } else {
71
+ alert('Error: ' + data.error);
72
+ }
73
+ });
74
+ }
75
+
76
+ function deleteToken(tokenId) {
77
+ if (confirm('Are you sure you want to delete this token?')) {
78
+ fetch(`{{ url_for("admin.delete_token", token_id=0) }}`.replace('/0', `/${tokenId}`), {
79
+ method: 'DELETE'
80
+ })
81
+ .then(response => response.json())
82
+ .then(data => {
83
+ if (data.success) {
84
+ location.reload();
85
+ } else {
86
+ alert('Error: ' + data.error);
87
+ }
88
+ });
89
+ }
90
+ }
91
+ </script>
92
+ {% endblock %}
app/templates/base.html ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>{% block title %}Participatory Planning{% endblock %}</title>
7
+ <link href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css" rel="stylesheet">
8
+ <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.min.css" />
9
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/[email protected]/font/bootstrap-icons.css">
10
+ {% block extra_css %}{% endblock %}
11
+ <style>
12
+ .gradient-bg {
13
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
14
+ }
15
+ .card-hover:hover {
16
+ transform: translateY(-5px);
17
+ transition: transform 0.3s ease;
18
+ }
19
+ .map-container {
20
+ height: 400px;
21
+ border-radius: 0.5rem;
22
+ }
23
+ .dashboard-map-container {
24
+ height: 500px;
25
+ border-radius: 0.5rem;
26
+ }
27
+ </style>
28
+ </head>
29
+ <body>
30
+ {% with messages = get_flashed_messages(with_categories=true) %}
31
+ {% if messages %}
32
+ <div class="position-fixed top-0 end-0 p-3" style="z-index: 9999">
33
+ {% for category, message in messages %}
34
+ <div class="alert alert-{{ 'danger' if category == 'error' else category }} alert-dismissible fade show" role="alert">
35
+ {{ message }}
36
+ <button type="button" class="btn-close" data-bs-dismiss="alert"></button>
37
+ </div>
38
+ {% endfor %}
39
+ </div>
40
+ {% endif %}
41
+ {% endwith %}
42
+
43
+ {% block content %}{% endblock %}
44
+
45
+ <script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/js/bootstrap.bundle.min.js"></script>
46
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.min.js"></script>
47
+ <script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/chart.umd.min.js"></script>
48
+ {% block extra_js %}{% endblock %}
49
+ </body>
50
+ </html>
app/templates/generate.html ADDED
@@ -0,0 +1,100 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends "base.html" %}
2
+
3
+ {% block title %}Generate Token - Participatory Planning{% endblock %}
4
+
5
+ {% block content %}
6
+ <div class="min-vh-100 gradient-bg d-flex align-items-center justify-content-center p-4">
7
+ <div class="card shadow-lg" style="max-width: 800px; width: 100%;">
8
+ <div class="card-body p-5">
9
+ <div class="text-center mb-4">
10
+ <i class="bi bi-key-fill text-success" style="font-size: 4rem;"></i>
11
+ <h1 class="h3 mt-3 mb-2">Get Your Access Token</h1>
12
+ <p class="text-muted">Generate a unique token to participate in the planning session</p>
13
+ </div>
14
+
15
+ {% if not token_generation_enabled %}
16
+ <div class="alert alert-warning">
17
+ <i class="bi bi-exclamation-triangle-fill"></i>
18
+ Token generation is currently disabled by the administrator.
19
+ </div>
20
+ {% endif %}
21
+
22
+ {% if generated_token %}
23
+ <div class="alert alert-success">
24
+ <div class="text-center">
25
+ <i class="bi bi-check-circle-fill" style="font-size: 3rem;"></i>
26
+ <h4 class="mt-3">Token Generated Successfully!</h4>
27
+
28
+ <div class="bg-white rounded p-3 my-3">
29
+ <p class="text-muted mb-2">Your Access Token:</p>
30
+ <div class="d-flex align-items-center justify-content-center gap-2">
31
+ <code class="fs-3 text-success" id="generatedToken">{{ generated_token }}</code>
32
+ <button type="button" class="btn btn-primary" onclick="copyToken()">
33
+ <i class="bi bi-clipboard"></i> Copy
34
+ </button>
35
+ </div>
36
+ </div>
37
+
38
+ <div class="alert alert-warning">
39
+ <strong>Important:</strong> Save this token! You'll need it to login and submit your contributions.
40
+ </div>
41
+
42
+ <a href="{{ url_for('auth.login') }}" class="btn btn-primary btn-lg mt-3">
43
+ Continue to Login
44
+ </a>
45
+ </div>
46
+ </div>
47
+ {% else %}
48
+ <form method="POST">
49
+ <div class="mb-4">
50
+ <label for="name" class="form-label">Your Name (Optional)</label>
51
+ <input type="text" class="form-control" id="name" name="name"
52
+ placeholder="Enter your name (leave blank for default)"
53
+ {% if not token_generation_enabled %}disabled{% endif %}>
54
+ </div>
55
+
56
+ <div class="mb-4">
57
+ <label class="form-label">Select Your Role</label>
58
+ <div class="row g-3">
59
+ {% for type in contributor_types %}
60
+ <div class="col-md-6">
61
+ <div class="form-check">
62
+ <input class="form-check-input" type="radio" name="type"
63
+ id="type_{{ type.value }}" value="{{ type.value }}"
64
+ {% if not token_generation_enabled %}disabled{% endif %} required>
65
+ <label class="form-check-label" for="type_{{ type.value }}">
66
+ <strong>{{ type.label }}</strong>
67
+ <br>
68
+ <small class="text-muted">{{ type.description }}</small>
69
+ </label>
70
+ </div>
71
+ </div>
72
+ {% endfor %}
73
+ </div>
74
+ </div>
75
+
76
+ <button type="submit" class="btn btn-success btn-lg w-100"
77
+ {% if not token_generation_enabled %}disabled{% endif %}>
78
+ Generate My Token
79
+ </button>
80
+ </form>
81
+
82
+ <div class="mt-4 pt-4 border-top text-center">
83
+ <a href="{{ url_for('auth.login') }}" class="text-decoration-none">
84
+ Already have a token? Login here
85
+ </a>
86
+ </div>
87
+ {% endif %}
88
+ </div>
89
+ </div>
90
+ </div>
91
+
92
+ <script>
93
+ function copyToken() {
94
+ const token = document.getElementById('generatedToken').textContent;
95
+ navigator.clipboard.writeText(token).then(() => {
96
+ alert('Token copied to clipboard!');
97
+ });
98
+ }
99
+ </script>
100
+ {% endblock %}
app/templates/login.html ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends "base.html" %}
2
+
3
+ {% block title %}Login - Participatory Planning{% endblock %}
4
+
5
+ {% block content %}
6
+ <div class="min-vh-100 gradient-bg d-flex align-items-center justify-content-center p-4">
7
+ <div class="card shadow-lg" style="max-width: 500px; width: 100%;">
8
+ <div class="card-body p-5">
9
+ <div class="text-center mb-4">
10
+ <i class="bi bi-people-fill text-primary" style="font-size: 4rem;"></i>
11
+ <h1 class="h3 mt-3 mb-2">Participatory Planning</h1>
12
+ <p class="text-muted">Enter your access token to continue</p>
13
+ </div>
14
+
15
+ <form method="POST">
16
+ <div class="mb-3">
17
+ <label for="token" class="form-label">Access Token</label>
18
+ <input type="text" class="form-control form-control-lg" id="token" name="token"
19
+ placeholder="Enter your token" required autofocus>
20
+ </div>
21
+
22
+ <button type="submit" class="btn btn-primary btn-lg w-100">Login</button>
23
+ </form>
24
+
25
+ <div class="mt-4 pt-4 border-top text-center">
26
+ <a href="{{ url_for('auth.generate') }}" class="btn btn-success btn-sm">
27
+ <i class="bi bi-key-fill"></i> Don't have a token? Generate one here
28
+ </a>
29
+ </div>
30
+ </div>
31
+ </div>
32
+ </div>
33
+ {% endblock %}
app/templates/submit.html ADDED
@@ -0,0 +1,170 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends "base.html" %}
2
+
3
+ {% block title %}Submit Contribution{% endblock %}
4
+
5
+ {% block content %}
6
+ <div class="min-vh-100 bg-light">
7
+ <nav class="navbar navbar-light bg-white shadow-sm">
8
+ <div class="container-fluid">
9
+ <span class="navbar-brand mb-0 h1">Submit Your Contribution</span>
10
+ <a href="{{ url_for('auth.logout') }}" class="btn btn-outline-secondary">Logout</a>
11
+ </div>
12
+ </nav>
13
+
14
+ <div class="container py-5">
15
+ <div class="row justify-content-center">
16
+ <div class="col-lg-8">
17
+ <div class="card shadow">
18
+ <div class="card-body p-4">
19
+ <div class="mb-4">
20
+ <h5 class="card-title">Your Contribution</h5>
21
+ <p class="text-muted">
22
+ Contributor Type: <span class="badge bg-primary text-capitalize">{{ contributor_type }}</span>
23
+ </p>
24
+ </div>
25
+
26
+ {% if not submission_open %}
27
+ <div class="alert alert-warning">
28
+ <i class="bi bi-exclamation-triangle-fill"></i>
29
+ Submission period is currently closed.
30
+ </div>
31
+ {% endif %}
32
+
33
+ <form method="POST" id="submissionForm">
34
+ <div class="mb-3">
35
+ <label for="message" class="form-label">Your Message</label>
36
+ <textarea class="form-control" id="message" name="message" rows="6"
37
+ placeholder="Share your expectations, objectives, concerns, ideas..."
38
+ {% if not submission_open %}disabled{% endif %} required></textarea>
39
+ </div>
40
+
41
+ <div class="mb-3">
42
+ <label class="form-label">Location (Optional)</label>
43
+ <div class="d-flex gap-2 flex-wrap mb-2">
44
+ <button type="button" class="btn btn-outline-primary" onclick="toggleMap()">
45
+ <i class="bi bi-geo-alt-fill"></i> <span id="mapToggleText">Add Location</span>
46
+ </button>
47
+ <button type="button" class="btn btn-outline-success" onclick="getCurrentLocation()">
48
+ <i class="bi bi-crosshair"></i> Use My Location
49
+ </button>
50
+ <button type="button" class="btn btn-outline-danger" onclick="clearLocation()" id="clearBtn" style="display: none;">
51
+ <i class="bi bi-x-circle"></i> Clear
52
+ </button>
53
+ </div>
54
+ <div id="locationDisplay" class="text-muted small" style="display: none;"></div>
55
+
56
+ <div id="mapContainer" style="display: none;">
57
+ <div id="map" class="map-container border mt-2"></div>
58
+ </div>
59
+
60
+ <input type="hidden" id="latitude" name="latitude">
61
+ <input type="hidden" id="longitude" name="longitude">
62
+ </div>
63
+
64
+ <button type="submit" class="btn btn-primary btn-lg w-100"
65
+ {% if not submission_open %}disabled{% endif %}>
66
+ <i class="bi bi-send-fill"></i> Submit Contribution
67
+ </button>
68
+ </form>
69
+
70
+ <div class="mt-4 pt-4 border-top text-center">
71
+ <p class="text-muted mb-0">
72
+ Your submissions: <strong class="text-primary">{{ submission_count }}</strong>
73
+ </p>
74
+ </div>
75
+ </div>
76
+ </div>
77
+ </div>
78
+ </div>
79
+ </div>
80
+ </div>
81
+
82
+ <script>
83
+ let map = null;
84
+ let marker = null;
85
+ let mapVisible = false;
86
+
87
+ function toggleMap() {
88
+ mapVisible = !mapVisible;
89
+ const container = document.getElementById('mapContainer');
90
+ const toggleText = document.getElementById('mapToggleText');
91
+
92
+ if (mapVisible) {
93
+ container.style.display = 'block';
94
+ toggleText.textContent = 'Hide Map';
95
+ if (!map) {
96
+ initMap();
97
+ }
98
+ } else {
99
+ container.style.display = 'none';
100
+ toggleText.textContent = 'Add Location';
101
+ }
102
+ }
103
+
104
+ function initMap() {
105
+ map = L.map('map').setView([0, 0], 2);
106
+
107
+ L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
108
+ attribution: '© OpenStreetMap'
109
+ }).addTo(map);
110
+
111
+ map.on('click', function(e) {
112
+ setLocation(e.latlng.lat, e.latlng.lng);
113
+ });
114
+
115
+ setTimeout(() => map.invalidateSize(), 100);
116
+ }
117
+
118
+ function setLocation(lat, lng) {
119
+ document.getElementById('latitude').value = lat;
120
+ document.getElementById('longitude').value = lng;
121
+
122
+ const display = document.getElementById('locationDisplay');
123
+ display.textContent = `Selected: ${lat.toFixed(6)}, ${lng.toFixed(6)}`;
124
+ display.style.display = 'block';
125
+
126
+ document.getElementById('clearBtn').style.display = 'inline-block';
127
+
128
+ if (map) {
129
+ if (marker) {
130
+ map.removeLayer(marker);
131
+ }
132
+ marker = L.marker([lat, lng]).addTo(map);
133
+ map.setView([lat, lng], 13);
134
+ }
135
+ }
136
+
137
+ function getCurrentLocation() {
138
+ if (navigator.geolocation) {
139
+ navigator.geolocation.getCurrentPosition(
140
+ (position) => {
141
+ const lat = position.coords.latitude;
142
+ const lng = position.coords.longitude;
143
+ setLocation(lat, lng);
144
+
145
+ if (!mapVisible) {
146
+ toggleMap();
147
+ }
148
+ },
149
+ (error) => {
150
+ alert('Unable to get your location.');
151
+ }
152
+ );
153
+ } else {
154
+ alert('Geolocation is not supported by your browser.');
155
+ }
156
+ }
157
+
158
+ function clearLocation() {
159
+ document.getElementById('latitude').value = '';
160
+ document.getElementById('longitude').value = '';
161
+ document.getElementById('locationDisplay').style.display = 'none';
162
+ document.getElementById('clearBtn').style.display = 'none';
163
+
164
+ if (marker && map) {
165
+ map.removeLayer(marker);
166
+ marker = null;
167
+ }
168
+ }
169
+ </script>
170
+ {% endblock %}
app_hf.py ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Hugging Face Spaces entry point
3
+ This wraps the Flask app for Hugging Face deployment
4
+ """
5
+ import os
6
+ from app import create_app
7
+
8
+ # Create Flask app
9
+ app = create_app()
10
+
11
+ # Hugging Face Spaces uses port 7860
12
+ if __name__ == "__main__":
13
+ port = int(os.environ.get("PORT", 7860))
14
+ app.run(host="0.0.0.0", port=port, debug=False)
requirements.txt ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ Flask==3.0.0
2
+ Flask-SQLAlchemy==3.1.1
3
+ python-dotenv==1.0.0
4
+ transformers==4.36.0
5
+ torch==2.5.0
6
+ sentencepiece>=0.2.0
7
+ gunicorn==21.2.0
wsgi.py ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ WSGI entry point for production deployment
3
+ """
4
+ from app import create_app
5
+
6
+ app = create_app()
7
+
8
+ if __name__ == "__main__":
9
+ app.run()