Files
HiActs-Conference-Poll/app.py
T
2026-06-08 13:47:37 +02:00

1475 lines
68 KiB
Python

# =============================================================================
# conference_poll / app.py
# Hi-Acts 3rd Network Meeting — Innovation Challenge Arena
# Developed by TXproducts, June 2026
#
# This is the main server file. It does three things:
# 1. Defines all poll questions and answer options (edit these yourself)
# 2. Handles answer submissions from attendees
# 3. Serves all the pages of the app
#
# TO CHANGE QUESTIONS: edit the QUESTIONS list below (lines ~30-120)
# TO START THE APP: run python3 app.py in the terminal
# TO STOP THE APP: press Ctrl+C in the terminal
# =============================================================================
from flask import Flask, render_template, request, jsonify, session, redirect, Response
from collections import defaultdict, Counter
import uuid
import re
import threading
import time
import random
import json
import os
import csv
import io
import smtplib
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from email.mime.base import MIMEBase
from email import encoders
try:
from fpdf import FPDF
FPDF_AVAILABLE = True
except ImportError:
FPDF_AVAILABLE = False
# Create the Flask app
app = Flask(__name__)
# Secret key used to secure browser sessions (keeps each attendee's answers separate)
app.secret_key = 'hiacts-txproducts-2026'
# =============================================================================
# QUESTIONS
# This is the only section you need to edit to change the poll content.
#
# Each question needs:
# 'id' — a unique number (do not change these)
# 'text' — the question shown to attendees (edit freely)
# 'type' — 'multiple_choice' for button answers, 'free_text' for typed answers
# 'options' — the list of answer buttons (multiple_choice only)
# always keep 'Other' as the last option if you want a free-text fallback
#
# After editing, save the file and restart the app to see your changes.
# =============================================================================
QUESTIONS = [
{
'id': 1, 'label': 'Your Role',
'text': 'What best describes your role at the beamline?',
'type': 'multiple_choice',
'options': ['Beamline Scientist', 'Instrument Scientist', 'Industry User', 'Academic User', 'Control System / IT', 'Other']
},
{
'id': 2, 'label': 'Metadata Automation',
'text': 'Do you have automated routines to read and process metadata?',
'type': 'multiple_choice',
'options': ['Yes', 'Partially yes', 'No', 'Other']
},
{
'id': 3, 'label': 'In-Experiment Analysis',
'text': 'To what extent do you analyse experimental data during the experiment?',
'type': 'multiple_choice',
'options': ['Fully', 'Partially', 'Preliminary only', 'Not at all', 'Other']
},
{
'id': 4, 'label': 'On Standardisation',
'text': 'How do you feel about standardising beamline workflows?',
'type': 'multiple_choice',
'options': [
'Necessary and overdue',
'Desirable but very difficult',
'Too rigid for our experimental needs',
'Not our problem to solve',
'Other'
]
},
{
'id': 5, 'label': 'Script Adaptation',
'text': 'Before starting data analysis, do you have to adapt the existing scripts at the beamline?',
'type': 'multiple_choice',
'options': [
'No, my scripts run out of the box',
'I make minor adaptations, e.g. selecting the ROI',
'I need to make significant adaptations',
'I have to rewrite scripts from scratch',
'Other'
]
},
{
'id': 6, 'label': 'Methods Used',
'text': 'What methods do you primarily use?',
'type': 'multiple_choice',
'options': ['Mostly diffraction', 'Mostly spectroscopy', 'Other']
},
{
'id': 7, 'label': 'Time-Resolved',
'text': 'Are you using time-resolved, in-situ or in-operando methods?',
'type': 'multiple_choice',
'options': ['In-situ / in-operando', 'Microsecond dynamics', 'Picosecond / nanosecond dynamics', 'All of the above', 'None', 'Other']
},
{
'id': 8, 'label': 'Analysis Hardware',
'text': 'On what hardware do you analyse your data?',
'type': 'multiple_choice',
'options': ['DESY Maxwell Cluster (or similar)', 'Commercial cloud service', 'Own hardware', 'Other']
},
{
'id': 9, 'label': 'Efficiency Gain',
'text': 'To what extent do you think data standardisation could increase the efficiency of your experiment?',
'type': 'multiple_choice',
'options': ['No need for improvement', 'Minor efficiency increase (<20%)', 'Significant efficiency increase', 'Game changer', 'Other']
},
{
'id': 10, 'label': 'Electronic Labbook',
'text': 'Are you using an electronic labbook?',
'type': 'multiple_choice',
'options': ['Yes', 'No']
},
{
'id': 11, 'label': 'Labbook Connected',
'text': 'Is the labbook connected to your data analysis?',
'type': 'multiple_choice',
'options': ['Yes', 'No']
},
]
# =============================================================================
# DEMO DATA — used only for internal testing, not shown at the conference
# These are 20 simulated participant responses for testing the display page.
# Safe to ignore.
# =============================================================================
SIMULATED_RESPONSES = [
# (Q1 role, Q2 metadata auto, Q3 analysis during, Q4 standardisation, Q5 script adapt, Q6 method, Q7 time-resolved, Q8 hardware, Q9 efficiency, Q10 labbook, Q11 connected)
("Beamline Scientist", "Partially yes", "Partially", "Necessary and overdue", "I make minor adaptations, e.g. selecting the ROI", "Mostly diffraction", "In-situ / in-operando", "DESY Maxwell Cluster (or similar)", "Significant efficiency increase", "Yes", "No"),
("Industry User", "No", "Not at all", "Necessary and overdue", "I have to rewrite scripts from scratch", "Mostly diffraction", "None", "Own hardware", "Game changer", "No", "No"),
("Beamline Scientist", "Partially yes", "Partially", "Desirable but very difficult", "I need to make significant adaptations", "Mostly spectroscopy", "None", "DESY Maxwell Cluster (or similar)", "Significant efficiency increase", "Yes", "Yes"),
("Instrument Scientist", "No", "Preliminary only", "Necessary and overdue", "I have to rewrite scripts from scratch", "Mostly diffraction", "In-situ / in-operando", "DESY Maxwell Cluster (or similar)", "Game changer", "Yes", "No"),
("Academic User", "Yes", "Partially", "Necessary and overdue", "I make minor adaptations, e.g. selecting the ROI", "Mostly spectroscopy", "All of the above", "Own hardware", "Significant efficiency increase", "Yes", "Yes"),
("Control System / IT", "Yes", "Fully", "Desirable but very difficult", "No, my scripts run out of the box", "Mostly diffraction", "None", "DESY Maxwell Cluster (or similar)", "Minor efficiency increase (<20%)", "No", "No"),
("Industry User", "No", "Not at all", "Too rigid for our experimental needs", "I have to rewrite scripts from scratch", "Mostly spectroscopy", "None", "Own hardware", "No need for improvement", "No", "No"),
("Beamline Scientist", "Partially yes", "Partially", "Necessary and overdue", "I need to make significant adaptations", "Mostly diffraction", "Microsecond dynamics", "DESY Maxwell Cluster (or similar)", "Game changer", "Yes", "No"),
("Academic User", "No", "Preliminary only", "Desirable but very difficult", "I have to rewrite scripts from scratch", "Mostly spectroscopy", "Picosecond / nanosecond dynamics", "Own hardware", "Significant efficiency increase", "No", "No"),
("Instrument Scientist", "Partially yes", "Partially", "Necessary and overdue", "I make minor adaptations, e.g. selecting the ROI", "Mostly diffraction", "In-situ / in-operando", "DESY Maxwell Cluster (or similar)", "Significant efficiency increase", "Yes", "Yes"),
("Industry User", "No", "Not at all", "Necessary and overdue", "I have to rewrite scripts from scratch", "Mostly diffraction", "None", "Commercial cloud service", "Game changer", "No", "No"),
("Beamline Scientist", "Yes", "Fully", "Desirable but very difficult", "No, my scripts run out of the box", "Mostly spectroscopy", "None", "DESY Maxwell Cluster (or similar)", "Minor efficiency increase (<20%)", "Yes", "Yes"),
("Academic User", "Partially yes", "Partially", "Necessary and overdue", "I need to make significant adaptations", "Mostly spectroscopy", "In-situ / in-operando", "Own hardware", "Significant efficiency increase", "Yes", "No"),
("Control System / IT", "Yes", "Partially", "Desirable but very difficult", "No, my scripts run out of the box", "Mostly diffraction", "None", "DESY Maxwell Cluster (or similar)", "Significant efficiency increase", "No", "No"),
("Instrument Scientist", "No", "Preliminary only", "Not our problem to solve", "I have to rewrite scripts from scratch", "Mostly diffraction", "Picosecond / nanosecond dynamics", "Own hardware", "No need for improvement", "No", "No"),
("Beamline Scientist", "Partially yes", "Partially", "Necessary and overdue", "I make minor adaptations, e.g. selecting the ROI", "Mostly spectroscopy", "All of the above", "DESY Maxwell Cluster (or similar)", "Game changer", "Yes", "Yes"),
("Academic User", "No", "Not at all", "Desirable but very difficult", "I have to rewrite scripts from scratch", "Mostly spectroscopy", "None", "Own hardware", "Significant efficiency increase", "No", "No"),
("Industry User", "No", "Not at all", "Necessary and overdue", "I have to rewrite scripts from scratch", "Mostly diffraction", "None", "Commercial cloud service", "Game changer", "No", "No"),
("Beamline Scientist", "Yes", "Fully", "Desirable but very difficult", "No, my scripts run out of the box", "Mostly diffraction", "In-situ / in-operando", "DESY Maxwell Cluster (or similar)", "Minor efficiency increase (<20%)", "Yes", "No"),
("Instrument Scientist", "Partially yes", "Preliminary only", "Necessary and overdue", "I need to make significant adaptations", "Mostly spectroscopy", "Microsecond dynamics", "DESY Maxwell Cluster (or similar)", "Significant efficiency increase", "Yes", "No"),
]
# Words excluded when processing free-text answers into display phrases
STOPWORDS = {
'the', 'a', 'an', 'and', 'or', 'but', 'in', 'on', 'at', 'to', 'for',
'of', 'with', 'it', 'is', 'are', 'was', 'be', 'this', 'that', 'i',
'my', 'we', 'our', 'you', 'your', 'they', 'their', 'have', 'has',
'had', 'do', 'does', 'did', 'not', 'no', 'if', 'so', 'as', 'by',
'from', 'would', 'could', 'should', 'all', 'more', 'when', 'then',
'than', 'can', 'will', 'its', 'into', 'just', 'also', 'very',
'data', 'without', 'across', 'one', 'any', 'work', 'now', 'first',
}
# =============================================================================
# DATA STORE — saved to a JSON file so answers survive restarts and wifi drops
# File is written after every submission. Safe to lose power mid-session.
# =============================================================================
DATA_FILE = os.path.join(os.path.dirname(__file__), 'answers.json')
def load_answers():
if os.path.exists(DATA_FILE):
try:
with open(DATA_FILE, 'r') as f:
raw = json.load(f)
result = defaultdict(list)
for k, v in raw.items():
result[int(k)] = v
return result
except Exception:
pass
return defaultdict(list)
def save_answers():
with open(DATA_FILE, 'w') as f:
json.dump({str(k): v for k, v in answers.items()}, f)
answers = load_answers()
# =============================================================================
# EMAIL STORE — collects emails from attendees who want the full analysis report
# Stored separately from answers so it is never wiped by an admin reset.
# =============================================================================
EMAILS_FILE = os.path.join(os.path.dirname(__file__), 'emails.json')
# emails.json stores a list of dicts: {email, token, retained}
# retained=True means the attendee clicked the keep link and must NOT be deleted automatically.
def load_email_records():
if os.path.exists(EMAILS_FILE):
try:
with open(EMAILS_FILE, 'r') as f:
data = json.load(f)
# backward-compat: old format was a plain list of strings
if data and isinstance(data[0], str):
return [{'email': e, 'token': str(uuid.uuid4()), 'retained': False} for e in data]
return data
except Exception:
pass
return []
def load_emails():
return [r['email'] for r in load_email_records()]
def store_email(email):
records = load_email_records()
if any(r['email'] == email for r in records):
return None # already stored, return None so caller knows no token was generated
token = str(uuid.uuid4())
records.append({'email': email, 'token': token, 'retained': False})
with open(EMAILS_FILE, 'w') as f:
json.dump(records, f)
return token
def mark_retained(token):
records = load_email_records()
for r in records:
if r['token'] == token:
r['retained'] = True
with open(EMAILS_FILE, 'w') as f:
json.dump(records, f)
return r['email']
return None
COMMENTS_FILE = os.path.join(os.path.dirname(__file__), 'comments.json')
def load_comments():
if os.path.exists(COMMENTS_FILE):
try:
with open(COMMENTS_FILE, 'r') as f:
return json.load(f)
except Exception:
pass
return []
def store_comment(text):
existing = load_comments()
existing.append(text)
with open(COMMENTS_FILE, 'w') as f:
json.dump(existing, f)
_stream_state = {'running': False, 'progress': 0, 'total': 0}
_session_state = {'active_question': 0}
_session_version = [1] # incremented on reset to invalidate all existing sessions
# =============================================================================
# SMTP CONFIG — set these as environment variables on your server.
# Never hardcode credentials here; this file is in git.
#
# On PythonAnywhere: Web tab → scroll to "Environment variables" section
# MAIL_HOST — your SMTP server (e.g. smtp.office365.com or smtp.gmail.com)
# MAIL_PORT — usually 587
# MAIL_USER — the full sending address (e.g. mblack@txproducts.de)
# MAIL_PASS — your email password or app password
# =============================================================================
MAIL_HOST = os.environ.get('MAIL_HOST', '')
MAIL_PORT = int(os.environ.get('MAIL_PORT', '587'))
MAIL_USER = os.environ.get('MAIL_USER', '')
MAIL_PASS = os.environ.get('MAIL_PASS', '')
BASE_URL = os.environ.get('BASE_URL', 'http://localhost:5001')
ADMIN_PASS = os.environ.get('ADMIN_PASS', 'hiacts2026') # set this in PythonAnywhere env vars
def admin_required(f):
"""Decorator that redirects to /admin/login if the session is not authenticated."""
from functools import wraps
@wraps(f)
def decorated(*args, **kwargs):
if not session.get('admin_ok'):
return redirect('/admin/login')
return f(*args, **kwargs)
return decorated
def get_respondents(snapshot):
"""Build per-respondent dicts using positional alignment across all questions."""
min_len = min((len(snapshot.get(q['id'], [])) for q in QUESTIONS), default=0)
return [
{q['id']: snapshot[q['id']][i]
for q in QUESTIONS if i < len(snapshot[q['id']])}
for i in range(min_len)
]
def pct(a, b):
return int(round(a / b * 100)) if b > 0 else 0
def generate_narrative(respondents, role_counts, champions, pragmatists,
skeptics, n_no_auto, n_script_heavy, n_believers):
n = len(respondents)
if n == 0:
return 'No responses collected yet.'
top_roles = [
f'{cnt} {role.split()[0]}{"s" if cnt > 1 else ""}'
for role, cnt in role_counts.most_common(3)
]
para = (
f'This report captures {n} responses from Hi-Acts participants at DESY Hamburg, '
f'including {", ".join(top_roles)} and others. '
f'The data reveals a community where manual effort remains the norm: '
f'{pct(n_no_auto, n)}% have no automated routines to read and process metadata, '
f'and {pct(n_script_heavy, n)}% must make significant adaptations or fully '
f'rewrite analysis scripts before they can work with beamline data. '
)
if champions > 0 or skeptics > 0:
para += (
f'On standardisation, {pct(champions, n)}% call it necessary and overdue, '
f'while {pct(skeptics, n)}% express resistance. '
)
para += (
f'Despite this divide, {pct(n_believers, n)}% expect standardisation to deliver '
f'a significant efficiency gain or describe it as a game changer for their workflow.'
)
return para
def generate_analysis_pdf():
"""Analytical PDF that combines questions to reveal pain chains, adoption
segments, and role divergences — not available from the live poll display."""
if not FPDF_AVAILABLE:
return None
snapshot = {q['id']: list(answers[q['id']]) for q in QUESTIONS}
respondents = get_respondents(snapshot)
n = len(respondents)
if n == 0:
return None
# ── Computed metrics ───────────────────────────────────────────
role_counts = Counter(r.get(1, 'Other') for r in respondents)
CHAMP_ANS = 'Necessary and overdue'
PRAG_ANS = 'Desirable but very difficult'
SKEP_SET = {'Too rigid for our experimental needs', 'Not our problem to solve'}
SCRIPT_HEAVY = {'I need to make significant adaptations', 'I have to rewrite scripts from scratch'}
BELIEVERS = {'Significant efficiency increase', 'Game changer'}
LIMITED_ANA = {'Preliminary only', 'Not at all'}
champions = sum(1 for r in respondents if r.get(4) == CHAMP_ANS)
pragmatists = sum(1 for r in respondents if r.get(4) == PRAG_ANS)
skeptics = sum(1 for r in respondents if r.get(4) in SKEP_SET)
n_no_auto = sum(1 for r in respondents if r.get(2) == 'No')
n_script_heavy= sum(1 for r in respondents if r.get(5) in SCRIPT_HEAVY)
n_believers = sum(1 for r in respondents if r.get(9) in BELIEVERS)
n_limited_ana = sum(1 for r in respondents if r.get(3) in LIMITED_ANA)
industry = [r for r in respondents if r.get(1) == 'Industry User']
academic = [r for r in respondents if r.get(1) == 'Academic User']
facility = [r for r in respondents if r.get(1) not in ('Industry User', 'Academic User')]
n_ind, n_aca, n_fac = len(industry), len(academic), len(facility)
role_seg = {}
for r in respondents:
role = r.get(1, 'Other')
q4 = r.get(4, '')
if role not in role_seg:
role_seg[role] = {'champ': 0, 'prag': 0, 'skep': 0, 'total': 0}
role_seg[role]['total'] += 1
if q4 == CHAMP_ANS: role_seg[role]['champ'] += 1
elif q4 == PRAG_ANS: role_seg[role]['prag'] += 1
elif q4 in SKEP_SET: role_seg[role]['skep'] += 1
# Adoption segment lists (for cross-tab analysis)
champ_list = [r for r in respondents if r.get(4) == CHAMP_ANS]
prag_list = [r for r in respondents if r.get(4) == PRAG_ANS]
skep_list = [r for r in respondents if r.get(4) in SKEP_SET]
n_champ, n_prag, n_skep = len(champ_list), len(prag_list), len(skep_list)
# Frustrated Champions: want standardisation but have no automation
n_frustrated = sum(1 for r in champ_list if r.get(2) == 'No')
frustrated_roles = Counter(r.get(1, 'Other') for r in champ_list if r.get(2) == 'No')
# Digital maturity score: Q2(0-2) + Q10(0-1) + Q11(0-1) = max 4
def mat_score(r):
s = 2 if r.get(2) == 'Yes' else (1 if r.get(2) == 'Partially yes' else 0)
s += 1 if r.get(10) == 'Yes' else 0
s += 1 if r.get(11) == 'Yes' else 0
return s
role_maturity = {}
for r in respondents:
role_maturity.setdefault(r.get(1, 'Other'), []).append(mat_score(r))
# Time-resolved users vs those using no advanced methods
complex_users = [r for r in respondents if r.get(7) not in ('None', 'Other', '', None)]
simple_users = [r for r in respondents if r.get(7) == 'None']
n_cx, n_sx = len(complex_users), len(simple_users)
# ── Colours ────────────────────────────────────────────────────
DARK = (15, 39, 68)
MID = (26, 74, 138)
LIGHT = (93, 184, 245)
GOLD = (180, 134, 11)
GREEN = (5, 150, 105)
ORANGE = (217, 119, 6)
RED = (220, 38, 38)
GREY = (240, 244, 248)
Q_CLR = [(37,99,235),(5,150,105),(217,119,6),(124,58,237)]
pdf = FPDF()
pdf.set_auto_page_break(auto=True, margin=18)
def draw_row(label, p_val, color, lx=12, lw=64, bx=78, bw=95, rh=6.5):
"""One labelled bar row: label | track+fill | pct. Advances cursor."""
y = pdf.get_y()
pdf.set_xy(lx, y)
pdf.set_font('Helvetica', '', 8)
pdf.set_text_color(50, 60, 80)
pdf.cell(lw, rh, label[:44])
pdf.set_fill_color(*GREY)
pdf.rect(bx, y + rh * 0.22, bw, rh * 0.56, 'F')
if p_val > 0:
pdf.set_fill_color(*color)
pdf.rect(bx, y + rh * 0.22, bw * p_val / 100, rh * 0.56, 'F')
pdf.set_xy(bx + bw + 3, y)
pdf.set_font('Helvetica', 'B', 8)
pdf.set_text_color(*DARK)
pdf.cell(16, rh, f'{p_val}%')
pdf.set_y(y + rh + 0.5)
def sec(title, color=None):
"""Dark coloured section banner."""
c = color or MID
pdf.set_fill_color(*c)
pdf.set_text_color(255, 255, 255)
pdf.set_font('Helvetica', 'B', 9)
pdf.cell(0, 7, f' {title}', fill=True, ln=True)
pdf.ln(3)
def subhead(label):
pdf.set_font('Helvetica', 'B', 8)
pdf.set_text_color(*MID)
pdf.set_x(12)
pdf.cell(0, 5.5, label, ln=True)
# ═══════════════════════════════════════════════════════════════
# PAGE 1: COVER + NARRATIVE
# ═══════════════════════════════════════════════════════════════
pdf.add_page()
pdf.set_fill_color(*DARK)
pdf.rect(0, 0, 210, 56, 'F')
logo_path = os.path.join(os.path.dirname(__file__), 'static', 'logo_white.png')
if os.path.exists(logo_path):
pdf.image(logo_path, x=162, y=6, w=38)
pdf.set_xy(0, 9)
pdf.set_font('Helvetica', 'B', 8)
pdf.set_text_color(*GOLD)
pdf.cell(210, 5, 'HI-ACTS 3RD NETWORK MEETING DESY HAMBURG 10 JUNE 2026', align='C', ln=True)
pdf.ln(2)
pdf.set_font('Helvetica', 'B', 23)
pdf.set_text_color(255, 255, 255)
pdf.cell(210, 11, 'Innovation Challenge Arena', align='C', ln=True)
pdf.set_font('Helvetica', '', 13)
pdf.set_text_color(*LIGHT)
pdf.cell(210, 7, 'In-Depth Analysis Report', align='C', ln=True)
pdf.set_xy(0, 60)
pdf.set_font('Helvetica', '', 9)
pdf.set_text_color(80, 90, 110)
rs = ' / '.join(
f'{cnt} {role.split()[0]}{"s" if cnt>1 else ""}'
for role, cnt in role_counts.most_common(4)
)
pdf.cell(210, 7, f'{n} respondents: {rs}', align='C', ln=True)
pdf.ln(6)
sec('The Story in the Data')
narrative = generate_narrative(respondents, role_counts, champions, pragmatists,
skeptics, n_no_auto, n_script_heavy, n_believers)
pdf.set_font('Helvetica', '', 10)
pdf.set_text_color(30, 40, 60)
pdf.set_x(12)
pdf.multi_cell(186, 6.5, narrative)
pdf.ln(7)
# Three headline stat boxes
for i, (num, lbl) in enumerate([
(f'{pct(n_no_auto, n)}%', 'have no metadata automation'),
(f'{pct(n_script_heavy, n)}%', 'need major script rework'),
(f'{pct(n_believers, n)}%', 'expect significant gain'),
]):
bx = 12 + i * 64
by = pdf.get_y()
pdf.set_fill_color(*DARK)
pdf.rect(bx, by, 60, 22, 'F')
pdf.set_xy(bx, by + 2)
pdf.set_font('Helvetica', 'B', 20)
pdf.set_text_color(*LIGHT)
pdf.cell(60, 10, num, align='C', ln=True)
pdf.set_xy(bx, pdf.get_y())
pdf.set_font('Helvetica', '', 7)
pdf.set_text_color(180, 200, 220)
pdf.cell(60, 6, lbl, align='C', ln=True)
pdf.ln(6)
# Role adoption table — lives on page 1, supporting the narrative directly
sec('Adoption by Role: Champions, Pragmatists and Skeptics', MID)
CX = [12, 84, 120, 158]
CW = [70, 33, 33, 33]
pdf.set_font('Helvetica', 'B', 8)
for i, h in enumerate(['Role', 'Champions', 'Pragmatists', 'Skeptics']):
pdf.set_xy(CX[i], pdf.get_y())
pdf.set_text_color(*([DARK, GREEN, MID, RED][i]))
pdf.cell(CW[i], 5.5, h)
pdf.ln(6)
for role, _ in role_counts.most_common():
seg = role_seg.get(role, {'champ': 0, 'prag': 0, 'skep': 0, 'total': 0})
t = seg['total']
for i, (txt, clr) in enumerate([
(role[:30], DARK),
(f'{pct(seg["champ"],t)}% ({seg["champ"]})', GREEN),
(f'{pct(seg["prag"],t)}% ({seg["prag"]})', MID),
(f'{pct(seg["skep"],t)}% ({seg["skep"]})', RED),
]):
pdf.set_xy(CX[i], pdf.get_y())
pdf.set_font('Helvetica', 'B' if i == 0 else '', 8)
pdf.set_text_color(*clr)
pdf.cell(CW[i], 6, txt)
pdf.ln(7)
# ═══════════════════════════════════════════════════════════════
# PAGE 2: THE PAIN CHAIN
# ═══════════════════════════════════════════════════════════════
pdf.add_page()
sec('The Pain Chain: Automation Gaps to Limited In-Experiment Analysis', DARK)
pdf.set_font('Helvetica', '', 9)
pdf.set_text_color(50, 60, 80)
pdf.set_x(12)
pdf.multi_cell(186, 5.5,
'Q2, Q5, and Q3 form a connected chain: no metadata automation forces manual '
'script rework, which pushes analysis out of the experiment window entirely. '
'Comparing industry users, academic users, and facility staff reveals who bears '
'the greatest cost from this fragmentation.')
pdf.ln(6)
CHAIN_ITEMS = [
('Q2: No automated metadata routines',
n_no_auto,
sum(1 for r in industry if r.get(2) == 'No'),
sum(1 for r in academic if r.get(2) == 'No'),
sum(1 for r in facility if r.get(2) == 'No')),
('Q5: Significant rework or full script rewrite',
n_script_heavy,
sum(1 for r in industry if r.get(5) in SCRIPT_HEAVY),
sum(1 for r in academic if r.get(5) in SCRIPT_HEAVY),
sum(1 for r in facility if r.get(5) in SCRIPT_HEAVY)),
('Q3: Preliminary or no analysis during experiment',
n_limited_ana,
sum(1 for r in industry if r.get(3) in LIMITED_ANA),
sum(1 for r in academic if r.get(3) in LIMITED_ANA),
sum(1 for r in facility if r.get(3) in LIMITED_ANA)),
]
for grp_label, grp_n, grp_color, val_idx in [
(f'All respondents (n={n})', n, RED, 0),
(f'Industry Users (n={n_ind})', n_ind, ORANGE, 1),
(f'Academic Users (n={n_aca})', n_aca, MID, 2),
(f'Facility Staff (n={n_fac})', n_fac, GREEN, 3),
]:
if grp_n == 0:
continue
subhead(grp_label)
for item in CHAIN_ITEMS:
lbl, overall, ind_c, aca_c, fac_c = item
cnt = [overall, ind_c, aca_c, fac_c][val_idx]
draw_row(lbl, pct(cnt, grp_n), grp_color)
pdf.ln(5)
# ═══════════════════════════════════════════════════════════════
# PAGE 3: ADOPTION LANDSCAPE + EFFICIENCY BELIEF BY SEGMENT
# ═══════════════════════════════════════════════════════════════
pdf.add_page()
sec('Adoption Landscape and Efficiency Expectations (Q4 + Q9)', DARK)
pdf.set_font('Helvetica', '', 9)
pdf.set_text_color(50, 60, 80)
pdf.set_x(12)
pdf.multi_cell(186, 5.5,
'Q4 maps attitudes toward standardisation into Champions, Pragmatists, and '
'Skeptics. Crossing this with Q9 (expected efficiency gain) reveals whether '
'attitude aligns with expectation: do Champions expect the biggest gains, and '
'do Skeptics see no value? Divergence between attitude and expectation signals '
'where communication rather than product development is the gap.')
pdf.ln(5)
subhead(f'Overall adoption split (n={n})')
for lbl, cnt, color in [
('Champions "Necessary and overdue"', champions, GREEN),
('Pragmatists "Desirable but very difficult"', pragmatists, MID),
('Skeptics "Too rigid / Not our problem"', skeptics, RED),
]:
draw_row(f'{lbl} ({cnt})', pct(cnt, n), color)
pdf.ln(5)
q9_order = ['No need for improvement', 'Minor efficiency increase (<20%)',
'Significant efficiency increase', 'Game changer']
sec('Q9: Efficiency belief for each adoption segment', MID)
for seg_lbl, seg_list, seg_n, seg_color in [
(f'Champions (n={n_champ})', champ_list, n_champ, GREEN),
(f'Pragmatists (n={n_prag})', prag_list, n_prag, MID),
(f'Skeptics (n={n_skep})', skep_list, n_skep, RED),
]:
if seg_n == 0:
continue
subhead(seg_lbl)
q9c = Counter(r.get(9, '') for r in seg_list if r.get(9))
for ans in q9_order:
draw_row(ans[:44], pct(q9c.get(ans, 0), seg_n), seg_color)
pdf.ln(4)
# ═══════════════════════════════════════════════════════════════
# PAGE 4: ADOPTION-REALITY GAP + FRUSTRATED CHAMPIONS
# ═══════════════════════════════════════════════════════════════
pdf.add_page()
sec('Adoption-Reality Gap: What People Want vs What They Have (Q4 x Q2)', DARK)
pdf.set_font('Helvetica', '', 9)
pdf.set_text_color(50, 60, 80)
pdf.set_x(12)
pdf.multi_cell(186, 5.5,
'Do those who want standardisation already have automation in place, or are '
'they the ones in most pain? Crossing Q4 (attitude) with Q2 (automation level) '
'answers this. A Champion with no automation is a "Frustrated Champion": the '
'clearest signal of unmet demand. A Skeptic who already has automation may '
'resist because their pain has already been solved individually.')
pdf.ln(5)
for seg_lbl, seg_list, seg_n, seg_color in [
(f'Champions (n={n_champ}) -- automation level (Q2)', champ_list, n_champ, GREEN),
(f'Pragmatists (n={n_prag}) -- automation level (Q2)', prag_list, n_prag, MID),
(f'Skeptics (n={n_skep}) -- automation level (Q2)', skep_list, n_skep, RED),
]:
if seg_n == 0:
continue
subhead(seg_lbl)
q2c = Counter(r.get(2, '') for r in seg_list if r.get(2))
for ans, color in [('Yes', GREEN), ('Partially yes', ORANGE), ('No', RED)]:
draw_row(ans, pct(q2c.get(ans, 0), seg_n), color)
pdf.ln(4)
sec(f'Frustrated Champions: want standardisation, have no automation', MID)
pdf.set_font('Helvetica', '', 9)
pdf.set_text_color(50, 60, 80)
pdf.set_x(12)
pdf.multi_cell(186, 5.5,
f'{n_frustrated} of {n_champ} Champions ({pct(n_frustrated, n_champ)}%) '
f'have Q4="Necessary and overdue" but Q2="No automation". '
f'These respondents represent the highest-priority target audience: '
f'motivated, in pain, and ready for a solution.')
pdf.ln(4)
if frustrated_roles:
subhead('Frustrated Champions by role')
for role, cnt in frustrated_roles.most_common():
total_in_role = role_counts.get(role, 1)
draw_row(f'{role[:35]} ({cnt} of {total_in_role})',
pct(cnt, total_in_role), GREEN)
pdf.ln(5)
sec('Skeptic Profile: do Skeptics have automation already? (Q4 x Q2)', RED)
if n_skep > 0:
skep_auto = Counter(r.get(2, '') for r in skep_list if r.get(2))
subhead(f'Automation level among Skeptics (n={n_skep})')
for ans, color in [('Yes', GREEN), ('Partially yes', ORANGE), ('No', RED)]:
draw_row(ans, pct(skep_auto.get(ans, 0), n_skep), color)
pdf.ln(4)
skep_q9 = Counter(r.get(9, '') for r in skep_list if r.get(9))
subhead(f'Efficiency belief among Skeptics (Q9)')
for ans in q9_order:
draw_row(ans[:44], pct(skep_q9.get(ans, 0), n_skep), RED)
# ═══════════════════════════════════════════════════════════════
# PAGE 5: DIGITAL MATURITY COMPOSITE SCORE
# ═══════════════════════════════════════════════════════════════
pdf.add_page()
sec('Digital Maturity Score by Role (Q2 + Q10 + Q11)', DARK)
pdf.set_font('Helvetica', '', 9)
pdf.set_text_color(50, 60, 80)
pdf.set_x(12)
pdf.multi_cell(186, 5.5,
'A composite Digital Maturity Score combines three signals: automated metadata '
'routines (Q2: 2pts=Yes, 1pt=Partially, 0=No), electronic labbook use (Q10: '
'1pt=Yes), and labbook connected to analysis (Q11: 1pt=Yes). Maximum score is '
'4. Roles scoring 0-1 are essentially analog; 2-3 are transitioning; 4 are '
'fully digitally integrated. This reveals where standardisation adoption is '
'most and least likely to happen quickly.')
pdf.ln(5)
subhead('Average Digital Maturity Score by role (max = 4)')
role_avg = {role: sum(scores)/len(scores)
for role, scores in role_maturity.items() if scores}
for role, _ in role_counts.most_common():
avg = role_avg.get(role, 0)
bar_pct = int(round(avg / 4 * 100))
color = GREEN if avg >= 3 else (ORANGE if avg >= 2 else RED)
draw_row(f'{role[:32]} (avg {avg:.1f}/4)', bar_pct, color)
pdf.ln(5)
sec('Labbook Adoption by Role (Q10 + Q11)', MID)
subhead('Labbook users per role (Q10=Yes)')
for role, _ in role_counts.most_common():
role_rs = [r for r in respondents if r.get(1) == role]
lb_count = sum(1 for r in role_rs if r.get(10) == 'Yes')
draw_row(f'{role[:38]} ({lb_count}/{len(role_rs)})',
pct(lb_count, len(role_rs)), GREEN)
pdf.ln(4)
lb_users = [r for r in respondents if r.get(10) == 'Yes']
if lb_users:
conn_yes = sum(1 for r in lb_users if r.get(11) == 'Yes')
subhead(f'Of {len(lb_users)} labbook users, connected to data analysis (Q11=Yes)')
draw_row(f'Connected ({conn_yes})',
pct(conn_yes, len(lb_users)), GREEN)
draw_row(f'Not connected ({len(lb_users)-conn_yes})',
pct(len(lb_users)-conn_yes, len(lb_users)), ORANGE)
pdf.ln(5)
sec('Script Adaptation Burden by Labbook Status (Q5 x Q10)', MID)
pdf.set_font('Helvetica', '', 9)
pdf.set_text_color(50, 60, 80)
pdf.set_x(12)
pdf.multi_cell(186, 5.5,
'Do those with a digital labbook also adapt scripts more efficiently? '
'If labbook users still rewrite scripts at the same rate, the labbook is not '
'yet integrated into the full analytical workflow.')
pdf.ln(4)
no_lb = [r for r in respondents if r.get(10) == 'No']
for grp_lbl, grp_list, grp_color in [
(f'With labbook (n={len(lb_users)})', lb_users, GREEN),
(f'Without labbook (n={len(no_lb)})', no_lb, RED),
]:
if not grp_list:
continue
subhead(f'{grp_lbl} -- script adaptation (Q5)')
q5c = Counter(r.get(5, '') for r in grp_list if r.get(5))
heavy = q5c.get('I need to make significant adaptations', 0) + \
q5c.get('I have to rewrite scripts from scratch', 0)
draw_row('Major adaptation or full rewrite',
pct(heavy, len(grp_list)), grp_color)
easy = q5c.get('No, my scripts run out of the box', 0) + \
q5c.get('I make minor adaptations, e.g. selecting the ROI', 0)
draw_row('Out of the box or minor adaption',
pct(easy, len(grp_list)), GREEN if grp_color == GREEN else MID)
pdf.ln(3)
# ═══════════════════════════════════════════════════════════════
# PAGE 6: METHOD COMPLEXITY CROSS-ANALYSIS
# ═══════════════════════════════════════════════════════════════
pdf.add_page()
sec('Method Complexity vs Pain and Adoption (Q6 + Q7 cross-analysis)', DARK)
pdf.set_font('Helvetica', '', 9)
pdf.set_text_color(50, 60, 80)
pdf.set_x(12)
pdf.multi_cell(186, 5.5,
'Users running time-resolved, in-situ, or in-operando experiments (Q7) face '
'greater technical complexity. This page tests whether that complexity translates '
'into greater pain (more script rework, less automation) and stronger demand for '
'standardisation, or whether complex users have built their own solutions.')
pdf.ln(5)
for grp_lbl, grp_list, grp_color in [
(f'Time-resolved / in-situ users (n={n_cx})', complex_users, MID),
(f'No advanced timing methods (n={n_sx})', simple_users, ORANGE),
]:
if not grp_list:
continue
grp_n = len(grp_list)
subhead(grp_lbl)
no_auto_g = sum(1 for r in grp_list if r.get(2) == 'No')
script_g = sum(1 for r in grp_list if r.get(5) in SCRIPT_HEAVY)
champ_g = sum(1 for r in grp_list if r.get(4) == CHAMP_ANS)
believer_g = sum(1 for r in grp_list if r.get(9) in BELIEVERS)
draw_row('No metadata automation (Q2=No)', pct(no_auto_g, grp_n), grp_color)
draw_row('Script rework/rewrite needed (Q5)', pct(script_g, grp_n), grp_color)
draw_row('Champion for standardisation (Q4)', pct(champ_g, grp_n), GREEN)
draw_row('Expects significant gain (Q9)', pct(believer_g,grp_n), GREEN)
pdf.ln(4)
sec('Method Type vs Adoption Attitude (Q6 x Q4)', MID)
for method in ['Mostly diffraction', 'Mostly spectroscopy']:
m_list = [r for r in respondents if r.get(6) == method]
if not m_list:
continue
m_n = len(m_list)
subhead(f'{method} users (n={m_n})')
m_champ = sum(1 for r in m_list if r.get(4) == CHAMP_ANS)
m_skep = sum(1 for r in m_list if r.get(4) in SKEP_SET)
m_auto = sum(1 for r in m_list if r.get(2) == 'No')
draw_row('Champions for standardisation', pct(m_champ, m_n), GREEN)
draw_row('Skeptics', pct(m_skep, m_n), RED)
draw_row('No metadata automation (Q2=No)', pct(m_auto, m_n), ORANGE)
pdf.ln(4)
sec('Q7 Methods vs Hardware Used (Q7 x Q8)', MID)
pdf.set_font('Helvetica', '', 9)
pdf.set_text_color(50, 60, 80)
pdf.set_x(12)
pdf.multi_cell(186, 5.5,
'Do time-resolved users rely on high-performance cluster computing while '
'simpler users analyse on their own hardware? If so, standardisation of '
'data formats must accommodate HPC pipelines.')
pdf.ln(4)
for grp_lbl, grp_list in [
(f'Time-resolved users (n={n_cx})', complex_users),
(f'No advanced timing (n={n_sx})', simple_users),
]:
if not grp_list:
continue
grp_n = len(grp_list)
subhead(grp_lbl)
q8c = Counter(r.get(8, '') for r in grp_list if r.get(8))
for i, (hw, cnt) in enumerate(q8c.most_common()):
draw_row(hw[:44], pct(cnt, grp_n), Q_CLR[i % len(Q_CLR)])
pdf.ln(3)
# ═══════════════════════════════════════════════════════════════
# PAGE 7: EFFICIENCY BELIEF DEEP DIVE + HARDWARE OVERVIEW
# ═══════════════════════════════════════════════════════════════
pdf.add_page()
sec('Efficiency Belief Deep Dive: What Drives the "Game Changer" Response? (Q9)', DARK)
pdf.set_font('Helvetica', '', 9)
pdf.set_text_color(50, 60, 80)
pdf.set_x(12)
pdf.multi_cell(186, 5.5,
'Q9 asks how much efficiency gain respondents expect from standardisation. '
'Cross-referencing with pain indicators (Q2, Q5, Q3) reveals whether high '
'expectations come from those in most pain, or whether the belief is '
'independent of current pain level. This is the key validation test: '
'does lived pain predict perceived solution value?')
pdf.ln(5)
believers_list = [r for r in respondents if r.get(9) in BELIEVERS]
low_belief_list = [r for r in respondents if r.get(9) in {'No need for improvement', 'Minor efficiency increase (<20%)'}]
n_bel = len(believers_list)
n_low = len(low_belief_list)
for grp_lbl, grp_list, grp_color in [
(f'High-belief: significant/game-changer (n={n_bel})', believers_list, GREEN),
(f'Low-belief: no need / minor gain (n={n_low})', low_belief_list, RED),
]:
if not grp_list:
continue
grp_n = len(grp_list)
subhead(grp_lbl)
no_a = sum(1 for r in grp_list if r.get(2) == 'No')
scr = sum(1 for r in grp_list if r.get(5) in SCRIPT_HEAVY)
lim = sum(1 for r in grp_list if r.get(3) in LIMITED_ANA)
draw_row('No metadata automation (Q2=No)', pct(no_a, grp_n), grp_color)
draw_row('Major script rework needed (Q5)', pct(scr, grp_n), grp_color)
draw_row('Limited in-experiment analysis (Q3)', pct(lim, grp_n), grp_color)
pdf.ln(4)
sec('Efficiency Belief by Role (Q9 x Q1)', MID)
pdf.set_font('Helvetica', '', 9)
pdf.set_text_color(50, 60, 80)
pdf.set_x(12)
pdf.multi_cell(186, 5.5,
'Which roles are most convinced that standardisation will deliver? '
'This identifies where the strongest internal champions are likely to be found.')
pdf.ln(4)
for role, _ in role_counts.most_common():
role_rs = [r for r in respondents if r.get(1) == role]
role_n = len(role_rs)
bel_cnt = sum(1 for r in role_rs if r.get(9) in BELIEVERS)
color = GREEN if pct(bel_cnt, role_n) >= 60 else (ORANGE if pct(bel_cnt, role_n) >= 35 else RED)
draw_row(f'{role[:36]} (n={role_n})', pct(bel_cnt, role_n), color)
pdf.ln(5)
sec('Analysis Hardware Overview (Q8)', MID)
q8_all = Counter(r.get(8, '') for r in respondents if r.get(8))
for i, (ans, cnt) in enumerate(q8_all.most_common()):
draw_row(ans[:44], pct(cnt, n), Q_CLR[i % len(Q_CLR)])
pdf.ln(4)
subhead('Efficiency belief by hardware (Q8 x Q9: % expecting significant gain)')
for hw, _ in q8_all.most_common():
hw_list = [r for r in respondents if r.get(8) == hw]
hw_n = len(hw_list)
bel = sum(1 for r in hw_list if r.get(9) in BELIEVERS)
draw_row(hw[:44], pct(bel, hw_n), MID)
pdf.ln(5)
pdf.set_font('Helvetica', 'I', 7)
pdf.set_text_color(160, 170, 185)
pdf.cell(0, 5,
'Generated automatically from live poll data. Developed by TXproducts | mblack@txproducts.de',
align='C', ln=True)
return bytes(pdf.output())
def send_confirmation_email(to_email, token):
"""Sends an immediate data-retention notice with a personalised keep-link. Runs in a background thread."""
if not MAIL_HOST or not MAIL_USER or not MAIL_PASS:
print(f'[email] SMTP not configured, skipping confirmation to {to_email}')
return
try:
keep_url = f'{BASE_URL}/keep/{token}'
msg = MIMEMultipart()
msg['From'] = f'HiActs Poll - TXproducts <{MAIL_USER}>'
msg['To'] = to_email
msg['Subject'] = 'Hi-Acts Poll: your data and your full report'
body_html = f"""
<div style="font-family:sans-serif;max-width:560px;margin:0 auto;color:#2d3748">
<div style="background:linear-gradient(135deg,#0f2744,#1a4a8a);padding:28px 32px;border-radius:10px 10px 0 0">
<p style="color:#f5c842;font-size:11px;font-weight:700;letter-spacing:1.2px;text-transform:uppercase;margin:0 0 8px">Hi-Acts 3rd Network Meeting</p>
<h1 style="color:white;font-size:22px;margin:0;line-height:1.3">Thank you for participating</h1>
</div>
<div style="background:#f8fafc;padding:28px 32px;border:1px solid #e2e8f0;border-radius:0 0 10px 10px">
<p>We have received your email address and will send you the full Innovation Challenge Arena analysis report after the event.</p>
<div style="background:#fffbeb;border:1px solid #fcd34d;border-radius:8px;padding:16px 20px;margin:20px 0">
<p style="font-weight:700;color:#92400e;margin:0 0 8px">Your data and your rights (GDPR)</p>
<p style="color:#78350f;font-size:13px;margin:0;line-height:1.7">
Your email address is held by TXproducts UG solely for the purpose of sending you this report.
It will not be shared with or sold to any third party.
It will be permanently deleted by <strong>10 July 2026</strong> unless you choose to keep it on file.
</p>
</div>
<p style="font-size:14px;color:#4a5568;">
<strong>Would you like TXproducts to keep your contact details</strong> for future updates and communications?
If yes, click the button below. If you do nothing, your email will be deleted by 10 July 2026 as stated.
</p>
<div style="text-align:center;margin:24px 0;">
<a href="{keep_url}"
style="background:linear-gradient(135deg,#2563eb,#7c3aed);color:white;text-decoration:none;
padding:14px 32px;border-radius:10px;font-weight:700;font-size:15px;display:inline-block;">
Yes, keep my contact details
</a>
</div>
<p style="font-size:13px;color:#8a9baf;text-align:center;">
Or ignore this email and your data will be deleted automatically.
</p>
<p style="font-size:14px;color:#4a5568;margin-top:20px;">
To request immediate deletion or to exercise any of your rights under GDPR,
contact us at <a href="mailto:mblack@txproducts.de" style="color:#2563eb;">mblack@txproducts.de</a>.
</p>
<p style="color:#8a9baf;font-size:12px;margin-top:24px;border-top:1px solid #e2e8f0;padding-top:16px">
TXproducts UG | mblack@txproducts.de<br>
Data processed under GDPR Article 6(1)(a) on the basis of your consent.
</p>
</div>
</div>
"""
msg.attach(MIMEText(body_html, 'html'))
with smtplib.SMTP(MAIL_HOST, MAIL_PORT, timeout=15) as server:
server.ehlo()
server.starttls()
server.login(MAIL_USER, MAIL_PASS)
server.send_message(msg)
print(f'[email] Confirmation sent to {to_email}')
except Exception as e:
print(f'[email] Failed to send confirmation to {to_email}: {e}')
def send_deletion_confirmation(to_email):
"""Notifies the attendee that their email has been deleted. Call before wiping emails.json."""
if not MAIL_HOST or not MAIL_USER or not MAIL_PASS:
print(f'[email] SMTP not configured, skipping deletion notice to {to_email}')
return
try:
msg = MIMEMultipart()
msg['From'] = f'HiActs Poll - TXproducts <{MAIL_USER}>'
msg['To'] = to_email
msg['Subject'] = 'Hi-Acts Poll: your email address has been deleted'
body_html = f"""
<div style="font-family:sans-serif;max-width:560px;margin:0 auto;color:#2d3748">
<div style="background:linear-gradient(135deg,#0f2744,#1a4a8a);padding:28px 32px;border-radius:10px 10px 0 0">
<p style="color:#f5c842;font-size:11px;font-weight:700;letter-spacing:1.2px;text-transform:uppercase;margin:0 0 8px">Hi-Acts 3rd Network Meeting</p>
<h1 style="color:white;font-size:22px;margin:0;line-height:1.3">Your data has been deleted</h1>
</div>
<div style="background:#f8fafc;padding:28px 32px;border:1px solid #e2e8f0;border-radius:0 0 10px 10px">
<p>This is to confirm that your email address has been permanently deleted from the
Hi-Acts Innovation Challenge Arena poll system operated by TXproducts UG.</p>
<div style="background:#f0fdf4;border:1px solid #86efac;border-radius:8px;padding:16px 20px;margin:20px 0">
<p style="font-weight:700;color:#166534;margin:0 0 6px">Deletion confirmed</p>
<p style="color:#15803d;font-size:13px;margin:0;line-height:1.7">
Your email address is no longer stored in any TXproducts system related to this event.
No further emails will be sent to this address from the Hi-Acts poll.
</p>
</div>
<p style="font-size:14px;color:#4a5568;">
If you have any questions or concerns, contact us at
<a href="mailto:mblack@txproducts.de" style="color:#2563eb;">mblack@txproducts.de</a>.
</p>
<p style="color:#8a9baf;font-size:12px;margin-top:24px;border-top:1px solid #e2e8f0;padding-top:16px">
TXproducts UG | mblack@txproducts.de<br>
This deletion was carried out in accordance with GDPR Article 17 (Right to Erasure).
</p>
</div>
</div>
"""
msg.attach(MIMEText(body_html, 'html'))
with smtplib.SMTP(MAIL_HOST, MAIL_PORT, timeout=15) as server:
server.ehlo()
server.starttls()
server.login(MAIL_USER, MAIL_PASS)
server.send_message(msg)
print(f'[email] Deletion confirmation sent to {to_email}')
except Exception as e:
print(f'[email] Failed to send deletion notice to {to_email}: {e}')
def send_analysis_report(to_email):
"""Generates the PDF and emails it to the given address. Runs in a background thread."""
if not MAIL_HOST or not MAIL_USER or not MAIL_PASS:
print(f'[email] SMTP not configured, skipping report to {to_email}')
return
try:
pdf_bytes = generate_analysis_pdf()
msg = MIMEMultipart()
msg['From'] = f'HiActs Poll - TXproducts <{MAIL_USER}>'
msg['To'] = to_email
msg['Subject'] = 'Hi-Acts Network Meeting - Full Poll Analysis Report'
body_html = f"""
<div style="font-family:sans-serif;max-width:560px;margin:0 auto;color:#2d3748">
<div style="background:linear-gradient(135deg,#0f2744,#1a4a8a);padding:28px 32px;border-radius:10px 10px 0 0">
<p style="color:#f5c842;font-size:11px;font-weight:700;letter-spacing:1.2px;text-transform:uppercase;margin:0 0 8px">Hi-Acts 3rd Network Meeting</p>
<h1 style="color:white;font-size:22px;margin:0;line-height:1.3">Your Poll Analysis Report</h1>
</div>
<div style="background:#f8fafc;padding:28px 32px;border:1px solid #e2e8f0;border-radius:0 0 10px 10px">
<p>Thank you for taking part in the Innovation Challenge Arena poll at DESY Hamburg.</p>
<p>The full analysis report is attached as a PDF. It includes:</p>
<ul style="color:#4a5568;line-height:1.9">
<li>Key findings from all respondents</li>
<li>Top 5 answers per question with response counts</li>
<li>Cross-role breakdown — how different roles answered key questions differently</li>
</ul>
<p style="color:#8a9baf;font-size:12px;margin-top:24px">Developed by TXproducts | mblack@txproducts.de</p>
</div>
</div>
"""
msg.attach(MIMEText(body_html, 'html'))
if pdf_bytes:
part = MIMEBase('application', 'octet-stream')
part.set_payload(pdf_bytes)
encoders.encode_base64(part)
part.add_header('Content-Disposition', 'attachment', filename='HiActs_Analysis_Report.pdf')
msg.attach(part)
with smtplib.SMTP(MAIL_HOST, MAIL_PORT, timeout=15) as server:
server.ehlo()
server.starttls()
server.login(MAIL_USER, MAIL_PASS)
server.send_message(msg)
print(f'[email] Report sent to {to_email}')
except Exception as e:
print(f'[email] Failed to send to {to_email}: {e}')
# =============================================================================
# ROUTES — each function below handles one page or API endpoint
# =============================================================================
# Home page — the attendee poll
@app.route('/')
def index():
# Assign a unique session ID to each visitor so they can only answer once per question
if 'session_id' not in session or session.get('version') != _session_version[0]:
session['session_id'] = str(uuid.uuid4())
session['answered'] = []
session['version'] = _session_version[0]
return render_template('poll.html', questions=QUESTIONS)
# Receives a submitted answer from an attendee
@app.route('/submit', methods=['POST'])
def submit():
data = request.json
q_id = data.get('question_id')
answer = data.get('answer', '').strip()
if not answer or not q_id:
return jsonify({'error': 'Invalid'}), 400
# Reset session if the data was cleared since this session started
if session.get('version') != _session_version[0]:
session['answered'] = []
session['version'] = _session_version[0]
# Only record the answer if this session has not already answered this question
answered = session.get('answered', [])
if q_id not in answered:
answers[q_id].append(answer)
answered = answered + [q_id]
session['answered'] = answered
save_answers()
return jsonify({'ok': True})
# Receives an optional free-text comment from the attendee after all questions are answered
@app.route('/submit_comment', methods=['POST'])
def submit_comment():
data = request.json
text = data.get('comment', '').strip()[:150]
if text:
store_comment(text)
return jsonify({'ok': True})
# API endpoint — returns all current results as JSON for the display and results pages
@app.route('/api/results')
def results():
bubbles = []
for q in QUESTIONS:
q_id = q['id']
q_answers = answers[q_id]
if q['type'] == 'multiple_choice':
# Count how many times each option was chosen
counts = Counter(q_answers)
for text, count in counts.items():
bubbles.append({'text': text, 'value': count, 'question': q['text'], 'q_id': q_id})
else:
# For free-text answers, group by the first 7 words of each response
phrase_counts = Counter()
for a in q_answers:
phrase = ' '.join(a.strip().split()[:7])
if phrase:
phrase_counts[phrase] += 1
for phrase, count in phrase_counts.items():
bubbles.append({'text': phrase, 'value': count, 'question': q['text'], 'q_id': q_id})
total = sum(len(answers[q['id']]) for q in QUESTIONS)
return jsonify({
'bubbles': bubbles,
'total_responses': total,
'streaming': _stream_state['running'],
'stream_progress': _stream_state['progress'],
'stream_total': _stream_state['total'],
})
# QR code join slide — shown on the beamer at the start of the session
@app.route('/join')
def join():
return render_template('join.html')
# Live results display page — shows bubble chart and per-question analysis
@app.route('/display')
def display():
return render_template('display.html', questions=QUESTIONS)
# Per-question bubble chart — linked from the bottom strip on the display page
@app.route('/q/<int:q_id>')
def question_view(q_id):
q = next((x for x in QUESTIONS if x['id'] == q_id), None)
if not q:
return redirect('/display')
return render_template('question_view.html', question=q, questions=QUESTIONS)
# Receives an email address, stores it, and sends an immediate data-retention notice
@app.route('/api/email', methods=['POST'])
def capture_email():
data = request.json
email = (data.get('email') or '').strip()
if email and '@' in email and '.' in email:
token = store_email(email)
if token: # None means duplicate, no need to resend
t = threading.Thread(target=send_confirmation_email, args=(email, token), daemon=True)
t.start()
return jsonify({'ok': True})
# Attendee clicks the keep-link from their confirmation email to opt into retention
@app.route('/keep/<token>')
def keep_data(token):
email = mark_retained(token)
if email:
return f'''<!DOCTYPE html>
<html><head><meta charset="UTF-8"><title>Preference saved</title>
<style>body{{font-family:sans-serif;display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0;background:#f0f4f8}}
.box{{background:white;border-radius:16px;padding:40px 48px;max-width:440px;text-align:center;box-shadow:0 8px 40px rgba(0,0,0,.12)}}
h2{{color:#1a365d;margin-bottom:12px}}p{{color:#4a5568;line-height:1.7}}.tick{{font-size:48px;margin-bottom:16px}}</style>
</head><body><div class="box"><div class="tick">&#10003;</div>
<h2>Preference saved</h2>
<p>Your contact details will be kept on file by TXproducts UG.<br>
You will not receive a deletion notice.<br><br>
To change your mind at any time, email <a href="mailto:mblack@txproducts.de">mblack@txproducts.de</a>.</p>
</div></body></html>'''
return '''<!DOCTYPE html>
<html><head><meta charset="UTF-8"><title>Link not found</title></head>
<body style="font-family:sans-serif;text-align:center;padding:60px;color:#4a5568">
<p>This link has already been used or is no longer valid.</p>
</body></html>''', 404
# Sends the full PDF analysis report to every collected email address (run this after the conference)
@app.route('/admin/login', methods=['GET', 'POST'])
def admin_login():
error = ''
if request.method == 'POST':
if request.form.get('password') == ADMIN_PASS:
session['admin_ok'] = True
return redirect('/admin')
error = 'Incorrect password.'
return f'''<!DOCTYPE html>
<html><head><meta charset="UTF-8"><title>Admin Login</title>
<style>
body{{font-family:sans-serif;display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0;background:#f0f4f8}}
.box{{background:white;border-radius:16px;padding:40px 48px;max-width:360px;width:90%;box-shadow:0 8px 40px rgba(0,0,0,.12)}}
h2{{color:#1a365d;margin:0 0 6px;font-size:20px}}
p{{color:#8a9baf;font-size:13px;margin:0 0 24px}}
input{{width:100%;padding:12px 14px;border:1.5px solid #dde5ee;border-radius:9px;font-size:15px;font-family:inherit;box-sizing:border-box;margin-bottom:12px;outline:none}}
input:focus{{border-color:#2563eb}}
button{{width:100%;padding:13px;background:linear-gradient(135deg,#0f2744,#1a4a8a);color:white;border:none;border-radius:9px;font-size:15px;font-weight:700;cursor:pointer;font-family:inherit}}
.err{{color:#dc2626;font-size:13px;margin-bottom:10px}}
</style></head>
<body><div class="box">
<h2>Admin Access</h2>
<p>Hi-Acts Innovation Challenge Arena</p>
<form method="POST">
{"<div class='err'>" + error + "</div>" if error else ""}
<input type="password" name="password" placeholder="Password" autofocus>
<button type="submit">Enter</button>
</form>
</div></body></html>'''
@app.route('/admin/logout')
def admin_logout():
session.pop('admin_ok', None)
return redirect('/admin/login')
@app.route('/admin/send_all_reports', methods=['POST'])
@admin_required
def send_all_reports():
emails = load_emails()
if not emails:
return jsonify({'ok': False, 'message': 'No emails collected yet'})
for email in emails:
t = threading.Thread(target=send_analysis_report, args=(email,), daemon=True)
t.start()
return jsonify({'ok': True, 'message': f'Sending report to {len(emails)} address(es)'})
# Sends a deletion confirmation to every stored email, then wipes emails.json
@app.route('/admin/delete_and_notify', methods=['POST'])
@admin_required
def delete_and_notify():
records = load_email_records()
to_delete = [r for r in records if not r.get('retained', False)]
if not to_delete:
return jsonify({'ok': False, 'message': 'No emails to delete (all retained or none collected)'})
emails = to_delete # used by notify_then_delete closure below
def notify_then_delete():
records = load_email_records()
to_delete = [r for r in records if not r.get('retained', False)]
to_keep = [r for r in records if r.get('retained', False)]
for r in to_delete:
send_deletion_confirmation(r['email'])
with open(EMAILS_FILE, 'w') as f:
json.dump(to_keep, f)
print(f'[email] Deleted {len(to_delete)} address(es), kept {len(to_keep)} retained address(es)')
t = threading.Thread(target=notify_then_delete, daemon=True)
t.start()
return jsonify({'ok': True, 'message': f'Notifying {len(emails)} address(es) then deleting data'})
# Downloads all poll responses as a CSV file — one row per respondent
@app.route('/admin/export')
@admin_required
def export_csv():
n = max((len(answers[q['id']]) for q in QUESTIONS), default=0)
output = io.StringIO()
writer = csv.writer(output)
writer.writerow(['Respondent'] + [f'Q{q["id"]}: {q["text"]}' for q in QUESTIONS])
for i in range(n):
row = [f'R{i+1}']
for q in QUESTIONS:
q_ans = answers[q['id']]
row.append(q_ans[i] if i < len(q_ans) else '')
writer.writerow(row)
output.seek(0)
return Response(
output.getvalue(),
mimetype='text/csv',
headers={'Content-Disposition': 'attachment; filename=hiacts_poll_results.csv'}
)
# =============================================================================
# DEMO / INTERNAL ROUTES — not linked from any attendee-facing page
# These are used for internal testing only.
# =============================================================================
# Adds a single answer to the store (used by the display page demo button)
@app.route('/submit_demo', methods=['POST'])
def submit_demo():
data = request.json
q_id = data.get('question_id')
answer = data.get('answer', '').strip()
if answer and q_id:
answers[q_id].append(answer)
return jsonify({'ok': True})
# Streams 20 simulated responses gradually over ~2 minutes (for live demo testing)
@app.route('/demo/stream', methods=['POST'])
def demo_stream():
if _stream_state['running']:
return jsonify({'error': 'Already streaming'}), 400
def run():
_stream_state['running'] = True
_stream_state['progress'] = 0
answers.clear()
participants = list(SIMULATED_RESPONSES)
random.shuffle(participants)
_stream_state['total'] = len(participants)
for i, row in enumerate(participants):
q1, q2, q3, q4, q5, q6, q7, q8, q9, q10, q11 = row
answers[1].append(q1); answers[2].append(q2); answers[3].append(q3)
answers[4].append(q4); answers[5].append(q5); answers[6].append(q6)
answers[7].append(q7); answers[8].append(q8); answers[9].append(q9)
answers[10].append(q10); answers[11].append(q11)
_stream_state['progress'] = i + 1
time.sleep(6)
_stream_state['running'] = False
t = threading.Thread(target=run, daemon=True)
t.start()
return jsonify({'ok': True, 'message': '20 participants streaming over ~2 minutes'})
# Returns the current demo streaming progress
@app.route('/demo/status')
def demo_status():
return jsonify(_stream_state)
# Admin page — shows all raw responses per question with bar charts
@app.route('/admin')
@admin_required
def admin():
processed = []
for q in QUESTIONS:
q_answers = answers[q['id']]
processed.append({
'question': q,
'total': len(q_answers),
'counts': dict(Counter(q_answers).most_common())
})
email_records = load_email_records()
return render_template('admin.html', processed=processed,
email_records=email_records,
emails=load_emails(),
comments=load_comments())
# Clears all answers — used by the admin page reset button
@app.route('/admin/reset', methods=['POST'])
@admin_required
def reset():
answers.clear()
save_answers()
_session_version[0] += 1 # invalidates all existing phone sessions
_stream_state['running'] = False
_stream_state['progress'] = 0
with open(COMMENTS_FILE, 'w') as f:
json.dump([], f)
with open(EMAILS_FILE, 'w') as f:
json.dump([], f)
return jsonify({'ok': True})
# Injects answers directly into the store — used by the display page demo button
@app.route('/admin/seed', methods=['POST'])
def seed():
data = request.json
for entry in data:
answers[entry['question_id']].append(entry['answer'])
save_answers()
total = sum(len(answers[q['id']]) for q in QUESTIONS)
return jsonify({'ok': True, 'total': total})
# Generates the analysis PDF on demand and returns it as a download
@app.route('/admin/report')
@admin_required
def report():
pdf_bytes = generate_analysis_pdf()
if not pdf_bytes:
return 'fpdf2 not installed', 500
return Response(
pdf_bytes,
mimetype='application/pdf',
headers={'Content-Disposition': 'inline; filename=HiActs_Analysis_Report.pdf'}
)
# =============================================================================
# START THE APP
# Runs on all network interfaces (0.0.0.0) so phones on the same wifi can connect.
# Port 5001 — access at http://localhost:5001 on this machine.
# =============================================================================
if __name__ == '__main__':
app.run(debug=True, host='0.0.0.0', port=5001, use_reloader=False)