From 64ab1d041241e23be4a97ddd86a9cb7411961868 Mon Sep 17 00:00:00 2001 From: Paul Bokel Date: Sat, 25 Apr 2026 07:17:47 +0000 Subject: [PATCH] Add attendee type management and staff codes Co-Authored-By: Claude Opus 4.6 --- app.py | 629 +++++++++++++++++- init_db.py | 2 + static/css/style.css | 3 +- templates/attendee/dashboard.html | 7 + templates/attendee/personal.html | 2 +- templates/auth/login.html | 33 +- templates/base.html | 2 +- templates/organizer/attendee_types.html | 5 +- templates/organizer/badges.html | 42 +- .../organizer/breakout_session_detail.html | 2 + .../organizer/create_breakout_session.html | 6 +- templates/organizer/dashboard.html | 93 ++- templates/organizer/edit_attendee.html | 10 + templates/organizer/edit_attendee_type.html | 79 +++ .../organizer/edit_breakout_session.html | 2 +- templates/organizer/event_detail.html | 5 + 16 files changed, 844 insertions(+), 78 deletions(-) create mode 100644 templates/organizer/edit_attendee_type.html diff --git a/app.py b/app.py index a406e64..5d6f525 100644 --- a/app.py +++ b/app.py @@ -23,6 +23,8 @@ from flask_babel import Babel, gettext as _, ngettext, force_locale from reportlab.lib.pagesizes import A4 from reportlab.lib.units import mm from reportlab.pdfgen import canvas +import openpyxl +from openpyxl.styles import Font, PatternFill, Alignment from mysql.connector import Error from config import Config @@ -116,6 +118,7 @@ ENGLISH_TRANSLATIONS = { 'dashboard': 'Dashboard', 'logout': 'Logout', 'login': 'Login', + 'organizer_login': 'Organizer Login', 'register': 'Register', 'events': 'Events', 'attendees': 'Attendees', @@ -296,6 +299,12 @@ ENGLISH_TRANSLATIONS = { 'copy': 'Copy', 'delete': 'Delete', 'confirm_delete_type': 'Are you sure you want to delete this attendee type?', + 'edit_attendee_type': 'Edit Attendee Type', + 'back_to_types': 'Back to Types', + 'type_details': 'Type Details', + 'save_changes': 'Save Changes', + 'edit': 'Edit', + 'cannot_delete_type_with_attendees': 'Cannot delete: %d attendees assigned to this type', 'no_attendee_types_yet': 'No attendee types defined yet.', 'create_first_type': 'Create the first type', 'back_to_event': 'Back to Event', @@ -583,6 +592,22 @@ def generate_attendee_code(): conn.close() +def generate_staff_code(): + """Generate a unique 10-character alphanumeric staff code.""" + chars = string.ascii_uppercase + string.digits + while True: + code = ''.join(random.choices(chars, k=10)) + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute("SELECT id FROM organizers WHERE staff_code = %s", (code,)) + if not cursor.fetchone(): + cursor.close() + conn.close() + return code + cursor.close() + conn.close() + + def generate_type_code(): """Generate a unique 10-character alphanumeric attendee type code.""" chars = string.ascii_uppercase + string.digits @@ -648,6 +673,11 @@ def verify_recaptcha(recaptcha_response): # Rate limiting for failed login attempts failed_login_attempts = {} # {ip_address: [(timestamp, count), ...]} +# Rate limiting for failed personal page attempts (30-minute block) +failed_personal_page_attempts = {} # {ip_address: [(timestamp, count), ...]} +PERSONAL_PAGE_BLOCK_DURATION = 30 * 60 # 30 minutes in seconds +PERSONAL_PAGE_MAX_ATTEMPTS = 5 # Max failed attempts before blocking + def is_ip_blocked(ip_address): """Check if an IP address is blocked due to too many failed login attempts.""" if ip_address not in failed_login_attempts: @@ -671,6 +701,43 @@ def is_ip_blocked(ip_address): return False +def is_ip_blocked_for_personal_page(ip_address): + """Check if an IP address is blocked due to too many failed personal page attempts.""" + if ip_address not in failed_personal_page_attempts: + return False + + # Clean up old entries (older than 30 minutes) + current_time = datetime.now() + failed_personal_page_attempts[ip_address] = [ + (ts, count) for ts, count in failed_personal_page_attempts[ip_address] + if (current_time - ts).total_seconds() < PERSONAL_PAGE_BLOCK_DURATION + ] + + # Check if still blocked + if len(failed_personal_page_attempts[ip_address]) >= PERSONAL_PAGE_MAX_ATTEMPTS: + return True + + # Remove empty entries + if not failed_personal_page_attempts[ip_address]: + del failed_personal_page_attempts[ip_address] + + return False + + +def record_failed_personal_page_attempt(ip_address): + """Record a failed personal page attempt for an IP address.""" + if ip_address not in failed_personal_page_attempts: + failed_personal_page_attempts[ip_address] = [] + + failed_personal_page_attempts[ip_address].append((datetime.now(), 1)) + + +def clear_failed_personal_page_attempts(ip_address): + """Clear failed personal page attempts after successful access.""" + if ip_address in failed_personal_page_attempts: + del failed_personal_page_attempts[ip_address] + + def record_failed_login(ip_address): """Record a failed login attempt for an IP address.""" if ip_address not in failed_login_attempts: @@ -685,6 +752,82 @@ def clear_failed_logins(ip_address): del failed_login_attempts[ip_address] +def ensure_staff_code_column(): + """Ensure the staff_code column exists in organizers table.""" + try: + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute(""" + SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = %s AND TABLE_NAME = 'organizers' AND COLUMN_NAME = 'staff_code' + """, (Config.DB_NAME,)) + if not cursor.fetchone(): + cursor.execute("ALTER TABLE organizers ADD COLUMN staff_code VARCHAR(10) UNIQUE DEFAULT NULL") + conn.commit() + print("Added staff_code column to organizers table") + cursor.close() + conn.close() + except Error as e: + print(f"Error ensuring staff_code column: {e}") + + +def ensure_all_organizers_have_staff_code(): + """Ensure all existing organizers have a staff_code.""" + try: + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + cursor.execute("SELECT id FROM organizers WHERE staff_code IS NULL") + organizers_without_code = cursor.fetchall() + for org in organizers_without_code: + staff_code = generate_staff_code() + cursor.execute("UPDATE organizers SET staff_code = %s WHERE id = %s", (staff_code, org['id'])) + if organizers_without_code: + conn.commit() + print(f"Generated staff_codes for {len(organizers_without_code)} organizers") + cursor.close() + conn.close() + except Error as e: + print(f"Error generating staff codes: {e}") + + +def ensure_staff_staff_code_column(): + """Ensure the staff_code column exists in staff table.""" + try: + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute(""" + SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = %s AND TABLE_NAME = 'staff' AND COLUMN_NAME = 'staff_code' + """, (Config.DB_NAME,)) + if not cursor.fetchone(): + cursor.execute("ALTER TABLE staff ADD COLUMN staff_code VARCHAR(10) UNIQUE DEFAULT NULL") + conn.commit() + print("Added staff_code column to staff table") + cursor.close() + conn.close() + except Error as e: + print(f"Error ensuring staff staff_code column: {e}") + + +def ensure_all_staff_have_staff_code(): + """Ensure all existing staff members have a staff_code.""" + try: + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + cursor.execute("SELECT id FROM staff WHERE staff_code IS NULL") + staff_without_code = cursor.fetchall() + for s in staff_without_code: + staff_code = generate_staff_code() + cursor.execute("UPDATE staff SET staff_code = %s WHERE id = %s", (staff_code, s['id'])) + if staff_without_code: + conn.commit() + print(f"Generated staff_codes for {len(staff_without_code)} staff members") + cursor.close() + conn.close() + except Error as e: + print(f"Error generating staff codes: {e}") + + def allowed_file(filename): """Check if file extension is allowed.""" return '.' in filename and filename.rsplit('.', 1)[1].lower() in Config.ALLOWED_EXTENSIONS @@ -1001,6 +1144,157 @@ def generate_badge_pdf(attendee, event=None): return buffer +def generate_rectangular_badges_pdf(attendees, event=None, attendee_types_map=None): + """Generate an A4 PDF with rectangular labels. + + Label dimensions: 80mm x 50mm + Layout: 2 columns x 5 rows = 10 badges per A4 page. + Each badge contains: attendee type (black bar), name, organization, role, and QR code. + """ + buffer = io.BytesIO() + c = canvas.Canvas(buffer, pagesize=A4) + width, height = A4 # 595.28 x 841.89 points + + # Label dimensions: 80mm x 50mm + label_width_mm = 80 + label_height_mm = 50 + label_width = label_width_mm * mm + label_height = label_height_mm * mm + + badges_per_row = 2 + badges_per_col = 5 + badges_per_page = badges_per_row * badges_per_col + + # Page margins + margin_left_mm = 15 + margin_right_mm = 15 + margin_top_mm = 12 + margin_bottom_mm = 12 + + # Calculate spacing + available_width = width - (margin_left_mm * mm) - (margin_right_mm * mm) + available_height = height - (margin_top_mm * mm) - (margin_bottom_mm * mm) + + # Gap between labels + gap_x = (available_width - badges_per_row * label_width) / (badges_per_row + 1) + gap_y = (available_height - badges_per_col * label_height) / (badges_per_col + 1) + + # Badge positions (starting from top-left) + badge_positions = [] + for row in range(badges_per_col): + for col in range(badges_per_row): + x = margin_left_mm * mm + gap_x + col * (label_width + gap_x) + y = height - margin_top_mm * mm - gap_y - row * (label_height + gap_y) - label_height + badge_positions.append((x, y)) + + # Draw badges for each attendee + for i, attendee in enumerate(attendees): + badge_index = i % badges_per_page + + # New page if needed + if badge_index == 0 and i > 0: + c.showPage() + + if badge_index < len(badge_positions): + x, y = badge_positions[badge_index] + + # Draw label border (light gray) + c.setStrokeColorRGB(0.7, 0.7, 0.7) + c.setLineWidth(0.5) + c.rect(x, y, label_width, label_height, fill=False, stroke=True) + + # Get attendee type name from mapping + attendee_type_id = attendee.get('attendee_type_id') + attendee_type_name = '' + if attendee_type_id and attendee_types_map: + type_info = attendee_types_map.get(attendee_type_id, {}) + attendee_type_name = type_info.get('name', '') or '' + + # Draw black bar at top with attendee type in white + if attendee_type_name: + bar_height = 8 * mm + c.setFillColorRGB(0, 0, 0) + c.rect(x, y + label_height - bar_height, label_width, bar_height, fill=True, stroke=False) + c.setFillColorRGB(1, 1, 1) + c.setFont("Helvetica-Bold", 16) + c.drawCentredString(x + label_width / 2, y + label_height - bar_height + 1.5 * mm, attendee_type_name[:20]) + + # Draw attendee name (20pt) + first_name = attendee.get('first_name', '') or '' + last_name = attendee.get('last_name', '') or '' + full_name = f"{first_name} {last_name}".strip() + + font_size = 20 + max_name_width = label_width - 2 * mm + name_width = c.stringWidth(full_name, "Helvetica-Bold", font_size) + if name_width > max_name_width: + # Scale down to fit + font_size = int(font_size * max_name_width / name_width) + + c.setFont("Helvetica-Bold", font_size) + c.setFillColorRGB(0, 0, 0) + + name_y = y + label_height - 22 * mm + c.drawCentredString(x + label_width / 2, name_y, full_name) + + # Draw organization + org = attendee.get('organisation', '') or '' + c.setFont("Helvetica", 14) + org_y = name_y - 8 * mm + if org: + c.drawCentredString(x + label_width / 2, org_y, org[:22]) + + # Draw role + role = attendee.get('role', '') or '' + c.setFont("Helvetica", 14) + if role: + role_y = org_y - 7 * mm + c.drawCentredString(x + label_width / 2, role_y, role[:22]) + + # Generate QR code at bottom-right + attendee_code = attendee.get('attendee_code', '') + if attendee_code: + qr_size = 12 * mm + qr_x = x + label_width - qr_size - 2 * mm + qr_y = y + 10 * mm + + qr = qrcode.QRCode(version=1, box_size=2, border=0) + qr.add_data(attendee_code) + qr.make(fit=True) + qr_img = qr.make_image(fill_color="black", back_color="white") + + qr_buffer = io.BytesIO() + qr_img.save(qr_buffer, format='PNG') + qr_buffer.seek(0) + + from reportlab.lib.utils import ImageReader + qr_reader = ImageReader(qr_buffer) + c.drawImage(qr_reader, qr_x, qr_y, width=qr_size, height=qr_size) + + # Draw black bar at bottom with event name and start date + bar_height = 8 * mm + c.setFillColorRGB(0, 0, 0) + c.rect(x, y, label_width, bar_height, fill=True, stroke=False) + c.setFillColorRGB(1, 1, 1) + c.setFont("Helvetica-Bold", 14) + + event_name = (event.get('name', '') or '') if event else '' + start_time = event.get('start_time', '') if event else '' + event_date_str = '' + if start_time: + if hasattr(start_time, 'strftime'): + event_date_str = start_time.strftime('%d-%m-%Y') + else: + event_date_str = str(start_time)[:10] + + bar_text = f"{event_name} — {event_date_str}" if event_date_str else event_name + c.drawCentredString(x + label_width / 2, y + bar_height / 2 - 1.5 * mm, bar_text) + + c.save() + buffer.seek(0) + return buffer + + def send_badge_email(attendee_email, attendee_name, event_name, pdf_buffer): """Send email with badge PDF attachment.""" subject = f"Your Badge for {event_name}" @@ -1052,6 +1346,65 @@ def index(): return render_template('index.html') +@app.route('/staff/') +def staff_login(staff_code): + """Staff/organizer login via staff_code (no password required).""" + client_ip = request.remote_addr + + # Check if IP is blocked (reuse login rate limiting) + if is_ip_blocked(client_ip): + flash('Too many failed login attempts. Please try again later (blocked for 1 hour).') + return redirect(url_for('index')) + + try: + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + # First check if it's an organizer + cursor.execute("SELECT * FROM organizers WHERE staff_code = %s", (staff_code,)) + organizer = cursor.fetchone() + + if organizer: + cursor.close() + conn.close() + clear_failed_logins(client_ip) + + # Log in the organizer + session['user_id'] = organizer['id'] + session['user_type'] = 'organizer' + session['event_id'] = None + + flash(f'Welcome back, {organizer["name"]}!') + return redirect(url_for('organizer_dashboard')) + + # Check if it's a staff member + cursor.execute("SELECT * FROM staff WHERE staff_code = %s", (staff_code,)) + staff_member = cursor.fetchone() + + cursor.close() + conn.close() + + if not staff_member: + record_failed_login(client_ip) + flash('Invalid staff link.') + return redirect(url_for('index')) + + # Clear failed login attempts on successful staff code login + clear_failed_logins(client_ip) + + # Log in the staff member + session['user_id'] = staff_member['id'] + session['user_type'] = 'staff' + session['event_id'] = staff_member['event_id'] + + flash(f'Welcome back, {staff_member["first_name"]}!') + return redirect(url_for('staff_dashboard')) + + except Error as e: + flash(f'Database error: {e}') + return redirect(url_for('index')) + + # Routes - Auth @app.route('/login', methods=['GET', 'POST']) def login(): @@ -1255,9 +1608,10 @@ def register_organizer(): # Create organizer password_hash = hash_password(password) + staff_code = generate_staff_code() cursor.execute( - "INSERT INTO organizers (email, password_hash, name) VALUES (%s, %s, %s)", - (email, password_hash, name) + "INSERT INTO organizers (email, password_hash, name, staff_code) VALUES (%s, %s, %s, %s)", + (email, password_hash, name, staff_code) ) conn.commit() cursor.close() @@ -1670,6 +2024,17 @@ def create_breakout_session(event_id): flash(f'Database error: {e}') return redirect(url_for('organizer_dashboard')) + # Check if event is single-day + is_single_day = False + prefilled_date = '' + if event.get('start_time') and event.get('end_time'): + start = event['start_time'] + end = event['end_time'] + if hasattr(start, 'date') and hasattr(end, 'date'): + is_single_day = start.date() == end.date() + if is_single_day: + prefilled_date = start.strftime('%Y-%m-%d') + if request.method == 'POST': name = request.form.get('name', '').strip() description = request.form.get('description', '').strip() @@ -1722,7 +2087,7 @@ def create_breakout_session(event_id): except Error as e: flash(f'Database error: {e}') - return render_template('organizer/create_breakout_session.html', event=event) + return render_template('organizer/create_breakout_session.html', event=event, prefilled_date=prefilled_date) @app.route('/organizer/breakout-session//edit', methods=['GET', 'POST']) @@ -2298,6 +2663,72 @@ def manage_attendee_types(event_id): return redirect(url_for('organizer_dashboard')) +@app.route('/organizer/event//attendee-type//edit', methods=['GET', 'POST']) +@login_required +def edit_attendee_type(event_id, type_id): + """Edit an attendee type.""" + if session.get('user_type') != 'organizer': + flash('Access denied.') + return redirect(url_for('index')) + + try: + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute("SELECT * FROM events WHERE id = %s AND organizer_id = %s", (event_id, session['user_id'])) + event = cursor.fetchone() + + if not event: + flash('Event not found.') + cursor.close() + conn.close() + return redirect(url_for('organizer_dashboard')) + + cursor.execute("SELECT * FROM attendee_types WHERE id = %s AND event_id = %s", (type_id, event_id)) + attendee_type = cursor.fetchone() + + if not attendee_type: + flash('Attendee type not found.') + cursor.close() + conn.close() + return redirect(url_for('manage_attendee_types', event_id=event_id)) + + # Check how many attendees are assigned to this type + cursor.execute("SELECT COUNT(*) as count FROM attendees WHERE attendee_type_id = %s", (type_id,)) + attendee_count = cursor.fetchone()['count'] + + if request.method == 'POST': + name = request.form.get('name', '').strip() + price = request.form.get('price', '').strip() + + if not name: + flash('Type name is required.') + cursor.close() + conn.close() + return render_template('organizer/edit_attendee_type.html', event=event, attendee_type=attendee_type) + + try: + price_value = float(price) if price else 0.00 + except ValueError: + price_value = 0.00 + + cursor.execute(""" + UPDATE attendee_types SET name = %s, price = %s WHERE id = %s + """, (name, price_value, type_id)) + conn.commit() + flash(f'Attendee type "{name}" updated successfully!') + cursor.close() + conn.close() + return redirect(url_for('manage_attendee_types', event_id=event_id)) + + cursor.close() + conn.close() + return render_template('organizer/edit_attendee_type.html', event=event, attendee_type=attendee_type, attendee_count=attendee_count) + except Error as e: + flash(f'Database error: {e}') + return redirect(url_for('organizer_dashboard')) + + @app.route('/organizer/event//attendee-type//delete', methods=['POST']) @login_required def delete_attendee_type(event_id, type_id): @@ -2451,10 +2882,11 @@ def manage_event_staff(event_id): return render_template('organizer/event_staff.html', event=event, staff_members=staff_members) invite_token = generate_invite_token() + staff_code = generate_staff_code() cursor.execute(""" - INSERT INTO staff (event_id, email, first_name, last_name, invite_token) - VALUES (%s, %s, %s, %s, %s) - """, (event_id, email, first_name, last_name, invite_token)) + INSERT INTO staff (event_id, email, first_name, last_name, invite_token, staff_code) + VALUES (%s, %s, %s, %s, %s, %s) + """, (event_id, email, first_name, last_name, invite_token, staff_code)) conn.commit() # Get organizer email for sending @@ -2692,10 +3124,11 @@ def batch_add_staff(event_id): added_count = 0 for member in source_staff: if member['email'].lower() not in existing_emails: + staff_code = generate_staff_code() cursor.execute(""" - INSERT INTO staff (event_id, first_name, last_name, email, invite_token, invite_used, created_at) - VALUES (%s, %s, %s, %s, UUID(), FALSE, NOW()) - """, (event_id, member['first_name'], member['last_name'], member['email'])) + INSERT INTO staff (event_id, first_name, last_name, email, invite_token, invite_used, created_at, staff_code) + VALUES (%s, %s, %s, %s, UUID(), FALSE, NOW(), %s) + """, (event_id, member['first_name'], member['last_name'], member['email'], staff_code)) existing_emails.add(member['email'].lower()) added_count += 1 @@ -2776,6 +3209,10 @@ def edit_attendee(attendee_id): conn.close() return redirect(url_for('organizer_dashboard')) + # Get attendee types for this event + cursor.execute("SELECT * FROM attendee_types WHERE event_id = %s ORDER BY name", (attendee['event_id'],)) + attendee_types = cursor.fetchall() + if request.method == 'POST': first_name = request.form.get('first_name', '').strip() last_name = request.form.get('last_name', '').strip() @@ -2783,12 +3220,13 @@ def edit_attendee(attendee_id): organisation = request.form.get('organisation', '').strip() role = request.form.get('role', '').strip() introduction = request.form.get('introduction', '').strip() + attendee_type_id = request.form.get('attendee_type_id', '').strip() cursor.execute(""" UPDATE attendees - SET first_name = %s, last_name = %s, email = %s, organisation = %s, role = %s, introduction = %s + SET first_name = %s, last_name = %s, email = %s, organisation = %s, role = %s, introduction = %s, attendee_type_id = %s WHERE id = %s - """, (first_name, last_name, email, organisation, role, introduction, attendee_id)) + """, (first_name, last_name, email, organisation, role, introduction, attendee_type_id if attendee_type_id else None, attendee_id)) conn.commit() cursor.close() conn.close() @@ -2798,7 +3236,7 @@ def edit_attendee(attendee_id): cursor.close() conn.close() - return render_template('organizer/edit_attendee.html', attendee=attendee) + return render_template('organizer/edit_attendee.html', attendee=attendee, attendee_types=attendee_types) except Error as e: flash(f'Database error: {e}') return redirect(url_for('organizer_dashboard')) @@ -2883,7 +3321,145 @@ def event_badges(event_id): return redirect(url_for('organizer_dashboard')) -@app.route('/organizer/event//stats') +@app.route('/organizer/event//badges/rectangular/download') +@login_required +def download_rectangular_badges(event_id): + """Download rectangular badge PDFs for event.""" + if session.get('user_type') != 'organizer': + flash('Access denied.') + return redirect(url_for('index')) + + try: + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute("SELECT * FROM events WHERE id = %s AND organizer_id = %s", (event_id, session['user_id'])) + event = cursor.fetchone() + + if not event: + flash('Event not found.') + cursor.close() + conn.close() + return redirect(url_for('organizer_dashboard')) + + cursor.execute("SELECT * FROM attendees WHERE event_id = %s ORDER BY last_name, first_name", (event_id,)) + attendees = cursor.fetchall() + + # Get attendee types for this event + cursor.execute("SELECT * FROM attendee_types WHERE event_id = %s", (event_id,)) + attendee_types = cursor.fetchall() + attendee_types_map = {at['id']: at for at in attendee_types} + + cursor.close() + conn.close() + + if not attendees: + flash('No attendees found for this event.') + return redirect(url_for('event_badges', event_id=event_id)) + + # Generate rectangular badge PDF + pdf_buffer = generate_rectangular_badges_pdf(attendees, event, attendee_types_map) + + filename = f"badges_rectangular_{event.get('code', event_id)}.pdf" + return send_file( + pdf_buffer, + mimetype='application/pdf', + as_attachment=True, + download_name=filename + ) + except Error as e: + flash(f'Database error: {e}') + return redirect(url_for('organizer_dashboard')) + + +@app.route('/organizer/event//attendees/excel/download') +@login_required +def download_attendees_excel(event_id): + """Download all attendees as Excel sheet.""" + if session.get('user_type') != 'organizer': + flash('Access denied.') + return redirect(url_for('index')) + + try: + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute("SELECT * FROM events WHERE id = %s AND organizer_id = %s", (event_id, session['user_id'])) + event = cursor.fetchone() + + if not event: + cursor.close() + conn.close() + flash('Event not found.') + return redirect(url_for('organizer_dashboard')) + + cursor.execute("SELECT * FROM attendees WHERE event_id = %s ORDER BY last_name, first_name", (event_id,)) + attendees = cursor.fetchall() + + # Get attendee types + cursor.execute("SELECT * FROM attendee_types WHERE event_id = %s", (event_id,)) + attendee_types = cursor.fetchall() + attendee_types_map = {at['id']: at for at in attendee_types} + + cursor.close() + conn.close() + + if not attendees: + flash('No attendees found.') + return redirect(url_for('event_badges', event_id=event_id)) + + # Create Excel workbook + wb = openpyxl.Workbook() + ws = wb.active + ws.title = "Attendees" + + # Header styling + header_font = Font(bold=True, color="FFFFFF") + header_fill = PatternFill("solid", fgColor="333333") + header_alignment = Alignment(horizontal="center") + + headers = ['ID', 'Voornaam', 'Achternaam', 'Email', 'Organisatie', 'Rol', 'Type', 'Ingecheckt', 'Aangemeld op'] + for col, header in enumerate(headers, 1): + cell = ws.cell(row=1, column=col, value=header) + cell.font = header_font + cell.fill = header_fill + cell.alignment = header_alignment + + # Data rows + for row, attendee in enumerate(attendees, 2): + ws.cell(row=row, column=1, value=attendee.get('id')) + ws.cell(row=row, column=2, value=attendee.get('first_name', '')) + ws.cell(row=row, column=3, value=attendee.get('last_name', '')) + ws.cell(row=row, column=4, value=attendee.get('email', '')) + ws.cell(row=row, column=5, value=attendee.get('organisation', '')) + ws.cell(row=row, column=6, value=attendee.get('role', '')) + ws.cell(row=row, column=7, value=attendee_types_map.get(attendee.get('attendee_type_id'), {}).get('name', '')) + ws.cell(row=row, column=8, value='Ja' if attendee.get('checked_in') else 'Nee') + ws.cell(row=row, column=9, value=str(attendee.get('created_at', ''))[:19] if attendee.get('created_at') else '') + + # Auto-fit column widths + for col in ws.columns: + max_length = 0 + column_letter = col[0].column_letter + for cell in col: + if cell.value: + max_length = max(max_length, len(str(cell.value))) + ws.column_dimensions[column_letter].width = min(max_length + 2, 30) + + filename = f"attendees_{event.get('code', event_id)}.xlsx" + buffer = io.BytesIO() + wb.save(buffer) + buffer.seek(0) + + return send_file( + buffer, + mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + as_attachment=True, + download_name=filename + ) + except Error as e: + flash(f'Database error: {e}') + return redirect(url_for('organizer_dashboard')) @login_required def event_stats(event_id): """Attendance statistics for event.""" @@ -3948,6 +4524,13 @@ def payment_page(code): @app.route('/attendee/personal/') def attendee_personal_page(token): """Personal page accessed via confirmation email link.""" + client_ip = request.remote_addr + + # Check if IP is blocked + if is_ip_blocked_for_personal_page(client_ip): + flash(f'Too many invalid attempts. Please try again later (blocked for 30 minutes).') + return redirect(url_for('index')) + try: conn = get_db_connection() cursor = conn.cursor(dictionary=True) @@ -3956,17 +4539,23 @@ def attendee_personal_page(token): cursor.execute("SELECT * FROM attendees WHERE confirmation_token = %s", (token,)) attendee = cursor.fetchone() + # Fallback: also accept attendee_code for old links where token was cleared if not attendee: - flash('Invalid or expired link.') + cursor.execute("SELECT * FROM attendees WHERE attendee_code = %s", (token,)) + attendee = cursor.fetchone() + + if not attendee: + record_failed_personal_page_attempt(client_ip) + flash('Invalid link.') cursor.close() conn.close() return redirect(url_for('index')) - # Clear confirmation token (one-time use for security) - cursor.execute("UPDATE attendees SET confirmation_token = NULL WHERE id = %s", (attendee['id'],)) - conn.commit() + # Clear failed attempts on successful access + clear_failed_personal_page_attempts(client_ip) # Auto-login the attendee + # Note: confirmation_token is kept so the link remains valid indefinitely session['user_id'] = attendee['id'] session['user_type'] = 'attendee' session['event_id'] = attendee['event_id'] @@ -4030,4 +4619,10 @@ def view_event(event_id): if __name__ == '__main__': + # Ensure staff_code column exists in organizers and staff tables + ensure_staff_code_column() + ensure_all_organizers_have_staff_code() + ensure_staff_staff_code_column() + ensure_all_staff_have_staff_code() + app.run(debug=True, host='0.0.0.0', port=5002) diff --git a/init_db.py b/init_db.py index 24c8906..2621f4a 100644 --- a/init_db.py +++ b/init_db.py @@ -50,6 +50,7 @@ def create_tables(): email VARCHAR(255) UNIQUE NOT NULL, password_hash VARCHAR(255) NOT NULL, name VARCHAR(255) NOT NULL, + staff_code VARCHAR(10) UNIQUE DEFAULT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) """, @@ -163,6 +164,7 @@ def create_tables(): password_hash VARCHAR(255) DEFAULT NULL, first_name VARCHAR(100) NOT NULL, last_name VARCHAR(100) NOT NULL, + staff_code VARCHAR(10) UNIQUE DEFAULT NULL, invite_token VARCHAR(64) DEFAULT NULL, invite_used BOOLEAN DEFAULT FALSE, preferred_language VARCHAR(5) DEFAULT 'en', diff --git a/static/css/style.css b/static/css/style.css index 0a8cf83..05cd132 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -122,11 +122,12 @@ main { padding: 0.75rem 1.5rem; border-radius: 6px; text-decoration: none; - font-weight: 500; + font-weight: 400; text-align: center; cursor: pointer; border: none; transition: all 0.2s; + vertical-align: middle; } .btn-primary { diff --git a/templates/attendee/dashboard.html b/templates/attendee/dashboard.html index 9bf389f..07a7753 100644 --- a/templates/attendee/dashboard.html +++ b/templates/attendee/dashboard.html @@ -3,6 +3,13 @@ {% block title %}{{ 'attendee_dashboard'|t }} - NetEvents{% endblock %} {% block content %} +

{{ 'attendee_dashboard'|t }}

diff --git a/templates/attendee/personal.html b/templates/attendee/personal.html index 1df0f44..a1afe75 100644 --- a/templates/attendee/personal.html +++ b/templates/attendee/personal.html @@ -56,7 +56,7 @@ .personal-page { max-width: 900px; margin: 0 auto; - padding: 20px; + padding: 30px 25px; } .personal-page h1 { diff --git a/templates/auth/login.html b/templates/auth/login.html index 2d8f7f5..1427de9 100644 --- a/templates/auth/login.html +++ b/templates/auth/login.html @@ -5,17 +5,10 @@ {% block content %}
-

{{ 'login'|t }}

- -
- - - - -
+

{{ 'organizer_login'|t }}

- +
@@ -36,26 +29,4 @@

- - {% endblock %} \ No newline at end of file diff --git a/templates/base.html b/templates/base.html index a5cb7d8..4718f94 100644 --- a/templates/base.html +++ b/templates/base.html @@ -31,7 +31,7 @@
-
+
{% with messages = get_flashed_messages() %} {% if messages %}
diff --git a/templates/organizer/attendee_types.html b/templates/organizer/attendee_types.html index 4842c7b..f67997e 100644 --- a/templates/organizer/attendee_types.html +++ b/templates/organizer/attendee_types.html @@ -124,9 +124,8 @@ - - + + diff --git a/templates/organizer/badges.html b/templates/organizer/badges.html index 92a3db9..b29b41f 100644 --- a/templates/organizer/badges.html +++ b/templates/organizer/badges.html @@ -9,6 +9,8 @@

{{ 'attendee_badges'|t }}

@@ -18,7 +20,7 @@

- {{ attendee.first_name }}
+ {{ attendee.first_name }} {{ attendee.last_name }}

@@ -38,27 +40,32 @@ {% if attendee.checked_in %}✓ {{ 'checked_in'|t }}{% else %}{{ 'pending'|t }}{% endif %}
+
+ {{ event.name }} — {{ event.start_time|localized_date if event.start_time else 'TBD' }} +
{% endfor %} @@ -215,7 +295,7 @@ function toggleSection(id) {
Staff ({{ event.staff|length }})
- {% endif %} @@ -241,7 +322,7 @@ function toggleSection(id) {
{{ 'breakout_sessions'|t }} ({{ event.breakout_sessions|length }})
- {% endif %} @@ -271,7 +353,7 @@ function toggleSection(id) {
{{ 'attendees'|t }} ({{ event.attendees|length }})
- {% endif %} diff --git a/templates/organizer/edit_attendee.html b/templates/organizer/edit_attendee.html index 36794d5..09dd153 100644 --- a/templates/organizer/edit_attendee.html +++ b/templates/organizer/edit_attendee.html @@ -37,6 +37,16 @@ +
+ + +
+
{{ 'cancel'|t }} diff --git a/templates/organizer/edit_attendee_type.html b/templates/organizer/edit_attendee_type.html new file mode 100644 index 0000000..2b759e4 --- /dev/null +++ b/templates/organizer/edit_attendee_type.html @@ -0,0 +1,79 @@ +{% extends "base.html" %} + +{% block title %}{{ 'edit_attendee_type'|t }} - {{ event.name }} - NetEvents{% endblock %} + +{% block content %} + + +
+ ← {{ 'back_to_types'|t }} + +

{{ 'edit_attendee_type'|t }} - {{ event.name }}

+ +
+

{{ 'type_details'|t }}

+
+
+
+ + +
+
+ + + {{ 'leave_empty_for_free'|t }} +
+
+ + {{ 'cancel'|t }} + {% if attendee_count == 0 %} + + +
+ {% else %} + + {% endif %} + +
+
+{% endblock %} \ No newline at end of file diff --git a/templates/organizer/edit_breakout_session.html b/templates/organizer/edit_breakout_session.html index 1767445..35b8334 100644 --- a/templates/organizer/edit_breakout_session.html +++ b/templates/organizer/edit_breakout_session.html @@ -3,7 +3,7 @@ {% block title %}{{ 'edit_breakout_session'|t }} - NetEvents{% endblock %} {% block content %} -
+

{{ 'edit_breakout_session'|t }}

{{ 'event'|t }}: {{ event.name }}

diff --git a/templates/organizer/event_detail.html b/templates/organizer/event_detail.html index 657871a..1139fa0 100644 --- a/templates/organizer/event_detail.html +++ b/templates/organizer/event_detail.html @@ -3,6 +3,7 @@ {% block title %}{{ event.name }} - NetEvents{% endblock %} {% block content %} +