From dec6446d7d6f222010f120914eabc161d52cca1e Mon Sep 17 00:00:00 2001 From: Paul Bokel Date: Sat, 18 Apr 2026 14:53:41 +0000 Subject: [PATCH] Initial commit: conference app with Flask Co-Authored-By: Claude Opus 4.6 --- SPEC.md | 177 + app.py | 4033 +++++++++++++++++ config.py | 35 + favicon.svg | 9 + init_db.py | 302 ++ requirements.txt | 8 + static/css/style.css | 745 +++ static/favicon.svg | 9 + static/js/main.js | 93 + .../19985df3-c12e-44f4-8d12-23ff6a7099a7.jpg | Bin 0 -> 32267 bytes .../4077c4be-c151-492b-b96a-ade05037c574.jpg | Bin 0 -> 32267 bytes .../d247ee5b-e3bc-49b8-be4e-7b5f32d12f8c.jpg | Bin 0 -> 32267 bytes templates/attendee/attendees.html | 50 + templates/attendee/breakout_sessions.html | 85 + templates/attendee/connection_requests.html | 154 + templates/attendee/dashboard.html | 102 + templates/attendee/event.html | 37 + templates/attendee/event_register.html | 488 ++ templates/attendee/payment.html | 191 + templates/attendee/personal.html | 201 + templates/attendee/profile.html | 321 ++ templates/attendee/scan.html | 229 + templates/auth/login.html | 61 + templates/base.html | 56 + templates/index.html | 75 + templates/organizer/all_attendee_types.html | 169 + templates/organizer/attendee_types.html | 182 + templates/organizer/badges.html | 104 + .../organizer/breakout_session_detail.html | 58 + templates/organizer/breakout_sessions.html | 43 + .../organizer/create_breakout_session.html | 47 + templates/organizer/create_event.html | 46 + templates/organizer/dashboard.html | 307 ++ templates/organizer/edit_attendee.html | 46 + .../organizer/edit_breakout_session.html | 47 + .../edit_breakout_session_confirm.html | 126 + templates/organizer/edit_event.html | 36 + templates/organizer/edit_event_confirm.html | 126 + templates/organizer/edit_staff.html | 31 + templates/organizer/event_detail.html | 647 +++ templates/organizer/event_staff.html | 192 + templates/organizer/scan.html | 333 ++ templates/presenter/dashboard.html | 34 + templates/register.html | 85 + templates/staff/dashboard.html | 17 + templates/staff/scan.html | 387 ++ templates/staff/staff_events.html | 86 + templates/staff_invite.html | 34 + 48 files changed, 10644 insertions(+) create mode 100644 SPEC.md create mode 100644 app.py create mode 100644 config.py create mode 100644 favicon.svg create mode 100644 init_db.py create mode 100644 requirements.txt create mode 100644 static/css/style.css create mode 100644 static/favicon.svg create mode 100644 static/js/main.js create mode 100644 static/uploads/19985df3-c12e-44f4-8d12-23ff6a7099a7.jpg create mode 100644 static/uploads/4077c4be-c151-492b-b96a-ade05037c574.jpg create mode 100644 static/uploads/d247ee5b-e3bc-49b8-be4e-7b5f32d12f8c.jpg create mode 100644 templates/attendee/attendees.html create mode 100644 templates/attendee/breakout_sessions.html create mode 100644 templates/attendee/connection_requests.html create mode 100644 templates/attendee/dashboard.html create mode 100644 templates/attendee/event.html create mode 100644 templates/attendee/event_register.html create mode 100644 templates/attendee/payment.html create mode 100644 templates/attendee/personal.html create mode 100644 templates/attendee/profile.html create mode 100644 templates/attendee/scan.html create mode 100644 templates/auth/login.html create mode 100644 templates/base.html create mode 100644 templates/index.html create mode 100644 templates/organizer/all_attendee_types.html create mode 100644 templates/organizer/attendee_types.html create mode 100644 templates/organizer/badges.html create mode 100644 templates/organizer/breakout_session_detail.html create mode 100644 templates/organizer/breakout_sessions.html create mode 100644 templates/organizer/create_breakout_session.html create mode 100644 templates/organizer/create_event.html create mode 100644 templates/organizer/dashboard.html create mode 100644 templates/organizer/edit_attendee.html create mode 100644 templates/organizer/edit_breakout_session.html create mode 100644 templates/organizer/edit_breakout_session_confirm.html create mode 100644 templates/organizer/edit_event.html create mode 100644 templates/organizer/edit_event_confirm.html create mode 100644 templates/organizer/edit_staff.html create mode 100644 templates/organizer/event_detail.html create mode 100644 templates/organizer/event_staff.html create mode 100644 templates/organizer/scan.html create mode 100644 templates/presenter/dashboard.html create mode 100644 templates/register.html create mode 100644 templates/staff/dashboard.html create mode 100644 templates/staff/scan.html create mode 100644 templates/staff/staff_events.html create mode 100644 templates/staff_invite.html diff --git a/SPEC.md b/SPEC.md new file mode 100644 index 0000000..017c519 --- /dev/null +++ b/SPEC.md @@ -0,0 +1,177 @@ +# Networking Event Platform - Specification + +## Project Overview +- **Project Name**: NetEvent +- **Type**: Full-stack web application (Flask + MySQL) +- **Core Functionality**: A platform for organizing networking events where attendees can RSVP, connect, and schedule appointments +- **Target Users**: Event organizers and event attendees + +## Technology Stack +- **Backend**: Python Flask +- **Database**: MySQL (roast.duckdns.org:33062) +- **Frontend**: HTML/CSS/JavaScript with Jinja2 templates + +## Database Schema + +### Tables + +#### `organizers` +| Column | Type | Description | +|--------|------|-------------| +| id | INT PRIMARY KEY AUTO_INCREMENT | Organizer ID | +| email | VARCHAR(255) UNIQUE | Organizer email | +| password_hash | VARCHAR(255) | Hashed password | +| name | VARCHAR(255) | Organizer name | +| created_at | TIMESTAMP | Creation timestamp | + +#### `events` +| Column | Type | Description | +|--------|------|-------------| +| id | INT PRIMARY KEY AUTO_INCREMENT | Event ID | +| organizer_id | INT FOREIGN KEY | Reference to organizers | +| code | VARCHAR(10) UNIQUE | Unique 10-char alphanumeric event code for deep linking | +| name | VARCHAR(255) | Event name | +| description | TEXT | Event description | +| start_time | DATETIME | Event start date/time | +| end_time | DATETIME | Event end date/time | +| location | VARCHAR(255) | Event location | +| max_attendees | INT | Maximum attendees (NULL = unlimited) | +| created_at | TIMESTAMP | Creation timestamp | + +#### `attendees` +| Column | Type | Description | +|--------|------|-------------| +| id | INT PRIMARY KEY AUTO_INCREMENT | Attendee ID | +| event_id | INT FOREIGN KEY | Reference to events | +| email | VARCHAR(255) | Attendee email | +| password_hash | VARCHAR(255) | Hashed password | +| first_name | VARCHAR(100) | First name | +| last_name | VARCHAR(100) | Last name | +| organisation | VARCHAR(255) | Organization/Company | +| role | VARCHAR(255) | Role/Profession | +| introduction | TEXT | Short introduction | +| profile_picture | VARCHAR(255) | Profile picture path | +| created_at | TIMESTAMP | Creation timestamp | + +#### `connections` +| Column | Type | Description | +|--------|------|-------------| +| id | INT PRIMARY KEY AUTO_INCREMENT | Connection ID | +| attendee_id | INT FOREIGN KEY | Requester attendee | +| connected_attendee_id | INT FOREIGN KEY | Connect target attendee | +| status | ENUM('pending','accepted','rejected') | Connection status | +| created_at | TIMESTAMP | Creation timestamp | + +#### `appointments` +| Column | Type | Description | +|--------|------|-------------| +| id | INT PRIMARY KEY AUTO_INCREMENT | Appointment ID | +| event_id | INT FOREIGN KEY | Reference to events | +| requester_id | INT FOREIGN KEY | Requester attendee | +| target_id | INT FOREIGN KEY | Target attendee | +| appointment_time | DATETIME | Proposed meeting time | +| location | VARCHAR(255) | Meeting location | +| notes | TEXT | Appointment notes | +| status | ENUM('pending','accepted','rejected') | Appointment status | +| created_at | TIMESTAMP | Creation timestamp | + +## Functionality Specification + +### Organiser Features +1. **Authentication**: Login/logout for organizers +2. **Event Management**: Create, edit, delete events +3. **Attendee List**: View all attendees for their events +4. **Badge Printing**: Generate printable badge list (PDF-ready HTML) +5. **Attendance Stats**: See check-in counts and attendee statistics +6. **QR Code Scanning**: Mobile camera-based check-in by scanning attendee QR codes + +### Attendee Features +1. **Authentication**: Register/login for attendees +2. **Event RSVP**: Register for events +3. **Profile Management**: Update profile with name, org, role, intro, photo +4. **Connections**: Send/accept/reject connection requests +5. **Appointments**: Request/accept/reject meeting appointments + +### User Interactions & Flows + +#### Organiser Flow +1. Login → Dashboard → Create Event → View Attendees → Print Badges + +#### Attendee Flow +1. Register → Login → RSVP to Event → Manage Profile → Connect with Attendees → Request Appointments + +## API Endpoints + +### Auth +- `POST /api/auth/organizer/register` - Register organizer +- `POST /api/auth/organizer/login` - Login organizer +- `POST /api/auth/attendee/register` - Register attendee +- `POST /api/auth/attendee/login` - Login attendee +- `POST /api/auth/logout` - Logout + +### Events +- `GET /api/events` - List public events +- `POST /api/events` - Create event (organizer) +- `GET /api/events/` - Get event details +- `PUT /api/events/` - Update event +- `DELETE /api/events/` - Delete event + +### Attendees +- `GET /api/events//attendees` - List attendees for event +- `GET /api/attendees/` - Get attendee profile +- `PUT /api/attendees/` - Update attendee profile +- `POST /api/attendees//photo` - Upload profile photo + +### Connections +- `GET /api/connections` - List my connections +- `POST /api/connections` - Send connection request +- `PUT /api/connections/` - Accept/reject connection +- `GET /api/attendees` - Search attendees + +### Appointments +- `GET /api/appointments` - List my appointments +- `POST /api/appointments` - Request appointment +- `PUT /api/appointments/` - Accept/reject appointment + +### Organizer Tools +- `GET /api/organizer/events//badges` - Get badge printable view +- `GET /api/organizer/events//stats` - Get attendance stats +- `GET /api/organizer/events//scan` - QR code scanner page for check-in + +## Security +- Password hashing with bcrypt +- Session-based authentication +- CSRF protection +- SQL injection prevention via parameterized queries + +## File Structure +``` +/home/paul/conference/ +├── app.py # Flask application +├── config.py # Configuration +├── init_db.py # Database initialization script +├── requirements.txt # Python dependencies +├── static/ +│ ├── css/ +│ │ └── style.css +│ └── js/ +│ └── main.js +└── templates/ + ├── base.html + ├── index.html + ├── auth/ + │ ├── login.html + │ └── register.html + ├── organizer/ + │ ├── dashboard.html + │ ├── create_event.html + │ ├── event_detail.html + │ ├── badges.html + │ └── scan.html + └── attendee/ + ├── dashboard.html + ├── event.html + ├── profile.html + ├── connections.html + └── appointments.html +``` diff --git a/app.py b/app.py new file mode 100644 index 0000000..a406e64 --- /dev/null +++ b/app.py @@ -0,0 +1,4033 @@ +import base64 +import io +import os +import random +import smtplib +import string +import uuid +import requests +from datetime import datetime +from email.encoders import encode_base64 +from email.mime.multipart import MIMEMultipart +from email.mime.text import MIMEText +from email.mime.base import MIMEBase +from functools import wraps + +import bcrypt +import mysql.connector +import qrcode +from babel import Locale +from flask import Flask, flash, jsonify, redirect, render_template, request, session, url_for, send_file +from flask_cors import CORS +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 +from mysql.connector import Error + +from config import Config + +app = Flask(__name__, template_folder='templates', static_folder='static') +app.config.from_object(Config) +CORS(app) + +# Supported locales +SUPPORTED_LOCALES = ['en', 'nl', 'de', 'fr', 'es', 'it', 'pl'] + + +def get_locale(): + """Determine the best locale based on URL parameter, cookie, session, or user preference.""" + from flask import session, request + from config import Config + + # 1. Check URL parameter + lang = request.args.get('lang') + if lang and lang in SUPPORTED_LOCALES: + session['locale'] = lang + return lang + + # 2. Check session + if 'locale' in session and session['locale'] in SUPPORTED_LOCALES: + return session['locale'] + + # 3. Check cookie (for returning visitors) + lang = request.cookies.get('locale') + if lang and lang in SUPPORTED_LOCALES: + session['locale'] = lang + return lang + + # 4. Check user preference from database + if 'user_id' in session and 'user_type' in session: + user_lang = get_user_preferred_language(session['user_id'], session.get('user_type')) + if user_lang: + return user_lang + + # 5. Default + return Config.BABEL_DEFAULT_LOCALE + + +def get_locale_string(): + """Return locale as string for Babel.""" + locale = get_locale() + # Convert 'en' to 'en_US' format if needed, but Babel 4.0 should handle short codes + return locale + + +# Initialize Babel with locale_selector +babel = Babel() +babel.init_app( + app, + default_locale=Config.BABEL_DEFAULT_LOCALE, + default_translation_directories=Config.BABEL_TRANSLATION_DIRECTORIES, + locale_selector=get_locale_string +) + + +def get_user_preferred_language(user_id, user_type): + """Get user's preferred language from database.""" + try: + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + if user_type == 'organizer': + cursor.execute("SELECT preferred_language FROM organizers WHERE id = %s", (user_id,)) + elif user_type == 'attendee': + cursor.execute("SELECT preferred_language FROM attendees WHERE id = %s", (user_id,)) + elif user_type == 'staff': + cursor.execute("SELECT preferred_language FROM staff WHERE id = %s", (user_id,)) + else: + cursor.close() + conn.close() + return None + + result = cursor.fetchone() + cursor.close() + conn.close() + + if result and result.get('preferred_language') in SUPPORTED_LOCALES: + return result['preferred_language'] + except Error: + pass + return None + + +# English translations map (replaces database translations) +ENGLISH_TRANSLATIONS = { + 'dashboard': 'Dashboard', + 'logout': 'Logout', + 'login': 'Login', + 'register': 'Register', + 'events': 'Events', + 'attendees': 'Attendees', + 'profile': 'Profile', + 'create_event': 'Create Event', + 'edit_event': 'Edit Event', + 'delete_event': 'Delete Event', + 'event_details': 'Event Details', + 'staff': 'Staff', + 'add_staff': 'Add Staff', + 'remove_staff': 'Remove Staff', + 'breakout_sessions': 'Break-out Sessions', + 'registered_attendees': 'Registered Attendees', + 'checked_in': 'Checked In', + 'not_checked_in': 'Not Checked In', + 'invite_pending': 'Invite Pending', + 'active': 'Active', + 'name': 'Name', + 'email': 'Email', + 'status': 'Status', + 'actions': 'Actions', + 'start': 'Start', + 'end': 'End', + 'location': 'Location', + 'max_attendees': 'Max Attendees', + 'registration_link': 'Registration Link', + 'details': 'Details', + 'unlimited': 'unlimited', + 'registered': 'registered', + 'my_events': 'My Events', + 'no_events_yet': "You haven't created any events yet.", + 'create_first_event': 'Create your first event', + 'organizer': 'Organizer', + 'first_name': 'First Name', + 'last_name': 'Last Name', + 'description': 'Description', + 'view': 'View', + 'remove': 'Remove', + 'add_attendee': 'Add Attendee', + 'add_breakout_session': 'Add Break-out Session', + 'add_staff_member': 'Add Staff Member', + 'no_staff_yet': 'No staff members added yet.', + 'no_breakout_sessions_yet': 'No break-out sessions created yet.', + 'confirm_remove_staff': 'Are you sure you want to remove', + 'from_event': 'from this event', + 'remove_staff_member': 'Remove Staff Member', + 'time': 'Time', + 'organisation': 'Organisation', + 'role': 'Role', + 'role_profession': 'Role / Profession', + 'phone': 'Phone', + 'linkedin': 'LinkedIn', + 'about_me': 'About Me', + 'cancel': 'Cancel', + 'welcome': 'Welcome to NetEvents', + 'connect_with_professionals': 'Connect with professionals at networking events', + 'register_as_organizer': 'Register as Organizer', + 'already_have_account': 'Already have an account? Login as:', + 'presenter': 'Presenter', + 'visitor': 'Visitor', + 'organiser': 'Organiser', + 'upcoming_events': 'Upcoming Events', + 'no_upcoming_events': 'No upcoming events at the moment. Check back soon!', + 'platform_features': 'Platform Features', + 'for_organizers': 'For Organizers', + 'for_attendees': 'For Attendees', + 'view_event': 'View Event', + 'translation_management': 'Translation Management', + 'key': 'Key', + 'translation': 'Translation', + 'language': 'Language', + 'save': 'Save', + 'add': 'Add', + 'translations': 'Translations', + 'presenter_dashboard': 'Presenter Dashboard', + 'my_breakout_sessions': 'My Break-out Sessions', + 'event': 'Event', + 'scan_qr_code': 'Scan QR Code', + 'scan_qr_to_connect': 'Scan QR to connect!', + 'scan_instructions': 'Point your camera at an attendee QR code to check them in', + 'camera_access': 'Camera Access', + 'camera_permission_denied': 'Camera permission denied. Please allow camera access to scan QR codes.', + 'error_reading_qr': 'Error reading QR code', + 'attendee_not_found': 'Attendee not found', + 'already_checked_in': 'Already checked in', + 'check_in_successful': 'Check-in successful', + 'password': 'Password', + 'confirm_password': 'Confirm Password', + 'complete_registration': 'Complete Registration', + 'youre_invited': "You're invited to join", + 'set_password_complete': 'Set a password to complete your registration', + 'your_profile': 'Your Profile', + 'edit_profile': 'Edit Profile', + 'save_profile': 'Save Profile', + 'profile_updated': 'Profile updated successfully', + 'connection_requests': 'Connection Requests', + 'my_connections': 'My Connections', + 'send_connection_request': 'Send Connection Request', + 'connection_sent': 'Connection request sent', + 'accept': 'Accept', + 'reject': 'Reject', + 'pending': 'Pending', + 'accepted': 'Accepted', + 'rejected': 'Rejected', + 'appointments': 'Appointments', + 'request_appointment': 'Request Appointment', + 'appointment_time': 'Appointment Time', + 'appointment_notes': 'Notes', + 'appointment_location': 'Location', + 'request_sent': 'Appointment request sent', + 'appointments_with': 'Appointments with', + 'no_appointments': 'No appointments yet', + 'breakout_session': 'Break-out Session', + 'breakout_session_detail': 'Break-out Session Details', + 'speaker': 'Speaker', + 'session_full': 'Session Full', + 'register_for_session': 'Register for Session', + 'registered_for_session': 'Registered for Session', + 'unregister_from_session': 'Unregister from Session', + 'check_in': 'Check In', + 'check_out': 'Check Out', + 'event_checked_in': 'Event Checked In', + 'event_checked_out': 'Event Checked Out', + 'your_events': 'Your Events', + 'past_events': 'Past Events', + 'no_past_events': 'No past events', + 'event_not_found': 'Event not found', + 'invalid_invite_link': 'Invalid or expired invite link', + 'password_mismatch': 'Passwords do not match', + 'password_too_short': 'Password must be at least 8 characters', + 'registration_successful': 'Registration successful', + 'login_successful': 'Login successful', + 'logout_successful': 'Logout successful', + 'access_denied': 'Access denied', + 'page_not_found': 'Page not found', + 'internal_error': 'Internal server error', + 'db_error': 'Database error', + 'error_adding_staff': 'Error adding staff member', + 'error_checking_in': 'Error checking in attendee', + 'badge_printed': 'Badge printed', + 'no_events': 'No events found', + 'create_event': 'Create Event', + 'edit_breakout_session': 'Edit Break-out Session', + 'delete_breakout_session': 'Delete Break-out Session', + 'view_all': 'View All', + 'loading': 'Loading...', + 'submit': 'Submit', + 'close': 'Close', + 'back': 'Back', + 'next': 'Next', + 'previous': 'Previous', + 'search': 'Search', + 'filter': 'Filter', + 'sort_by': 'Sort by', + 'ascending': 'Ascending', + 'descending': 'Descending', + 'rows_per_page': 'Rows per page', + 'no_data': 'No data available', + 'staff_dashboard': 'Staff Dashboard', + 'select_event': 'Select an Event', + 'no_events_assigned': 'No events assigned to you yet', + 'start_qr_scanner': 'Start QR Scanner', + 'back_to_dashboard': 'Back to Dashboard', + 'download_badge': 'Download Badge', + 'email_badge': 'Email Badge', + 'badge_sent': 'Badge sent to your email!', + 'attendee_types': 'Attendee Types', + 'manage_types': 'Manage Types', + 'create_attendee_type': 'Create Attendee Type', + 'type_name': 'Type Name', + 'e_g_vip_speaker_student': 'e.g., VIP, Speaker, Student', + 'price': 'Price', + 'optional': 'optional', + 'leave_empty_for_free': 'Leave empty for free', + 'create_type': 'Create Type', + 'existing_types': 'Existing Types', + 'registration_link': 'Registration Link', + 'copy': 'Copy', + 'delete': 'Delete', + 'confirm_delete_type': 'Are you sure you want to delete this attendee type?', + 'no_attendee_types_yet': 'No attendee types defined yet.', + 'create_first_type': 'Create the first type', + 'back_to_event': 'Back to Event', + 'link_copied': 'Link copied!', + 'copy_failed': 'Failed to copy link.', + 'free': 'Free', + 'select_all': 'Select All', + 'assign_type': 'Assign Type', + 'no_type': 'No Type', + 'apply': 'Apply', + 'selected': 'selected', + 'type': 'Type', + 'registration_type': 'Registration Type', + 'manage_attendee_types_across_events': 'Manage attendee types across all your events', + 'no_attendee_types_for_event': 'No attendee types defined for this event.', + 'attendees': 'Attendees', + 'manage': 'Manage', + 'payment': 'Payment', + 'complete_payment': 'Complete Payment', + 'back_to_registration': 'Back to Registration', + 'order_summary': 'Order Summary', + 'event_registration': 'Event Registration', + 'attendee_type': 'Attendee Type', + 'attendee': 'Attendee', + 'total': 'Total', + 'name_on_card': 'Name on Card', + 'card_number': 'Card Number', + 'expiry_date': 'Expiry Date', + 'cvv': 'CVV', + 'pay_now': 'Pay Now', + 'payment_secure': 'Your payment is secure and encrypted', + 'john_doe': 'John Doe', + 'mm/yy': 'MM/YY', +} + + +def get_translation(key, locale=None): + """Get translation for a key in English, with underscores replaced by spaces.""" + result = ENGLISH_TRANSLATIONS.get(key, key) + # Replace underscores with spaces and title case + if '_' in str(result): + words = str(result).split('_') + result = ' '.join(word.capitalize() for word in words) + return result + + +def t(key, locale=None): + """Shorthand for get_translation.""" + return get_translation(key, locale) + + +def strftime_to_babel_pattern(strftime_format): + """Convert Python strftime format string to Babel CLDR pattern. + Quotes literal text for proper Babel interpretation. + """ + import re + + # Mapping of strftime codes to Babel CLDR codes + strftime_to_babel = { + '%Y': 'yyyy', + '%y': 'yy', + '%m': 'MM', + '%B': 'MMMM', + '%b': 'MMM', + '%d': 'dd', + '%A': 'EEEE', + '%a': 'EEE', + '%H': 'HH', + '%I': 'hh', + '%M': 'mm', + '%S': 'ss', + '%p': 'a', + '%j': 'DDD', + '%U': 'ww', + '%W': 'ww', + } + + # Find all strftime format codes and their positions + format_code_pattern = re.compile(r'%[A-Za-z]') + + result_parts = [] + last_end = 0 + + for match in format_code_pattern.finditer(strftime_format): + # Get the literal text before this format code + if match.start() > last_end: + literal = strftime_format[last_end:match.start()] + # Quote literal text for Babel (escape single quotes by doubling) + if literal: + quoted_literal = literal.replace("'", "''") + result_parts.append(f"'{quoted_literal}'") + + # Convert the format code + code = match.group() + if code in strftime_to_babel: + result_parts.append(strftime_to_babel[code]) + + last_end = match.end() + + # Handle any remaining literal text after the last format code + if last_end < len(strftime_format): + literal = strftime_format[last_end:] + if literal: + quoted_literal = literal.replace("'", "''") + result_parts.append(f"'{quoted_literal}'") + + return ''.join(result_parts) + + +def localized_date(dt, locale=None, format=None): + """Format a datetime in the specified locale's format.""" + if dt is None: + return '' + + # Ensure dt is a datetime object + from datetime import datetime as dt_class + if not isinstance(dt, dt_class): + # If it's a string or something else, try to parse it or return empty + if isinstance(dt, str): + try: + dt = dt_class.strptime(dt, '%Y-%m-%d %H:%M:%S') + except ValueError: + try: + dt = dt_class.strptime(dt, '%Y-%m-%dT%H:%M:%S') + except ValueError: + return str(dt) if dt else '' + else: + return str(dt) if dt else '' + + if locale is None: + locale = get_locale() + + try: + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + cursor.execute("SELECT date_format FROM languages WHERE code = %s", (locale,)) + result = cursor.fetchone() + cursor.close() + conn.close() + + if format is None and result: + format = strftime_to_babel_pattern(result['date_format']) + elif format is None: + format = 'MMMM d, yyyy h:mm a' + else: + # User provided a format from Jinja filter - convert it too + format = strftime_to_babel_pattern(format) + + # Use babel's date formatting for proper locale support + from babel.dates import format_datetime + return format_datetime(dt, format, locale=locale) + except Exception: + # Fallback to strftime (will only properly show English) + try: + return dt.strftime('%B %d, %Y at %H:%M') + except Exception: + return str(dt) + + +# Jinja2 filters +@app.template_filter('localized_date') +def localized_date_filter(dt, format=None): + return localized_date(dt, get_locale(), format) + + +@app.template_filter('t') +def translate_filter(key): + # Always use English for translations + return get_translation(key, 'en') + + +@app.template_filter('spacify') +def spacify_filter(text): + """Replace underscores with spaces and capitalize each word.""" + if text is None or text == '': + return '' + return ' '.join(word.capitalize() for word in str(text).split('_')) + + +@app.template_filter('default') +def default_filter(value, default_value=''): + """Return default_value if value is none or undefined.""" + if value is None: + return default_value + return value + + +@app.template_filter('format_currency') +def format_currency_filter(value): + """Format a number as currency.""" + if value is None or value == '': + return '' + try: + return f'{float(value):.2f}' + except (ValueError, TypeError): + return str(value) + + +@app.context_processor +def utility_processor(): + """Add utility functions to template context.""" + def get_currency_symbol(): + return '€' # Could be made dynamic based on locale + return dict(get_currency_symbol=get_currency_symbol) + + +# Update session locale when language changes +@app.before_request +def before_request(): + """Before each request, update locale from URL if provided.""" + if 'lang' in request.args: + lang = request.args.get('lang') + if lang in SUPPORTED_LOCALES: + session['locale'] = lang + + +@app.after_request +def prevent_caching(response): + """Prevent proxy caching to ensure fresh translations.""" + response.headers['Cache-Control'] = 'no-store, no-cache, must-revalidate, max-age=0' + response.headers['Pragma'] = 'no-cache' + response.headers['Expires'] = '0' + return response + +# Ensure upload folder exists +os.makedirs(app.config['UPLOAD_FOLDER'], exist_ok=True) + + +def get_db_connection(user=Config.DB_APP_USER, password=Config.DB_APP_PASSWORD): + """Get a database connection.""" + return mysql.connector.connect( + host=Config.DB_HOST, + port=Config.DB_PORT, + user=user, + password=password, + database=Config.DB_NAME + ) + + +def generate_event_code(): + """Generate a unique 10-character alphanumeric event 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 events WHERE code = %s", (code,)) + if not cursor.fetchone(): + cursor.close() + conn.close() + return code + cursor.close() + conn.close() + + +def generate_session_code(): + """Generate a unique 10-character alphanumeric breakout session 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 breakout_sessions WHERE code = %s", (code,)) + if not cursor.fetchone(): + cursor.close() + conn.close() + return code + cursor.close() + conn.close() + + +def generate_attendee_code(): + """Generate a unique 10-character alphanumeric attendee 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 attendees WHERE attendee_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 + while True: + code = ''.join(random.choices(chars, k=10)) + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute("SELECT id FROM attendee_types WHERE code = %s", (code,)) + if not cursor.fetchone(): + cursor.close() + conn.close() + return code + cursor.close() + conn.close() + + +def login_required(f): + """Decorator to require login.""" + @wraps(f) + def decorated_function(*args, **kwargs): + if 'user_id' not in session or 'user_type' not in session: + flash('Please log in first.') + return redirect(url_for('login')) + return f(*args, **kwargs) + return decorated_function + + +def get_current_user(): + """Get current logged-in user from session.""" + if 'user_id' not in session or 'user_type' not in session: + return None, None + return session['user_id'], session['user_type'] + + +# Utility functions +def hash_password(password): + """Hash a password.""" + return bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8') + + +def verify_password(password, hashed): + """Verify a password.""" + return bcrypt.checkpw(password.encode('utf-8'), hashed.encode('utf-8')) + + +def verify_recaptcha(recaptcha_response): + """Verify a reCAPTCHA response with Google.""" + secret_key = app.config.get('RECAPTCHA_SECRET_KEY') + if not secret_key: + return True # Skip verification if no secret key configured + try: + payload = { + 'secret': secret_key, + 'response': recaptcha_response + } + resp = requests.post('https://www.google.com/recaptcha/api/siteverify', data=payload, timeout=10) + result = resp.json() + return result.get('success', False) + except Exception: + return False + + +# Rate limiting for failed login attempts +failed_login_attempts = {} # {ip_address: [(timestamp, count), ...]} + +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: + return False + + # Clean up old entries (older than 1 hour) + current_time = datetime.now() + failed_login_attempts[ip_address] = [ + (ts, count) for ts, count in failed_login_attempts[ip_address] + if (current_time - ts).total_seconds() < 3600 + ] + + # Check if still blocked + if len(failed_login_attempts[ip_address]) >= 5: + return True + + # Remove empty entries + if not failed_login_attempts[ip_address]: + del failed_login_attempts[ip_address] + + return False + + +def record_failed_login(ip_address): + """Record a failed login attempt for an IP address.""" + if ip_address not in failed_login_attempts: + failed_login_attempts[ip_address] = [] + + failed_login_attempts[ip_address].append((datetime.now(), 1)) + + +def clear_failed_logins(ip_address): + """Clear failed login attempts after successful login.""" + if ip_address in failed_login_attempts: + del failed_login_attempts[ip_address] + + +def allowed_file(filename): + """Check if file extension is allowed.""" + return '.' in filename and filename.rsplit('.', 1)[1].lower() in Config.ALLOWED_EXTENSIONS + + +def generate_invite_token(): + """Generate a unique invite token for staff.""" + return uuid.uuid4().hex + + +def send_staff_invite_email(staff_email, staff_name, event_name, invite_token, organizer_email): + """Send invite email to staff member.""" + invite_url = url_for('staff_invite', token=invite_token, _external=True) + + subject = f"You're invited to join {event_name} as Staff" + body = f"""Hello {staff_name}, + +You've been invited to join the event "{event_name}" as a staff member. + +Click the link below to complete your registration and set your password: +{invite_url} + +This link will expire in 7 days. + +Best regards, +The Event Team +""" + + msg = MIMEMultipart() + msg['From'] = Config.MAIL_DEFAULT_SENDER + msg['To'] = staff_email + msg['Subject'] = subject + msg.attach(MIMEText(body, 'plain')) + + try: + with smtplib.SMTP(Config.MAIL_SERVER, Config.MAIL_PORT) as server: + if Config.MAIL_USE_TLS: + server.starttls() + if Config.MAIL_USERNAME: + server.login(Config.MAIL_USERNAME, Config.MAIL_PASSWORD) + server.send_message(msg) + return True + except Exception as e: + print(f"Email error: {e}") + return False + + +def send_attendee_confirmation_email(attendee_email, attendee_name, event_name, event_date, event_location, personal_page_url=None): + """Send confirmation email to attendee after registration.""" + subject = f"Registration confirmed for {event_name}" + + personal_page_section = f""" +Your Personal Page: +{personal_page_url} + +View your registered events and breakout sessions anytime. +""" if personal_page_url else "" + + body = f"""Hello {attendee_name}, + +Your registration for {event_name} has been confirmed! + +Event Details: +- Date: {event_date} +- Location: {event_location}{personal_page_section} +You can now log in to: +- View event details +- RSVP for break-out sessions +- Connect with other attendees + +We look forward to seeing you there! + +Best regards, +The Event Team +""" + + msg = MIMEMultipart() + msg['From'] = Config.MAIL_DEFAULT_SENDER + msg['To'] = attendee_email + msg['Subject'] = subject + msg.attach(MIMEText(body, 'plain')) + + try: + with smtplib.SMTP(Config.MAIL_SERVER, Config.MAIL_PORT) as server: + if Config.MAIL_USE_TLS: + server.starttls() + if Config.MAIL_USERNAME: + server.login(Config.MAIL_USERNAME, Config.MAIL_PASSWORD) + server.send_message(msg) + return True + except Exception as e: + print(f"Email error: {e}") + return False + + +def send_breakout_session_update_email(attendee_email, attendee_name, session_name, event_name, changes_text): + """Send update email to attendee when a breakout session is modified.""" + subject = f"Update on Breakout Session: {session_name}" + + body = f"""Hello {attendee_name}, + +The breakout session "{session_name}" for {event_name} has been updated. + +Changes made: +{changes_text} + +Please log in to view the updated details. + +Best regards, +The Event Team +""" + + msg = MIMEMultipart() + msg['From'] = Config.MAIL_DEFAULT_SENDER + msg['To'] = attendee_email + msg['Subject'] = subject + msg.attach(MIMEText(body, 'plain')) + + try: + with smtplib.SMTP(Config.MAIL_SERVER, Config.MAIL_PORT) as server: + if Config.MAIL_USE_TLS: + server.starttls() + if Config.MAIL_USERNAME: + server.login(Config.MAIL_USERNAME, Config.MAIL_PASSWORD) + server.send_message(msg) + return True + except Exception as e: + print(f"Email error: {e}") + return False + + +def send_event_update_email(attendee_email, attendee_name, event_name, changes_text): + """Send update email to attendee when an event is modified.""" + subject = f"Update on Event: {event_name}" + + body = f"""Hello {attendee_name}, + +The event "{event_name}" has been updated. + +Changes made: +{changes_text} + +Please log in to view the updated details. + +Best regards, +The Event Team +""" + + msg = MIMEMultipart() + msg['From'] = Config.MAIL_DEFAULT_SENDER + msg['To'] = attendee_email + msg['Subject'] = subject + msg.attach(MIMEText(body, 'plain')) + + try: + with smtplib.SMTP(Config.MAIL_SERVER, Config.MAIL_PORT) as server: + if Config.MAIL_USE_TLS: + server.starttls() + if Config.MAIL_USERNAME: + server.login(Config.MAIL_USERNAME, Config.MAIL_PASSWORD) + server.send_message(msg) + return True + except Exception as e: + print(f"Email error: {e}") + return False + + +def generate_badge_pdf(attendee, event=None): + """Generate an A4 PDF badge for an attendee. + + The PDF is designed to be folded horizontally and vertically once, + with the attendee info in the lower-left quarter. + Includes dotted fold lines and QR code. + """ + buffer = io.BytesIO() + c = canvas.Canvas(buffer, pagesize=A4) + width, height = A4 # 595.28 x 841.89 points + + # A4 dimensions in mm + a4_width_mm = 210 + a4_height_mm = 297 + + # Margins - 10cm higher than before (10cm = 100mm) + left_margin = 15 * mm + bottom_margin = 15 * mm + 100 * mm # 10cm higher + + # Dotted line settings + dash_length = 5 + gap_length = 5 + + # Draw vertical dotted line down the middle + center_x = width / 2 + c.setDash(dash_length, gap_length) + c.setLineWidth(0.5) + c.line(center_x, 0, center_x, height) + + # Draw horizontal dotted line over the middle + center_y = height / 2 + c.line(0, center_y, width, center_y) + + # Reset dash for other drawing + c.setDash([]) + + # Draw VISITOR bar at top of bottom-left quadrant, left edge to center, below dotted line + bar_height = 50 # Height of the bar in points (doubled) + c.setFillColorRGB(0, 0, 0) # Black color + c.rect(0, center_y - bar_height, center_x, bar_height, fill=True, stroke=False) + # Draw VISITOR text in white, centered + c.setFillColorRGB(1, 1, 1) # White + c.setFont("Helvetica-Bold", 27) # 150% of 18 + text = "VISITOR" + text_width = c.stringWidth(text, "Helvetica-Bold", 27) + text_x = (center_x - text_width) / 2 + c.drawString(text_x, center_y - bar_height + 12, text) + + # Reset fill color to black for subsequent text + c.setFillColorRGB(0, 0, 0) + + # Font setup - 32pt (twice as high as 16pt) for name, 16pt for org/role + # Bold for name, regular for org/role + c.setFont("Helvetica-Bold", 32) + + # Position in lower-left quarter + # Content y position (from bottom of page) - moved 1.5cm (42.5pt) further up + y_name = bottom_margin + 40 - 42.5 # Start with name + + # Draw attendee first name (bold, 32pt) + first_name = attendee.get('first_name', '') + last_name = attendee.get('last_name', '') + + # Check if names fit within the width limit (half of A4 width minus margins) + max_name_width = (width / 2) - left_margin - 10 # 10pt padding + full_name_width = c.stringWidth(first_name + ' ' + last_name, "Helvetica-Bold", 32) + + # If full name is too wide, reduce font size + font_size = 32 + if full_name_width > max_name_width: + # Try smaller sizes + for size in [28, 24, 20, 18, 16]: + if c.stringWidth(first_name + ' ' + last_name, "Helvetica-Bold", size) <= max_name_width: + font_size = size + break + c.setFont("Helvetica-Bold", font_size) + + # Draw first name + c.drawString(left_margin, y_name, first_name) + # Draw last name on next line + y_last_name = y_name - (font_size * 1.2) + c.drawString(left_margin, y_last_name, last_name) + # Extra blank line after last name + y_org = y_last_name - (font_size * 1.4) + + # Draw organization (regular, 16pt, 150% = 24pt) + c.setFont("Helvetica", 24) + y_org = y_name - 50 + c.drawString(left_margin, y_org, attendee.get('organisation', '') or '') + + # Draw role (regular, 16pt, 150% = 24pt) + c.setFont("Helvetica", 24) + y_role = y_org - 40 + c.drawString(left_margin, y_role, attendee.get('role', '') or '') + + # Generate QR code for attendee_code + attendee_code = attendee.get('attendee_code', '') + if attendee_code: + qr = qrcode.QRCode(version=1, box_size=10, border=1) + qr.add_data(attendee_code) + qr.make(fit=True) + qr_img = qr.make_image(fill_color="black", back_color="white") + + # Convert to bytes and then to ImageReader for reportlab + 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) + + # Draw QR code at bottom-left with 15mm left padding, flush to bottom edge + qr_size = 40 * mm # QR code size + qr_x = 15 * mm # 15mm from left + qr_y = 25 * mm # 10mm from top of bottom_bar (15mm + 10mm) + + c.drawImage(qr_reader, qr_x, qr_y, width=qr_size, height=qr_size) + + # Draw bottom black bar with event info (from bottom edge, 150mm high) + if event: + bar_bottom_height = 15 * mm # 15mm high + c.setFillColorRGB(0, 0, 0) # Black color + c.rect(0, 0, center_x, bar_bottom_height, fill=True, stroke=False) + + # Draw event name centered + c.setFillColorRGB(1, 1, 1) # White + c.setFont("Helvetica-Bold", 14) + event_name = event.get('name', '') or '' + event_name_width = c.stringWidth(event_name, "Helvetica-Bold", 14) + event_name_x = (center_x - event_name_width) / 2 + c.drawString(event_name_x, bar_bottom_height - 21, event_name) + + # Draw start and end date/time on the same line, centered + c.setFont("Helvetica", 10) + start_time = event.get('start_time') + end_time = event.get('end_time') + if start_time and end_time: + start_str = localized_date(start_time, get_locale(), '%d/%m/%Y %H:%M') + end_str = localized_date(end_time, get_locale(), '%d/%m/%Y %H:%M') + combined_str = start_str + " - " + end_str + combined_width = c.stringWidth(combined_str, "Helvetica", 10) + combined_x = (center_x - combined_width) / 2 + c.drawString(combined_x, bar_bottom_height - 37, combined_str) + + 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}" + + body = f"""Hello {attendee_name}, + +Please find attached your badge for {event_name}. + +The badge is designed to be folded once horizontally and once vertically. +Your information appears in the lower-left quarter after folding. + +We look forward to seeing you there! + +Best regards, +The Event Team +""" + + msg = MIMEMultipart() + msg['From'] = Config.MAIL_DEFAULT_SENDER + msg['To'] = attendee_email + msg['Subject'] = subject + msg.attach(MIMEText(body, 'plain')) + + # Attach PDF + pdf_buffer.seek(0) + part = MIMEBase('application', 'octet-stream') + part.set_payload(pdf_buffer.read()) + encode_base64(part) + part.add_header('Content-Disposition', 'attachment', filename=f'badge_{attendee_name.replace(" ", "_")}.pdf') + msg.attach(part) + + try: + with smtplib.SMTP(Config.MAIL_SERVER, Config.MAIL_PORT) as server: + if Config.MAIL_USE_TLS: + server.starttls() + if Config.MAIL_USERNAME: + server.login(Config.MAIL_USERNAME, Config.MAIL_PASSWORD) + server.send_message(msg) + return True + except Exception as e: + print(f"Email error: {e}") + return False + + +# Routes - Main +@app.route('/') +def index(): + """Home page.""" + return render_template('index.html') + + +# Routes - Auth +@app.route('/login', methods=['GET', 'POST']) +def login(): + """Login page - supports both organizer and attendee login.""" + client_ip = request.remote_addr + + # Check if IP is blocked + if is_ip_blocked(client_ip): + flash('Too many failed login attempts. Please try again later (blocked for 1 hour).') + return render_template('auth/login.html') + + if request.method == 'POST': + email = request.form.get('email', '').strip() + password = request.form.get('password', '') + user_type = request.form.get('user_type', 'attendee') + + if not email or not password: + flash('Email and password are required.') + return render_template('auth/login.html') + + try: + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + if user_type == 'organizer': + cursor.execute("SELECT * FROM organizers WHERE email = %s", (email,)) + elif user_type == 'breakout_organizer': + cursor.execute("SELECT * FROM organizers WHERE email = %s", (email,)) + elif user_type == 'staff': + cursor.execute("SELECT * FROM staff WHERE email = %s", (email,)) + else: + cursor.execute("SELECT * FROM attendees WHERE email = %s", (email,)) + + user = cursor.fetchone() + cursor.close() + conn.close() + + if user and verify_password(password, user['password_hash']): + clear_failed_logins(client_ip) + session['user_id'] = user['id'] + session['user_type'] = user_type + session['event_id'] = user.get('event_id') if user_type in ['attendee', 'staff'] else None + if user_type == 'organizer': + flash(f'Welcome back, {user["name"]}!') + return redirect(url_for('organizer_dashboard')) + elif user_type == 'breakout_organizer': + flash(f'Welcome back, {user["name"]}!') + return redirect(url_for('presenter_dashboard')) + elif user_type == 'staff': + flash(f'Welcome back, {user["first_name"]}!') + return redirect(url_for('staff_dashboard')) + else: + flash(f'Welcome back, {user["first_name"]}!') + return redirect(url_for('attendee_dashboard')) + + record_failed_login(client_ip) + flash('Invalid email or password.') + except Error as e: + flash(f'Database error: {e}') + + # Handle pre-selection of user type from query string + default_type = request.args.get('type', 'attendee') + return render_template('auth/login.html', default_type=default_type) + + +@app.route('/presenter/dashboard') +@login_required +def presenter_dashboard(): + """Presenter (break-out organiser) dashboard.""" + if session.get('user_type') != 'breakout_organizer': + flash('Access denied.') + return redirect(url_for('index')) + + try: + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + # Get sessions where this organizer is assigned as presenter + cursor.execute(""" + SELECT bs.*, e.name as event_name + FROM breakout_sessions bs + JOIN breakout_session_organizers bso ON bs.id = bso.breakout_session_id + JOIN events e ON bs.event_id = e.id + WHERE bso.organizer_id = %s + ORDER BY bs.start_time + """, (session['user_id'],)) + sessions = cursor.fetchall() + + cursor.execute("SELECT name FROM organizers WHERE id = %s", (session['user_id'],)) + organizer = cursor.fetchone() + cursor.close() + conn.close() + + return render_template('presenter/dashboard.html', + sessions=sessions, + presenter_name=organizer['name']) + except Error as e: + flash(f'Database error: {e}') + return redirect(url_for('index')) + + +@app.route('/staff/dashboard') +@login_required +def staff_dashboard(): + """Staff dashboard - list events for staff member.""" + if session.get('user_type') != 'staff': + flash('Access denied.') + return redirect(url_for('index')) + + try: + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute("SELECT first_name, last_name FROM staff WHERE id = %s", (session['user_id'],)) + staff_member = cursor.fetchone() + + cursor.execute("SELECT * FROM events WHERE id IN (SELECT event_id FROM staff WHERE id = %s) ORDER BY start_time", (session['user_id'],)) + events = cursor.fetchall() + + cursor.close() + conn.close() + + return render_template('staff/staff_events.html', events=events, staff_name=f"{staff_member['first_name']} {staff_member['last_name']}") + except Error as e: + flash(f'Database error: {e}') + return redirect(url_for('index')) + + +@app.route('/staff//dashboard') +@login_required +def staff_event_dashboard(event_id): + """Staff dashboard for a specific event - only accessible by staff members assigned to this event.""" + if session.get('user_type') != 'staff': + flash('Access denied.') + return redirect(url_for('index')) + + try: + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + # Verify staff is assigned to this event + cursor.execute("SELECT 1 FROM staff WHERE id = %s AND event_id = %s", (session['user_id'], event_id)) + if not cursor.fetchone(): + flash('Access denied.') + cursor.close() + conn.close() + return redirect(url_for('staff_dashboard')) + + cursor.execute("SELECT * FROM events WHERE id = %s", (event_id,)) + event = cursor.fetchone() + + if not event: + flash('Event not found.') + cursor.close() + conn.close() + return redirect(url_for('staff_dashboard')) + + cursor.execute("SELECT first_name, last_name FROM staff WHERE id = %s", (session['user_id'],)) + staff_member = cursor.fetchone() + cursor.close() + conn.close() + + return render_template('staff/dashboard.html', event=event, staff_name=f"{staff_member['first_name']} {staff_member['last_name']}") + except Error as e: + flash(f'Database error: {e}') + return redirect(url_for('staff_dashboard')) + + +@app.route('/register/organizer', methods=['GET', 'POST']) +def register_organizer(): + """Register a new organizer.""" + if request.method == 'POST': + name = request.form.get('name', '').strip() + email = request.form.get('email', '').strip() + password = request.form.get('password', '') + confirm_password = request.form.get('confirm_password', '') + + if not all([name, email, password]): + flash('All fields are required.') + return render_template('register.html', user_type='organizer') + + if password != confirm_password: + flash('Passwords do not match.') + return render_template('register.html', user_type='organizer') + + if len(password) < 6: + flash('Password must be at least 6 characters.') + return render_template('register.html', user_type='organizer') + + try: + conn = get_db_connection() + cursor = conn.cursor() + + # Check if email exists + cursor.execute("SELECT id FROM organizers WHERE email = %s", (email,)) + if cursor.fetchone(): + flash('Email already registered.') + cursor.close() + conn.close() + return render_template('register.html', user_type='organizer') + + # Create organizer + password_hash = hash_password(password) + cursor.execute( + "INSERT INTO organizers (email, password_hash, name) VALUES (%s, %s, %s)", + (email, password_hash, name) + ) + conn.commit() + cursor.close() + conn.close() + + flash('Registration successful! Please log in.') + return redirect(url_for('login')) + except Error as e: + flash(f'Database error: {e}') + + return render_template('register.html', user_type='organizer') + + +@app.route('/register/attendee/', methods=['GET', 'POST']) +def register_attendee(code): + """Register a new attendee for an event using event code.""" + # Verify event exists + try: + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + cursor.execute("SELECT * FROM events WHERE code = %s", (code,)) + event = cursor.fetchone() + cursor.close() + conn.close() + + if not event: + flash('Event not found.') + return redirect(url_for('index')) + except Error as e: + flash(f'Database error: {e}') + return redirect(url_for('index')) + + if request.method == 'POST': + first_name = request.form.get('first_name', '').strip() + last_name = request.form.get('last_name', '').strip() + email = request.form.get('email', '').strip() + password = request.form.get('password', '') + confirm_password = request.form.get('confirm_password', '') + organisation = request.form.get('organisation', '').strip() + role = request.form.get('role', '').strip() + introduction = request.form.get('introduction', '').strip() + + if not all([first_name, last_name, email, password]): + flash('First name, last name, email, and password are required.') + return render_template('register.html', user_type='attendee', event=event) + + if password != confirm_password: + flash('Passwords do not match.') + return render_template('register.html', user_type='attendee', event=event) + + try: + conn = get_db_connection() + cursor = conn.cursor() + + # Check if email already registered for this event + cursor.execute( + "SELECT id FROM attendees WHERE email = %s AND event_id = %s", + (email, event['id']) + ) + if cursor.fetchone(): + flash('Email already registered for this event.') + cursor.close() + conn.close() + return render_template('register.html', user_type='attendee', event=event) + + password_hash = hash_password(password) + attendee_code = generate_attendee_code() + cursor.execute(""" + INSERT INTO attendees (event_id, email, password_hash, first_name, last_name, organisation, role, introduction, attendee_code) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s) + """, (event['id'], email, password_hash, first_name, last_name, organisation, role, introduction, attendee_code)) + conn.commit() + cursor.close() + conn.close() + + flash('Registration successful! Please log in.') + return redirect(url_for('login')) + except Error as e: + flash(f'Database error: {e}') + + return render_template('register.html', user_type='attendee', event=event) + + +@app.route('/logout') +def logout(): + """Log out current user.""" + session.clear() + flash('You have been logged out.') + return redirect(url_for('index')) + + +# Routes - Organizer +@app.route('/organizer/dashboard') +@login_required +def organizer_dashboard(): + """Organizer dashboard.""" + 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 organizer_id = %s ORDER BY start_time DESC", (session['user_id'],)) + events = cursor.fetchall() + + # Get attendee counts, breakout sessions, and staff for each event + for event in events: + cursor.execute("SELECT COUNT(*) as count FROM attendees WHERE event_id = %s", (event['id'],)) + event['attendee_count'] = cursor.fetchone()['count'] + + cursor.execute(""" + SELECT bs.id, bs.name, bs.max_attendees, + (SELECT COUNT(*) FROM breakout_session_rsvps WHERE breakout_session_id = bs.id AND status = 'registered') as rsvp_count + FROM breakout_sessions bs + WHERE bs.event_id = %s + ORDER BY bs.start_time + """, (event['id'],)) + event['breakout_sessions'] = cursor.fetchall() + + cursor.execute("SELECT id, first_name, last_name, email FROM staff WHERE event_id = %s ORDER BY last_name, first_name", (event['id'],)) + event['staff'] = cursor.fetchall() + + cursor.execute("SELECT id, first_name, last_name, email, checked_in FROM attendees WHERE event_id = %s ORDER BY last_name, first_name", (event['id'],)) + event['attendees'] = cursor.fetchall() + + cursor.close() + conn.close() + return render_template('organizer/dashboard.html', events=events) + except Error as e: + flash(f'Database error: {e}') + return redirect(url_for('index')) + + +@app.route('/organizer/attendee-types') +@login_required +def all_attendee_types(): + """Show all attendee types across all events.""" + if session.get('user_type') != 'organizer': + flash('Access denied.') + return redirect(url_for('index')) + + try: + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + # Get all events for this organizer + cursor.execute("SELECT * FROM events WHERE organizer_id = %s ORDER BY start_time DESC", (session['user_id'],)) + events = cursor.fetchall() + + # Get attendee types for each event + for event in events: + cursor.execute("SELECT * FROM attendee_types WHERE event_id = %s ORDER BY name", (event['id'],)) + event['attendee_types'] = cursor.fetchall() + + # Get attendee count per type + for at in event['attendee_types']: + cursor.execute("SELECT COUNT(*) as count FROM attendees WHERE event_id = %s AND attendee_type_id = %s", + (event['id'], at['id'])) + at['attendee_count'] = cursor.fetchone()['count'] + + cursor.close() + conn.close() + return render_template('organizer/all_attendee_types.html', events=events) + except Error as e: + flash(f'Database error: {e}') + return redirect(url_for('index')) + + +@app.route('/organizer/event/create', methods=['GET', 'POST']) +@login_required +def create_event(): + """Create a new event.""" + if session.get('user_type') != 'organizer': + flash('Access denied.') + return redirect(url_for('index')) + + if request.method == 'POST': + name = request.form.get('name', '').strip() + description = request.form.get('description', '').strip() + start_time = request.form.get('start_time', '') + end_time = request.form.get('end_time', '') + location = request.form.get('location', '').strip() + max_attendees = request.form.get('max_attendees', '') + + if not all([name, start_time, location]): + flash('Name, start time, and location are required.') + return render_template('organizer/create_event.html') + + try: + event_start = datetime.strptime(start_time, '%Y-%m-%dT%H:%M') + except ValueError: + flash('Invalid start time format.') + return render_template('organizer/create_event.html') + + event_end = None + if end_time: + try: + event_end = datetime.strptime(end_time, '%Y-%m-%dT%H:%M') + except ValueError: + flash('Invalid end time format.') + return render_template('organizer/create_event.html') + + try: + conn = get_db_connection() + cursor = conn.cursor() + event_code = generate_event_code() + cursor.execute(""" + INSERT INTO events (organizer_id, code, name, description, start_time, end_time, location, max_attendees) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s) + """, (session['user_id'], event_code, name, description, event_start, event_end, location, max_attendees if max_attendees else None)) + conn.commit() + cursor.close() + conn.close() + + flash('Event created successfully!') + return redirect(url_for('organizer_dashboard')) + except Error as e: + flash(f'Database error: {e}') + + return render_template('organizer/create_event.html') + + +@app.route('/organizer/event//edit', methods=['GET', 'POST']) +@login_required +def edit_event(event_id): + """Edit an existing 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')) + + if request.method == 'POST': + name = request.form.get('name', '').strip() + description = request.form.get('description', '').strip() + location = request.form.get('location', '').strip() + max_attendees = request.form.get('max_attendees', '') + + if not all([name, location]): + flash('Name and location are required.') + cursor.close() + conn.close() + return render_template('organizer/edit_event.html', event=event) + + cursor.execute(""" + UPDATE events + SET name = %s, description = %s, location = %s, max_attendees = %s + WHERE id = %s + """, (name, description, location, max_attendees if max_attendees else None, event_id)) + conn.commit() + + # Capture changes for the confirmation pop-up + changes = [] + old_event = event + if old_event['name'] != name: + changes.append(('Name', old_event['name'], name)) + if (old_event['description'] or '') != description: + changes.append(('Description', old_event['description'] or '', description)) + if old_event['location'] != location: + changes.append(('Location', old_event['location'], location)) + if str(old_event['max_attendees'] or '') != max_attendees: + changes.append(('Max Attendees', old_event['max_attendees'] or 'Unlimited', max_attendees or 'Unlimited')) + + cursor.close() + conn.close() + + if changes: + # Get updated event + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + cursor.execute("SELECT * FROM events WHERE id = %s", (event_id,)) + updated_event = cursor.fetchone() + cursor.close() + conn.close() + return render_template('organizer/edit_event_confirm.html', event=updated_event, changes=changes) + else: + flash('Event updated successfully!') + return redirect(url_for('event_detail', code=event['code'])) + + cursor.close() + conn.close() + return render_template('organizer/edit_event.html', event=event) + except Error as e: + flash(f'Database error: {e}') + return redirect(url_for('organizer_dashboard')) + + +@app.route('/organizer/event//notify', methods=['POST']) +@login_required +def notify_event_attendees(event_id): + """Send email notification to event attendees about changes.""" + 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')) + + # Get attendees for this event + cursor.execute("SELECT * FROM attendees WHERE event_id = %s", (event_id,)) + attendees = cursor.fetchall() + cursor.close() + conn.close() + + # Get changes text from form + changes_text = request.form.get('changes_text', 'Event details have been updated.') + + # Send emails + sent_count = 0 + failed_count = 0 + for attendee in attendees: + full_name = f"{attendee['first_name']} {attendee['last_name']}" + if send_event_update_email(attendee['email'], full_name, event['name'], changes_text): + sent_count += 1 + else: + failed_count += 1 + + if failed_count == 0: + flash(f'Notification emails sent to {sent_count} attendee{"s" if sent_count != 1 else ""}.') + else: + flash(f'Notifications sent: {sent_count}, failed: {failed_count}.') + + return redirect(url_for('event_detail', code=event['code'])) + + except Error as e: + flash(f'Database error: {e}') + return redirect(url_for('organizer_dashboard')) + + +@app.route('/organizer/event//breakout-sessions') +@login_required +def list_breakout_sessions(event_id): + """List breakout sessions for an 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 bs.*, + (SELECT COUNT(*) FROM breakout_session_rsvps WHERE breakout_session_id = bs.id AND status = 'registered') as rsvp_count + FROM breakout_sessions bs + WHERE bs.event_id = %s + ORDER BY bs.start_time + """, (event_id,)) + sessions = cursor.fetchall() + cursor.close() + conn.close() + + return render_template('organizer/breakout_sessions.html', event=event, sessions=sessions) + except Error as e: + flash(f'Database error: {e}') + return redirect(url_for('organizer_dashboard')) + + +@app.route('/organizer/event//breakout-session/create', methods=['GET', 'POST']) +@login_required +def create_breakout_session(event_id): + """Create a new breakout session.""" + 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() + cursor.close() + conn.close() + + if not event: + flash('Event not found.') + return redirect(url_for('organizer_dashboard')) + except Error as e: + flash(f'Database error: {e}') + return redirect(url_for('organizer_dashboard')) + + if request.method == 'POST': + name = request.form.get('name', '').strip() + description = request.form.get('description', '').strip() + start_time = request.form.get('start_time', '') + end_time = request.form.get('end_time', '') + location = request.form.get('location', '').strip() + max_attendees = request.form.get('max_attendees', '') + + if not all([name, start_time, end_time, location]): + flash('Name, start time, end time, and location are required.') + return render_template('organizer/create_breakout_session.html', event=event) + + try: + session_start = datetime.strptime(start_time, '%Y-%m-%dT%H:%M') + session_end = datetime.strptime(end_time, '%Y-%m-%dT%H:%M') + except ValueError: + flash('Invalid time format.') + return render_template('organizer/create_breakout_session.html', event=event) + + if session_end <= session_start: + flash('End time must be after start time.') + return render_template('organizer/create_breakout_session.html', event=event) + + if max_attendees and event['max_attendees'] and int(max_attendees) > event['max_attendees']: + flash(f'Break-out session attendees cannot exceed event capacity ({event["max_attendees"]}).') + return render_template('organizer/create_breakout_session.html', event=event) + + try: + conn = get_db_connection() + cursor = conn.cursor() + session_code = generate_session_code() + cursor.execute(""" + INSERT INTO breakout_sessions (code, event_id, name, description, start_time, end_time, location, max_attendees) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s) + """, (session_code, event_id, name, description, session_start, session_end, location, max_attendees if max_attendees else None)) + breakout_session_id = cursor.lastrowid + + # Add event organizer as breakout session organizer + cursor.execute(""" + INSERT INTO breakout_session_organizers (breakout_session_id, organizer_id) + VALUES (%s, %s) + """, (breakout_session_id, session['user_id'])) + + conn.commit() + cursor.close() + conn.close() + + flash('Breakout session created successfully!') + return redirect(url_for('list_breakout_sessions', event_id=event_id)) + except Error as e: + flash(f'Database error: {e}') + + return render_template('organizer/create_breakout_session.html', event=event) + + +@app.route('/organizer/breakout-session//edit', methods=['GET', 'POST']) +@login_required +def edit_breakout_session(session_id): + """Edit a breakout session.""" + if session.get('user_type') != 'organizer': + flash('Access denied.') + return redirect(url_for('index')) + + try: + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + # Get breakout session and verify organizer has access + cursor.execute(""" + SELECT bs.*, e.name as event_name, e.organizer_id as event_organizer_id, e.max_attendees as event_max_attendees + FROM breakout_sessions bs + JOIN events e ON bs.event_id = e.id + WHERE bs.id = %s + """, (session_id,)) + breakout_session = cursor.fetchone() + + if not breakout_session: + flash('Breakout session not found.') + cursor.close() + conn.close() + return redirect(url_for('organizer_dashboard')) + + if breakout_session['event_organizer_id'] != session['user_id']: + flash('Access denied.') + cursor.close() + conn.close() + return redirect(url_for('index')) + + event = {'id': breakout_session['event_id'], 'name': breakout_session['event_name'], 'max_attendees': breakout_session['event_max_attendees']} + + if request.method == 'POST': + name = request.form.get('name', '').strip() + description = request.form.get('description', '').strip() + start_time = request.form.get('start_time', '') + end_time = request.form.get('end_time', '') + location = request.form.get('location', '').strip() + max_attendees = request.form.get('max_attendees', '') + + if not all([name, start_time, end_time, location]): + flash('Name, start time, end time, and location are required.') + cursor.close() + conn.close() + return render_template('organizer/edit_breakout_session.html', session=breakout_session, event=event) + + try: + session_start = datetime.strptime(start_time, '%Y-%m-%dT%H:%M') + session_end = datetime.strptime(end_time, '%Y-%m-%dT%H:%M') + except ValueError: + flash('Invalid time format.') + cursor.close() + conn.close() + return render_template('organizer/edit_breakout_session.html', session=breakout_session, event=event) + + if session_end <= session_start: + flash('End time must be after start time.') + cursor.close() + conn.close() + return render_template('organizer/edit_breakout_session.html', session=breakout_session, event=event) + + if max_attendees and event['max_attendees'] and int(max_attendees) > event['max_attendees']: + flash(f'Break-out session attendees cannot exceed event capacity ({event["max_attendees"]}).') + cursor.close() + conn.close() + return render_template('organizer/edit_breakout_session.html', session=breakout_session, event=event) + + cursor.execute(""" + UPDATE breakout_sessions + SET name = %s, description = %s, start_time = %s, end_time = %s, location = %s, max_attendees = %s + WHERE id = %s + """, (name, description, session_start, session_end, location, max_attendees if max_attendees else None, session_id)) + conn.commit() + + # Capture changes for the confirmation pop-up + changes = [] + old_session = breakout_session + if old_session['name'] != name: + changes.append(('Name', old_session['name'], name)) + if old_session['description'] != description: + changes.append(('Description', old_session['description'] or '', description)) + if old_session['start_time'].strftime('%Y-%m-%dT%H:%M') != start_time: + changes.append(('Start Time', old_session['start_time'].strftime('%B %d, %Y at %H:%M'), session_start.strftime('%B %d, %Y at %H:%M'))) + if old_session['end_time'].strftime('%Y-%m-%dT%H:%M') != end_time: + changes.append(('End Time', old_session['end_time'].strftime('%B %d, %Y at %H:%M'), session_end.strftime('%B %d, %Y at %H:%M'))) + if old_session['location'] != location: + changes.append(('Location', old_session['location'], location)) + if str(old_session['max_attendees'] or '') != max_attendees: + changes.append(('Max Attendees', old_session['max_attendees'] or 'Unlimited', max_attendees or 'Unlimited')) + + # Get updated session data + cursor.execute(""" + SELECT bs.*, e.name as event_name + FROM breakout_sessions bs + JOIN events e ON bs.event_id = e.id + WHERE bs.id = %s + """, (session_id,)) + updated_session = cursor.fetchone() + + cursor.close() + conn.close() + + if changes: + return render_template('organizer/edit_breakout_session_confirm.html', + session=updated_session, event=event, changes=changes) + else: + flash('Breakout session updated successfully!') + return redirect(url_for('view_breakout_session', code=breakout_session['code'])) + + cursor.close() + conn.close() + return render_template('organizer/edit_breakout_session.html', session=breakout_session, event=event) + + except Error as e: + flash(f'Database error: {e}') + return redirect(url_for('organizer_dashboard')) + + +@app.route('/organizer/breakout-session//notify', methods=['POST']) +@login_required +def notify_breakout_session_attendees(session_id): + """Send email notification to breakout session attendees about changes.""" + if session.get('user_type') != 'organizer': + flash('Access denied.') + return redirect(url_for('index')) + + try: + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + # Get breakout session and verify organizer has access + cursor.execute(""" + SELECT bs.*, e.name as event_name, e.organizer_id as event_organizer_id + FROM breakout_sessions bs + JOIN events e ON bs.event_id = e.id + WHERE bs.id = %s + """, (session_id,)) + breakout_session = cursor.fetchone() + + if not breakout_session: + flash('Breakout session not found.') + cursor.close() + conn.close() + return redirect(url_for('organizer_dashboard')) + + if breakout_session['event_organizer_id'] != session['user_id']: + flash('Access denied.') + cursor.close() + conn.close() + return redirect(url_for('index')) + + # Get attendees who RSVPed to this session + cursor.execute(""" + SELECT a.email, a.first_name, a.last_name + FROM breakout_session_rsvps bsr + JOIN attendees a ON bsr.attendee_id = a.id + WHERE bsr.breakout_session_id = %s AND bsr.status = 'registered' + """, (session_id,)) + rsvps = cursor.fetchall() + cursor.close() + conn.close() + + # Get changes text from form + changes_text = request.form.get('changes_text', 'Session details have been updated.') + + # Send emails + sent_count = 0 + failed_count = 0 + for rsvp in rsvps: + full_name = f"{rsvp['first_name']} {rsvp['last_name']}" + if send_breakout_session_update_email(rsvp['email'], full_name, breakout_session['name'], + breakout_session['event_name'], changes_text): + sent_count += 1 + else: + failed_count += 1 + + if failed_count == 0: + flash(f'Notification emails sent to {sent_count} attendee{"s" if sent_count != 1 else ""}.') + else: + flash(f'Notifications sent: {sent_count}, failed: {failed_count}.') + + return redirect(url_for('view_breakout_session', code=breakout_session['code'])) + + except Error as e: + flash(f'Database error: {e}') + return redirect(url_for('organizer_dashboard')) + + +@app.route('/breakout-session/') +@login_required +def view_breakout_session(code): + """Breakout session detail page for organizers.""" + if session.get('user_type') != 'organizer': + flash('Access denied.') + return redirect(url_for('index')) + + try: + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + # Get breakout session and verify organizer has access + cursor.execute(""" + SELECT bs.*, e.name as event_name, e.organizer_id as event_organizer_id + FROM breakout_sessions bs + JOIN events e ON bs.event_id = e.id + WHERE bs.code = %s + """, (code,)) + session_data = cursor.fetchone() + + if not session_data: + flash('Breakout session not found.') + cursor.close() + conn.close() + return redirect(url_for('organizer_dashboard')) + + # Check if user is event organizer or breakout session organizer + cursor.execute(""" + SELECT id FROM breakout_session_organizers + WHERE breakout_session_id = %s AND organizer_id = %s + """, (session_data['id'], session['user_id'])) + is_organizer = cursor.fetchone() + + if session_data['event_organizer_id'] != session['user_id'] and not is_organizer: + flash('Access denied.') + cursor.close() + conn.close() + return redirect(url_for('organizer_dashboard')) + + # Get RSVPs + cursor.execute(""" + SELECT bsr.*, a.first_name, a.last_name, a.email, a.organisation, a.role + FROM breakout_session_rsvps bsr + JOIN attendees a ON bsr.attendee_id = a.id + WHERE bsr.breakout_session_id = %s AND bsr.status = 'registered' + ORDER BY a.last_name, a.first_name + """, (session_data['id'],)) + rsvps = cursor.fetchall() + cursor.close() + conn.close() + + return render_template('organizer/breakout_session_detail.html', session=session_data, rsvps=rsvps) + except Error as e: + flash(f'Database error: {e}') + return redirect(url_for('organizer_dashboard')) + + +@app.route('/attendee/breakout-sessions') +@login_required +def attendee_breakout_sessions(): + """List breakout sessions for attendee's event.""" + if session.get('user_type') != 'attendee': + 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", (session['event_id'],)) + event = cursor.fetchone() + + cursor.execute(""" + SELECT bs.*, + (SELECT COUNT(*) FROM breakout_session_rsvps WHERE breakout_session_id = bs.id AND status = 'registered') as rsvp_count, + (SELECT status FROM breakout_session_rsvps WHERE breakout_session_id = bs.id AND attendee_id = %s) as my_rsvp_status + FROM breakout_sessions bs + WHERE bs.event_id = %s + ORDER BY bs.start_time + """, (session['user_id'], session['event_id'])) + sessions = cursor.fetchall() + cursor.close() + conn.close() + + return render_template('attendee/breakout_sessions.html', event=event, sessions=sessions) + except Error as e: + flash(f'Database error: {e}') + return redirect(url_for('attendee_dashboard')) + + +@app.route('/attendee/breakout-session//rsvp', methods=['POST']) +@login_required +def rsvp_breakout_session(code): + """RSVP for a breakout session.""" + if session.get('user_type') != 'attendee': + return jsonify({'error': 'Access denied'}), 403 + + try: + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + # Get breakout session + cursor.execute("SELECT * FROM breakout_sessions WHERE code = %s", (code,)) + breakout = cursor.fetchone() + cursor.close() + + if not breakout: + conn.close() + return jsonify({'error': 'Breakout session not found'}), 404 + + session_id = breakout['id'] + + # Check capacity + if breakout['max_attendees']: + cursor = conn.cursor(dictionary=True) + cursor.execute(""" + SELECT COUNT(*) as count FROM breakout_session_rsvps + WHERE breakout_session_id = %s AND status = 'registered' + """, (session_id,)) + current_count = cursor.fetchone()['count'] + cursor.close() + + if current_count >= breakout['max_attendees']: + conn.close() + return jsonify({'error': 'Breakout session is full'}), 400 + + # Check for overlapping sessions + cursor = conn.cursor(dictionary=True) + cursor.execute(""" + SELECT bs.id, bs.name FROM breakout_sessions bs + JOIN breakout_session_rsvps bsr ON bs.id = bsr.breakout_session_id + WHERE bsr.attendee_id = %s + AND bsr.status = 'registered' + AND bs.id != %s + AND ( + (bs.start_time <= %s AND bs.end_time > %s) + OR (bs.start_time < %s AND bs.end_time >= %s) + OR (bs.start_time >= %s AND bs.end_time <= %s) + ) + """, (session['user_id'], session_id, + breakout['start_time'], breakout['start_time'], + breakout['end_time'], breakout['end_time'], + breakout['start_time'], breakout['end_time'])) + overlapping = cursor.fetchall() + cursor.close() + + if overlapping: + names = ', '.join([s['name'] for s in overlapping]) + conn.close() + return jsonify({'error': f'Overlaps with: {names}'}), 400 + + # Check if already registered + cursor = conn.cursor(dictionary=True) + cursor.execute(""" + SELECT id, status FROM breakout_session_rsvps + WHERE breakout_session_id = %s AND attendee_id = %s + """, (session_id, session['user_id'])) + existing = cursor.fetchone() + cursor.close() + + if existing: + if existing['status'] == 'registered': + conn.close() + return jsonify({'error': 'Already registered'}), 400 + else: + # Reactivate cancelled RSVP + cursor = conn.cursor() + cursor.execute(""" + UPDATE breakout_session_rsvps SET status = 'registered' + WHERE id = %s + """, (existing['id'],)) + conn.commit() + cursor.close() + conn.close() + return jsonify({'success': True, 'message': 'RSVP restored'}) + else: + # Create new RSVP + cursor = conn.cursor() + cursor.execute(""" + INSERT INTO breakout_session_rsvps (breakout_session_id, attendee_id, status) + VALUES (%s, %s, 'registered') + """, (session_id, session['user_id'])) + conn.commit() + cursor.close() + conn.close() + return jsonify({'success': True}) + + except Error as e: + return jsonify({'error': str(e)}), 500 + + +@app.route('/attendee/breakout-session//cancel-rsvp', methods=['POST']) +@login_required +def cancel_breakout_session_rsvp(code): + """Cancel RSVP for a breakout session.""" + if session.get('user_type') != 'attendee': + return jsonify({'error': 'Access denied'}), 403 + + try: + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + cursor.execute("SELECT id FROM breakout_sessions WHERE code = %s", (code,)) + breakout = cursor.fetchone() + cursor.close() + + if not breakout: + conn.close() + return jsonify({'error': 'Breakout session not found'}), 404 + + cursor = conn.cursor() + cursor.execute(""" + UPDATE breakout_session_rsvps SET status = 'cancelled' + WHERE breakout_session_id = %s AND attendee_id = %s AND status = 'registered' + """, (breakout['id'], session['user_id'])) + affected = cursor.rowcount + conn.commit() + cursor.close() + conn.close() + + if affected > 0: + return jsonify({'success': True}) + else: + return jsonify({'error': 'RSVP not found'}), 404 + except Error as e: + return jsonify({'error': str(e)}), 500 + + +@app.route('/attendee/event/attendance', methods=['GET', 'POST']) +@login_required +def update_event_attendance(): + """Update attendance status for main event.""" + if session.get('user_type') != 'attendee': + return jsonify({'error': 'Access denied'}), 403 + + if request.method == 'GET': + try: + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + cursor.execute("SELECT attendance_status FROM attendees WHERE id = %s", (session['user_id'],)) + result = cursor.fetchone() + cursor.close() + conn.close() + return jsonify({'status': result['attendance_status'] if result else 'attending'}) + except Error as e: + return jsonify({'error': str(e)}), 500 + + status = request.json.get('status') if request.json else request.form.get('status') + if status not in ['attending', 'not_attending']: + return jsonify({'error': 'Invalid status'}), 400 + + try: + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute(""" + UPDATE attendees SET attendance_status = %s WHERE id = %s + """, (status, session['user_id'])) + conn.commit() + cursor.close() + conn.close() + return jsonify({'success': True}) + except Error as e: + return jsonify({'error': str(e)}), 500 + + +@app.route('/organizer/event/') +@login_required +def event_detail(code): + """Event detail page for organizer.""" + 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 code = %s AND organizer_id = %s", (code, 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", (event['id'],)) + attendees = cursor.fetchall() + + cursor.execute(""" + SELECT bs.*, + (SELECT COUNT(*) FROM breakout_session_rsvps WHERE breakout_session_id = bs.id AND status = 'registered') as registered_count + FROM breakout_sessions bs + WHERE bs.event_id = %s + ORDER BY bs.start_time + """, (event['id'],)) + breakout_sessions = cursor.fetchall() + + cursor.execute("SELECT * FROM staff WHERE event_id = %s ORDER BY last_name, first_name", (event['id'],)) + staff = cursor.fetchall() + + # Get attendee types for this event + cursor.execute("SELECT * FROM attendee_types WHERE event_id = %s ORDER BY name", (event['id'],)) + attendee_types = cursor.fetchall() + + # Build a mapping of attendee type id to type name for attendees + attendee_type_map = {at['id']: at for at in attendee_types} + for attendee in attendees: + attendee['attendee_type_name'] = attendee_type_map.get(attendee.get('attendee_type_id'), {}).get('name') if attendee.get('attendee_type_id') else None + attendee['attendee_type_price'] = attendee_type_map.get(attendee.get('attendee_type_id'), {}).get('price') if attendee.get('attendee_type_id') else None + + # Get other events by this organizer for batch staff add + cursor.execute("SELECT id, name, code FROM events WHERE organizer_id = %s AND id != %s ORDER BY name", (session['user_id'], event['id'])) + other_events = cursor.fetchall() + + cursor.close() + conn.close() + + return render_template('organizer/event_detail.html', event=event, attendees=attendees, breakout_sessions=breakout_sessions, staff=staff, other_events=other_events, attendee_types=attendee_types) + except Error as e: + flash(f'Database error: {e}') + return redirect(url_for('organizer_dashboard')) + + +@app.route('/organizer/event//attendee-types', methods=['GET', 'POST']) +@login_required +def manage_attendee_types(event_id): + """Manage attendee types for an 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 attendee_types WHERE event_id = %s ORDER BY name", (event_id,)) + attendee_types = cursor.fetchall() + + 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/attendee_types.html', event=event, attendee_types=attendee_types) + + try: + price_value = float(price) if price else 0.00 + except ValueError: + price_value = 0.00 + + type_code = generate_type_code() + cursor.execute(""" + INSERT INTO attendee_types (event_id, code, name, price) + VALUES (%s, %s, %s, %s) + """, (event_id, type_code, name, price_value)) + conn.commit() + flash(f'Attendee type "{name}" created successfully!') + cursor.close() + conn.close() + return redirect(url_for('manage_attendee_types', event_id=event_id)) + + cursor.close() + conn.close() + return render_template('organizer/attendee_types.html', event=event, attendee_types=attendee_types) + 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): + """Delete 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)) + + # Set attendee_type_id to NULL for all attendees with this type + cursor.execute("UPDATE attendees SET attendee_type_id = NULL WHERE attendee_type_id = %s", (type_id,)) + + # Delete the attendee type + cursor.execute("DELETE FROM attendee_types WHERE id = %s", (type_id,)) + conn.commit() + + flash(f'Attendee type "{attendee_type["name"]}" deleted successfully!') + cursor.close() + conn.close() + return redirect(url_for('manage_attendee_types', event_id=event_id)) + except Error as e: + flash(f'Database error: {e}') + return redirect(url_for('organizer_dashboard')) + + +@app.route('/organizer/event//attendees/batch-assign-type', methods=['POST']) +@login_required +def batch_assign_attendee_type(event_id): + """Batch assign attendee type to selected attendees.""" + 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')) + + attendee_ids = request.form.getlist('attendee_ids') + attendee_type_id = request.form.get('attendee_type_id', '').strip() + + if not attendee_ids: + flash('No attendees selected.') + cursor.close() + conn.close() + return redirect(url_for('event_detail', code=event['code'])) + + if attendee_type_id: + # Validate attendee type exists for this event + cursor.execute("SELECT id FROM attendee_types WHERE id = %s AND event_id = %s", (attendee_type_id, event_id)) + if not cursor.fetchone(): + flash('Invalid attendee type.') + cursor.close() + conn.close() + return redirect(url_for('event_detail', code=event['code'])) + + for attendee_id in attendee_ids: + cursor.execute("UPDATE attendees SET attendee_type_id = %s WHERE id = %s AND event_id = %s", + (attendee_type_id, attendee_id, event_id)) + conn.commit() + flash(f'Successfully assigned type to {len(attendee_ids)} attendee(s).') + else: + # Remove type from selected attendees + for attendee_id in attendee_ids: + cursor.execute("UPDATE attendees SET attendee_type_id = NULL WHERE id = %s AND event_id = %s", + (attendee_id, event_id)) + conn.commit() + flash(f'Successfully removed type from {len(attendee_ids)} attendee(s).') + + cursor.close() + conn.close() + return redirect(url_for('event_detail', code=event['code'])) + except Error as e: + flash(f'Database error: {e}') + return redirect(url_for('organizer_dashboard')) + + +@app.route('/organizer/event//staff', methods=['GET', 'POST']) +@login_required +def manage_event_staff(event_id): + """Manage staff for an 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 staff WHERE event_id = %s ORDER BY last_name, first_name", (event_id,)) + staff_members = cursor.fetchall() + cursor.close() + conn.close() + + if request.method == 'POST': + first_name = request.form.get('first_name', '').strip() + last_name = request.form.get('last_name', '').strip() + email = request.form.get('email', '').strip() + + if not all([first_name, last_name, email]): + flash('First name, last name, and email are required.') + return render_template('organizer/event_staff.html', event=event, staff_members=staff_members) + + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + # Check if staff with this email already exists for this event + cursor.execute("SELECT id FROM staff WHERE event_id = %s AND email = %s", (event_id, email)) + if cursor.fetchone(): + flash('A staff member with this email already exists for this event.') + cursor.close() + conn.close() + return render_template('organizer/event_staff.html', event=event, staff_members=staff_members) + + invite_token = generate_invite_token() + 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)) + conn.commit() + + # Get organizer email for sending + cursor.execute("SELECT email FROM organizers WHERE id = %s", (session['user_id'],)) + organizer_email = cursor.fetchone()['email'] + cursor.close() + conn.close() + + # Send invite email + full_name = f"{first_name} {last_name}" + if send_staff_invite_email(email, full_name, event['name'], invite_token, organizer_email): + flash(f'Staff member added! Invite email sent to {email}.') + else: + flash(f'Staff member added but failed to send invite email.') + + return redirect(url_for('event_detail', code=event['code'])) + + return render_template('organizer/event_staff.html', event=event, staff_members=staff_members) + except Error as e: + flash(f'Database error: {e}') + return redirect(url_for('organizer_dashboard')) + + +@app.route('/staff/invite/', methods=['GET', 'POST']) +def staff_invite(token): + """Staff invite acceptance page.""" + recaptcha_site_key = app.config.get('RECAPTCHA_SITE_KEY', '') + try: + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute("SELECT * FROM staff WHERE invite_token = %s AND invite_used = FALSE", (token,)) + staff_member = cursor.fetchone() + + if not staff_member: + flash('Invalid or expired invite link.') + cursor.close() + conn.close() + return redirect(url_for('index')) + + cursor.execute("SELECT * FROM events WHERE id = %s", (staff_member['event_id'],)) + event = cursor.fetchone() + cursor.close() + conn.close() + + if request.method == 'POST': + password = request.form.get('password', '') + confirm_password = request.form.get('confirm_password', '') + recaptcha_response = request.form.get('g-recaptcha-response', '') + + if recaptcha_site_key and not verify_recaptcha(recaptcha_response): + flash('Please complete the reCAPTCHA verification.') + return render_template('staff_invite.html', staff=staff_member, event=event, recaptcha_site_key=recaptcha_site_key) + + if not password or len(password) < 6: + flash('Password must be at least 6 characters.') + return render_template('staff_invite.html', staff=staff_member, event=event, recaptcha_site_key=recaptcha_site_key) + + if password != confirm_password: + flash('Passwords do not match.') + return render_template('staff_invite.html', staff=staff_member, event=event, recaptcha_site_key=recaptcha_site_key) + + conn = get_db_connection() + cursor = conn.cursor() + password_hash = hash_password(password) + cursor.execute(""" + UPDATE staff SET password_hash = %s, invite_used = TRUE, invite_token = NULL + WHERE id = %s + """, (password_hash, staff_member['id'])) + conn.commit() + cursor.close() + conn.close() + + flash('Registration complete! Please log in with your email and password.') + return redirect(url_for('login')) + + return render_template('staff_invite.html', staff=staff_member, event=event, recaptcha_site_key=recaptcha_site_key) + except Error as e: + flash(f'Database error: {e}') + return redirect(url_for('index')) + + +@app.route('/organizer/event//staff//edit', methods=['GET', 'POST']) +@login_required +def edit_staff(event_id, staff_id): + """Edit a staff member.""" + 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 staff WHERE id = %s AND event_id = %s", (staff_id, event_id)) + staff_member = cursor.fetchone() + + if not staff_member: + flash('Staff member not found.') + cursor.close() + conn.close() + return redirect(url_for('manage_event_staff', event_id=event_id)) + + if request.method == 'POST': + first_name = request.form.get('first_name', '').strip() + last_name = request.form.get('last_name', '').strip() + email = request.form.get('email', '').strip() + + if not all([first_name, last_name, email]): + flash('First name, last name, and email are required.') + cursor.close() + conn.close() + return render_template('organizer/edit_staff.html', event=event, staff=staff_member) + + cursor.execute(""" + UPDATE staff SET first_name = %s, last_name = %s, email = %s + WHERE id = %s + """, (first_name, last_name, email, staff_id)) + conn.commit() + cursor.close() + conn.close() + + flash('Staff member updated successfully!') + return redirect(url_for('manage_event_staff', event_id=event_id)) + + cursor.close() + conn.close() + return render_template('organizer/edit_staff.html', event=event, staff=staff_member) + except Error as e: + flash(f'Database error: {e}') + return redirect(url_for('organizer_dashboard')) + + +@app.route('/organizer/event//staff//delete', methods=['POST']) +@login_required +def delete_staff(event_id, staff_id): + """Delete a staff member.""" + 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 staff WHERE id = %s AND event_id = %s", (staff_id, event_id)) + staff_member = cursor.fetchone() + + if not staff_member: + flash('Staff member not found.') + cursor.close() + conn.close() + return redirect(url_for('organizer_dashboard')) + + cursor.execute("DELETE FROM staff WHERE id = %s", (staff_id,)) + conn.commit() + cursor.close() + conn.close() + + flash('Staff member removed successfully!') + return redirect(url_for('organizer_dashboard')) + except Error as e: + flash(f'Database error: {e}') + return redirect(url_for('organizer_dashboard')) + + +@app.route('/organizer/event//batch-add-staff', methods=['POST']) +@login_required +def batch_add_staff(event_id): + """Batch add staff from another event.""" + if session.get('user_type') != 'organizer': + flash('Access denied.') + return redirect(url_for('index')) + + source_event_id = request.form.get('source_event_id') + if not source_event_id: + flash('Please select an event.') + return redirect(url_for('organizer_dashboard')) + + try: + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + # Verify target event belongs to organizer and get its code + cursor.execute("SELECT code FROM events WHERE id = %s AND organizer_id = %s", (event_id, session['user_id'])) + target_event = cursor.fetchone() + if not target_event: + flash('Event not found.') + cursor.close() + conn.close() + return redirect(url_for('organizer_dashboard')) + + # Verify source event belongs to same organizer + cursor.execute("SELECT id FROM events WHERE id = %s AND organizer_id = %s", (source_event_id, session['user_id'])) + source_event = cursor.fetchone() + if not source_event: + flash('Source event not found.') + cursor.close() + conn.close() + return redirect(url_for('event_detail', code=target_event['code'])) + + # Get staff from source event + cursor.execute("SELECT first_name, last_name, email FROM staff WHERE event_id = %s", (source_event_id,)) + source_staff = cursor.fetchall() + + if not source_staff: + flash('No staff members found in the selected event.') + cursor.close() + conn.close() + return redirect(url_for('event_detail', code=target_event['code'])) + + # Get existing staff emails in target event to avoid duplicates + cursor.execute("SELECT email FROM staff WHERE event_id = %s", (event_id,)) + existing_emails = set(row['email'].lower() for row in cursor.fetchall()) + + # Add staff that don't already exist + added_count = 0 + for member in source_staff: + if member['email'].lower() not in existing_emails: + 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'])) + existing_emails.add(member['email'].lower()) + added_count += 1 + + conn.commit() + cursor.close() + conn.close() + + flash(f'{added_count} staff member(s) added from other event.') + return redirect(url_for('event_detail', code=target_event['code'])) + + except Error as e: + flash(f'Database error: {e}') + return redirect(url_for('organizer_dashboard')) + + +@app.route('/organizer/breakout-session//delete', methods=['POST']) +@login_required +def delete_breakout_session(session_id): + """Delete a breakout session.""" + 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 bs.*, e.organizer_id, e.code + FROM breakout_sessions bs + JOIN events e ON bs.event_id = e.id + WHERE bs.id = %s + """, (session_id,)) + session_obj = cursor.fetchone() + + if not session_obj or session_obj['organizer_id'] != session['user_id']: + flash('Session not found.') + cursor.close() + conn.close() + return redirect(url_for('organizer_dashboard')) + + cursor.execute("DELETE FROM breakout_session_rsvps WHERE breakout_session_id = %s", (session_id,)) + cursor.execute("DELETE FROM breakout_sessions WHERE id = %s", (session_id,)) + conn.commit() + cursor.close() + conn.close() + + flash('Breakout session deleted successfully!') + return redirect(url_for('organizer_dashboard')) + except Error as e: + flash(f'Database error: {e}') + return redirect(url_for('organizer_dashboard')) + + +@app.route('/organizer/attendee//edit', methods=['GET', 'POST']) +@login_required +def edit_attendee(attendee_id): + """Edit an attendee.""" + 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 a.*, e.organizer_id, e.code + FROM attendees a + JOIN events e ON a.event_id = e.id + WHERE a.id = %s + """, (attendee_id,)) + attendee = cursor.fetchone() + + if not attendee or attendee['organizer_id'] != session['user_id']: + flash('Attendee not found.') + cursor.close() + conn.close() + return redirect(url_for('organizer_dashboard')) + + if request.method == 'POST': + first_name = request.form.get('first_name', '').strip() + last_name = request.form.get('last_name', '').strip() + email = request.form.get('email', '').strip() + organisation = request.form.get('organisation', '').strip() + role = request.form.get('role', '').strip() + introduction = request.form.get('introduction', '').strip() + + cursor.execute(""" + UPDATE attendees + SET first_name = %s, last_name = %s, email = %s, organisation = %s, role = %s, introduction = %s + WHERE id = %s + """, (first_name, last_name, email, organisation, role, introduction, attendee_id)) + conn.commit() + cursor.close() + conn.close() + + flash('Attendee updated successfully!') + return redirect(url_for('organizer_dashboard')) + + cursor.close() + conn.close() + return render_template('organizer/edit_attendee.html', attendee=attendee) + except Error as e: + flash(f'Database error: {e}') + return redirect(url_for('organizer_dashboard')) + + +@app.route('/organizer/attendee//delete', methods=['POST']) +@login_required +def delete_attendee(attendee_id): + """Delete an attendee.""" + 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 a.*, e.organizer_id + FROM attendees a + JOIN events e ON a.event_id = e.id + WHERE a.id = %s + """, (attendee_id,)) + attendee = cursor.fetchone() + + if not attendee or attendee['organizer_id'] != session['user_id']: + flash('Attendee not found.') + cursor.close() + conn.close() + return redirect(url_for('organizer_dashboard')) + + cursor.execute("DELETE FROM attendees WHERE id = %s", (attendee_id,)) + conn.commit() + cursor.close() + conn.close() + + flash('Attendee deleted successfully!') + return redirect(url_for('organizer_dashboard')) + except Error as e: + flash(f'Database error: {e}') + return redirect(url_for('organizer_dashboard')) + + +@app.route('/organizer/event//badges') +@login_required +def event_badges(event_id): + """Badge printing view 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() + cursor.close() + conn.close() + + # Generate QR codes for each attendee + for attendee in attendees: + qr_data = f"NETEVENT:{event_id}:{attendee['id']}" + qr_img = qrcode.make(qr_data, box_size=4, border=1) + buffer = io.BytesIO() + qr_img.save(buffer, format='PNG') + buffer.seek(0) + attendee['qr_code'] = f"data:image/png;base64,{base64.b64encode(buffer.getvalue()).decode('utf-8')}" + + return render_template('organizer/badges.html', event=event, attendees=attendees) + except Error as e: + flash(f'Database error: {e}') + return redirect(url_for('organizer_dashboard')) + + +@app.route('/organizer/event//stats') +@login_required +def event_stats(event_id): + """Attendance statistics 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 COUNT(*) as total FROM attendees WHERE event_id = %s", (event_id,)) + total = cursor.fetchone()['total'] + + cursor.execute("SELECT COUNT(*) as checked_in FROM attendees WHERE event_id = %s AND checked_in = TRUE", (event_id,)) + checked_in = cursor.fetchone()['checked_in'] + + cursor.close() + conn.close() + + return jsonify({ + 'total': total, + 'checked_in': checked_in, + 'not_checked_in': total - checked_in + }) + except Error as e: + return jsonify({'error': str(e)}), 500 + + +@app.route('/organizer/event//checkin/', methods=['POST']) +@login_required +def checkin_attendee(event_id, attendee_id): + """Check in an attendee.""" + if session.get('user_type') != 'organizer': + return jsonify({'error': 'Access denied'}), 403 + + try: + conn = get_db_connection() + cursor = conn.cursor() + + # Check if already checked in + cursor.execute( + "SELECT checked_in FROM attendees WHERE id = %s AND event_id = %s", + (attendee_id, event_id) + ) + result = cursor.fetchone() + if not result: + cursor.close() + conn.close() + return jsonify({'error': 'Attendee not found'}), 404 + + already_checked_in = result[0] + + cursor.execute( + "UPDATE attendees SET checked_in = TRUE WHERE id = %s AND event_id = %s", + (attendee_id, event_id) + ) + conn.commit() + + # Get attendee name for response + cursor.execute( + "SELECT first_name, last_name FROM attendees WHERE id = %s", + (attendee_id,) + ) + attendee = cursor.fetchone() + cursor.close() + conn.close() + + return jsonify({ + 'success': True, + 'already_checked_in': bool(already_checked_in), + 'attendee_name': f"{attendee[0]} {attendee[1]}" if attendee else 'Unknown' + }) + except Error as e: + return jsonify({'error': str(e)}), 500 + + +@app.route('/organizer/event//checkin/code', methods=['POST']) +@login_required +def checkin_by_code(event_id): + """Check in an attendee by their unique attendee code.""" + if session.get('user_type') not in ['organizer', 'staff']: + return jsonify({'error': 'Access denied'}), 403 + + data = request.get_json() + attendee_code = data.get('attendee_code') if data else None + + if not attendee_code: + return jsonify({'error': 'Attendee code required'}), 400 + + try: + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + # Find attendee by code and event + cursor.execute( + "SELECT id, first_name, last_name, checked_in FROM attendees WHERE attendee_code = %s AND event_id = %s", + (attendee_code, event_id) + ) + attendee = cursor.fetchone() + + if not attendee: + cursor.close() + conn.close() + return jsonify({'error': 'Attendee not found'}), 404 + + already_checked_in = attendee['checked_in'] + + cursor.execute( + "UPDATE attendees SET checked_in = TRUE WHERE id = %s AND event_id = %s", + (attendee['id'], event_id) + ) + conn.commit() + cursor.close() + conn.close() + + return jsonify({ + 'success': True, + 'already_checked_in': bool(already_checked_in), + 'attendee_name': f"{attendee['first_name']} {attendee['last_name']}" + }) + except Error as e: + return jsonify({'error': str(e)}), 500 + + +@app.route('/organizer/event//scan') +@login_required +def event_scan(event_id): + """QR code scanner page for check-in.""" + if session.get('user_type') not in ['organizer', 'staff']: + flash('Access denied.') + return redirect(url_for('index')) + + try: + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + # Organizers can access any event they own, staff can only access their event + if session.get('user_type') == 'organizer': + cursor.execute("SELECT * FROM events WHERE id = %s AND organizer_id = %s", (event_id, session['user_id'])) + else: + cursor.execute("SELECT * FROM events WHERE id = %s", (event_id,)) + # Verify staff belongs to this event + if session.get('event_id') != event_id: + flash('Access denied.') + cursor.close() + conn.close() + return redirect(url_for('index')) + + event = cursor.fetchone() + + if not event: + flash('Event not found.') + cursor.close() + conn.close() + if session.get('user_type') == 'organizer': + return redirect(url_for('organizer_dashboard')) + return redirect(url_for('staff_event_dashboard', event_id=session.get('event_id'))) + + cursor.execute("SELECT id, first_name, last_name, checked_in FROM attendees WHERE event_id = %s", (event_id,)) + attendees = cursor.fetchall() + cursor.close() + conn.close() + + if session.get('user_type') == 'staff': + return render_template('staff/scan.html', event=event, attendees=attendees) + return render_template('organizer/scan.html', event=event, attendees=attendees) + except Error as e: + flash(f'Database error: {e}') + return redirect(url_for('organizer_dashboard')) + + +# Routes - Attendee +@app.route('/attendee/dashboard') +@login_required +def attendee_dashboard(): + """Attendee dashboard.""" + if session.get('user_type') != 'attendee': + 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", (session['event_id'],)) + event = cursor.fetchone() + + if event: + # Get attendee info + cursor.execute("SELECT * FROM attendees WHERE id = %s", (session['user_id'],)) + attendee = cursor.fetchone() + + # Get connections + cursor.execute(""" + SELECT c.*, a.first_name, a.last_name, a.organisation, a.role + FROM connections c + JOIN attendees a ON c.connected_attendee_id = a.id + WHERE c.attendee_id = %s AND c.status = 'accepted' + """, (session['user_id'],)) + connections = cursor.fetchall() + + # Get pending connections + cursor.execute(""" + SELECT c.*, a.first_name, a.last_name, a.organisation, a.role + FROM connections c + JOIN attendees a ON c.attendee_id = a.id + WHERE c.connected_attendee_id = %s AND c.status = 'pending' + """, (session['user_id'],)) + pending_connections = cursor.fetchall() + + # Get appointments + cursor.execute(""" + SELECT ap.*, a.first_name as requester_first_name, a.last_name as requester_last_name, + at.first_name as target_first_name, at.last_name as target_last_name + FROM appointments ap + JOIN attendees a ON ap.requester_id = a.id + JOIN attendees at ON ap.target_id = at.id + WHERE ap.requester_id = %s OR ap.target_id = %s + ORDER BY ap.appointment_time + """, (session['user_id'], session['user_id'])) + appointments = cursor.fetchall() + else: + event = None + attendee = None + connections = [] + pending_connections = [] + appointments = [] + + cursor.close() + conn.close() + + return render_template('attendee/dashboard.html', event=event, attendee=attendee, + connections=connections, pending_connections=pending_connections, + appointments=appointments) + except Error as e: + flash(f'Database error: {e}') + return redirect(url_for('index')) + + +@app.route('/attendee/badge/download') +@login_required +def download_badge(): + """Generate and download attendee badge PDF.""" + if session.get('user_type') != 'attendee': + flash('Access denied.') + return redirect(url_for('index')) + + try: + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + # Get attendee info + cursor.execute("SELECT * FROM attendees WHERE id = %s", (session['user_id'],)) + attendee = cursor.fetchone() + + # Get event info + cursor.execute("SELECT * FROM events WHERE id = %s", (session['event_id'],)) + event = cursor.fetchone() + + cursor.close() + conn.close() + + if not attendee: + flash('Attendee not found.') + return redirect(url_for('attendee_dashboard')) + + # Generate PDF + pdf_buffer = generate_badge_pdf(attendee, event) + + return send_file( + pdf_buffer, + mimetype='application/pdf', + as_attachment=True, + download_name=f'badge_{attendee["first_name"]}_{attendee["last_name"]}.pdf' + ) + except Error as e: + flash(f'Database error: {e}') + return redirect(url_for('attendee_dashboard')) + + +@app.route('/attendee/badge/email', methods=['POST']) +@login_required +def email_badge(): + """Send badge PDF to attendee via email.""" + if session.get('user_type') != 'attendee': + flash('Access denied.') + return redirect(url_for('index')) + + try: + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + # Get attendee info + cursor.execute("SELECT * FROM attendees WHERE id = %s", (session['user_id'],)) + attendee = cursor.fetchone() + + # Get event info + cursor.execute("SELECT * FROM events WHERE id = %s", (session['event_id'],)) + event = cursor.fetchone() + + cursor.close() + conn.close() + + if not attendee: + flash('Attendee not found.') + return redirect(url_for('attendee_dashboard')) + + # Generate PDF + pdf_buffer = generate_badge_pdf(attendee, event) + + # Send email + if send_badge_email(attendee['email'], f"{attendee['first_name']} {attendee['last_name']}", + event['name'] if event else 'Event', pdf_buffer): + flash('Badge sent to your email!') + else: + flash('Failed to send badge email. Please try again.') + + return redirect(url_for('attendee_dashboard')) + except Error as e: + flash(f'Database error: {e}') + return redirect(url_for('attendee_dashboard')) + + +@app.route('/attendee/profile', methods=['GET', 'POST']) +@login_required +def attendee_profile(): + """Attendee profile page.""" + if session.get('user_type') != 'attendee': + flash('Access denied.') + return redirect(url_for('index')) + + try: + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute("SELECT * FROM attendees WHERE id = %s", (session['user_id'],)) + attendee = cursor.fetchone() + cursor.close() + conn.close() + + if request.method == 'POST': + first_name = request.form.get('first_name', '').strip() + last_name = request.form.get('last_name', '').strip() + organisation = request.form.get('organisation', '').strip() + role = request.form.get('role', '').strip() + introduction = request.form.get('introduction', '').strip() + + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute(""" + UPDATE attendees SET first_name = %s, last_name = %s, organisation = %s, role = %s, introduction = %s + WHERE id = %s + """, (first_name, last_name, organisation, role, introduction, session['user_id'])) + conn.commit() + cursor.close() + conn.close() + + flash('Profile updated successfully!') + return redirect(url_for('attendee_profile')) + + return render_template('attendee/profile.html', attendee=attendee) + except Error as e: + flash(f'Database error: {e}') + return redirect(url_for('attendee_dashboard')) + + +@app.route('/attendee/photo', methods=['POST']) +@login_required +def upload_photo(): + """Upload profile photo.""" + if session.get('user_type') != 'attendee': + return jsonify({'error': 'Access denied'}), 403 + + if 'photo' not in request.files: + return jsonify({'error': 'No file provided'}), 400 + + file = request.files['photo'] + if file.filename == '': + return jsonify({'error': 'No file selected'}), 400 + + if file: + # Generate unique filename + filename = f"{uuid.uuid4()}.jpg" + filepath = os.path.join(app.config['UPLOAD_FOLDER'], filename) + + # Delete old photo if exists + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + cursor.execute("SELECT profile_picture FROM attendees WHERE id = %s", (session['user_id'],)) + old_photo = cursor.fetchone()['profile_picture'] + if old_photo: + old_path = os.path.join(app.config['UPLOAD_FOLDER'], old_photo) + if os.path.exists(old_path): + os.remove(old_path) + + file.save(filepath) + + cursor.execute("UPDATE attendees SET profile_picture = %s WHERE id = %s", (filename, session['user_id'])) + conn.commit() + cursor.close() + conn.close() + + return jsonify({'success': True, 'filename': filename}) + + return jsonify({'error': 'Invalid file type'}), 400 + + +# Routes - Attendees list and connections +@app.route('/attendees') +@login_required +def list_attendees(): + """List other attendees at the same event.""" + if session.get('user_type') != 'attendee': + flash('Access denied.') + return redirect(url_for('index')) + + try: + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute(""" + SELECT a.*, + (SELECT status FROM connections WHERE attendee_id = %s AND connected_attendee_id = a.id) as my_status + FROM attendees a + WHERE a.event_id = %s AND a.id != %s + """, (session['user_id'], session['event_id'], session['user_id'])) + attendees = cursor.fetchall() + cursor.close() + conn.close() + + return render_template('attendee/attendees.html', attendees=attendees) + except Error as e: + flash(f'Database error: {e}') + return redirect(url_for('attendee_dashboard')) + + +@app.route('/connections', methods=['POST']) +@login_required +def create_connection(): + """Send a connection request.""" + if session.get('user_type') != 'attendee': + return jsonify({'error': 'Access denied'}), 403 + + connected_id = request.form.get('connected_attendee_id') + if not connected_id: + return jsonify({'error': 'Attendee ID required'}), 400 + + try: + conn = get_db_connection() + cursor = conn.cursor() + + # Check if connection already exists + cursor.execute(""" + SELECT id FROM connections + WHERE attendee_id = %s AND connected_attendee_id = %s + """, (session['user_id'], connected_id)) + if cursor.fetchone(): + cursor.close() + conn.close() + return jsonify({'error': 'Connection already exists'}), 400 + + cursor.execute(""" + INSERT INTO connections (attendee_id, connected_attendee_id, status) + VALUES (%s, %s, 'pending') + """, (session['user_id'], connected_id)) + conn.commit() + cursor.close() + conn.close() + + return jsonify({'success': True}) + except Error as e: + return jsonify({'error': str(e)}), 500 + + +@app.route('/connections/', methods=['PUT']) +@login_required +def update_connection(connection_id): + """Accept or reject a connection request.""" + if session.get('user_type') != 'attendee': + return jsonify({'error': 'Access denied'}), 403 + + status = request.json.get('status') if request.json else request.form.get('status') + if status not in ['accepted', 'rejected']: + return jsonify({'error': 'Invalid status'}), 400 + + try: + conn = get_db_connection() + cursor = conn.cursor() + + # Verify this connection is targeting the current user + cursor.execute(""" + SELECT id FROM connections + WHERE id = %s AND connected_attendee_id = %s AND status = 'pending' + """, (connection_id, session['user_id'])) + if not cursor.fetchone(): + cursor.close() + conn.close() + return jsonify({'error': 'Connection not found'}), 404 + + cursor.execute("UPDATE connections SET status = %s WHERE id = %s", (status, connection_id)) + conn.commit() + cursor.close() + conn.close() + + return jsonify({'success': True}) + except Error as e: + return jsonify({'error': str(e)}), 500 + + +@app.route('/attendee/scan') +@login_required +def attendee_scan(): + """QR scanner page for attendees to scan each other.""" + if session.get('user_type') != 'attendee': + flash('Access denied.') + return redirect(url_for('index')) + return render_template('attendee/scan.html') + + +@app.route('/attendee/scan-request', methods=['POST']) +@login_required +def scan_request_connection(): + """Send a connection request when scanning someone's QR code.""" + if session.get('user_type') != 'attendee': + return jsonify({'error': 'Access denied'}), 403 + + scanned_id = request.json.get('scanned_id') if request.json else request.form.get('scanned_id') + if not scanned_id: + return jsonify({'error': 'Scanned attendee ID required'}), 400 + + scanned_id = int(scanned_id) + + # Can't connect to yourself + if scanned_id == session['user_id']: + return jsonify({'error': 'Cannot connect to yourself'}), 400 + + try: + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + # Verify scanned attendee exists and is at same event + cursor.execute(""" + SELECT id FROM attendees WHERE id = %s AND event_id = %s + """, (scanned_id, session['event_id'])) + if not cursor.fetchone(): + cursor.close() + conn.close() + return jsonify({'error': 'Attendee not found at this event'}), 404 + + # Check if connection already exists + cursor.execute(""" + SELECT id, status FROM connections + WHERE attendee_id = %s AND connected_attendee_id = %s + """, (session['user_id'], scanned_id)) + existing = cursor.fetchone() + + if existing: + if existing['status'] == 'accepted': + cursor.close() + conn.close() + return jsonify({'error': 'Already connected'}), 400 + elif existing['status'] == 'pending': + cursor.close() + conn.close() + return jsonify({'error': 'Request already pending'}), 400 + else: + # Rejected - allow new request + cursor.execute(""" + UPDATE connections SET status = 'pending', attendee_id = %s, connected_attendee_id = %s + WHERE id = %s + """, (session['user_id'], scanned_id, existing['id'])) + conn.commit() + cursor.close() + conn.close() + return jsonify({'success': True, 'message': 'Request sent, awaiting approval'}) + else: + # Create new connection request + cursor.execute(""" + INSERT INTO connections (attendee_id, connected_attendee_id, status) + VALUES (%s, %s, 'pending') + """, (session['user_id'], scanned_id)) + conn.commit() + cursor.close() + conn.close() + return jsonify({'success': True, 'message': 'Request sent, awaiting approval'}) + + except Error as e: + return jsonify({'error': str(e)}), 500 + + +@app.route('/attendee/connection-requests') +@login_required +def connection_requests(): + """Show pending connection requests for the current user.""" + if session.get('user_type') != 'attendee': + flash('Access denied.') + return redirect(url_for('index')) + + try: + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + # Get pending requests where current user is the target + cursor.execute(""" + SELECT c.*, a.first_name, a.last_name, a.email, a.organisation, a.role, + a.introduction, a.profile_picture + FROM connections c + JOIN attendees a ON c.attendee_id = a.id + WHERE c.connected_attendee_id = %s AND c.status = 'pending' + ORDER BY c.created_at DESC + """, (session['user_id'],)) + requests = cursor.fetchall() + cursor.close() + conn.close() + + return render_template('attendee/connection_requests.html', requests=requests) + except Error as e: + flash(f'Database error: {e}') + return redirect(url_for('attendee_dashboard')) + + +@app.route('/attendee/connection-request/', methods=['POST']) +@login_required +def respond_to_connection(connection_id): + """Approve or reject a connection request.""" + if session.get('user_type') != 'attendee': + return jsonify({'error': 'Access denied'}), 403 + + action = request.json.get('action') if request.json else request.form.get('action') + if action not in ['approve', 'reject']: + return jsonify({'error': 'Invalid action'}), 400 + + try: + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + # Verify this request is for the current user + cursor.execute(""" + SELECT c.*, a.first_name, a.last_name, a.profile_picture, + a.email, a.organisation, a.role, a.introduction + FROM connections c + JOIN attendees a ON c.attendee_id = a.id + WHERE c.id = %s AND c.connected_attendee_id = %s AND c.status = 'pending' + """, (connection_id, session['user_id'])) + connection = cursor.fetchone() + + if not connection: + cursor.close() + conn.close() + return jsonify({'error': 'Connection request not found'}), 404 + + if action == 'approve': + cursor.execute("UPDATE connections SET status = 'accepted' WHERE id = %s", (connection_id,)) + conn.commit() + cursor.close() + conn.close() + return jsonify({'success': True, 'status': 'accepted'}) + else: + cursor.execute("UPDATE connections SET status = 'rejected' WHERE id = %s", (connection_id,)) + conn.commit() + cursor.close() + conn.close() + return jsonify({'success': True, 'status': 'rejected'}) + + except Error as e: + return jsonify({'error': str(e)}), 500 + + +# Routes - Appointments +@app.route('/appointments', methods=['POST']) +@login_required +def create_appointment(): + """Request an appointment.""" + if session.get('user_type') != 'attendee': + return jsonify({'error': 'Access denied'}), 403 + + target_id = request.form.get('target_id') + appointment_time = request.form.get('appointment_time') + location = request.form.get('location', '').strip() + notes = request.form.get('notes', '').strip() + + if not all([target_id, appointment_time]): + return jsonify({'error': 'Target and time are required'}), 400 + + try: + apt_datetime = datetime.strptime(appointment_time, '%Y-%m-%dT%H:%M') + except ValueError: + return jsonify({'error': 'Invalid datetime format'}), 400 + + try: + conn = get_db_connection() + cursor = conn.cursor() + + cursor.execute(""" + INSERT INTO appointments (event_id, requester_id, target_id, appointment_time, location, notes, status) + VALUES (%s, %s, %s, %s, %s, %s, 'pending') + """, (session['event_id'], session['user_id'], target_id, apt_datetime, location, notes)) + conn.commit() + cursor.close() + conn.close() + + return jsonify({'success': True}) + except Error as e: + return jsonify({'error': str(e)}), 500 + + +@app.route('/appointments/', methods=['PUT']) +@login_required +def update_appointment(appointment_id): + """Accept or reject an appointment.""" + if session.get('user_type') != 'attendee': + return jsonify({'error': 'Access denied'}), 403 + + status = request.json.get('status') if request.json else request.form.get('status') + if status not in ['accepted', 'rejected']: + return jsonify({'error': 'Invalid status'}), 400 + + try: + conn = get_db_connection() + cursor = conn.cursor() + + # Verify user is the target of this appointment + cursor.execute(""" + SELECT id FROM appointments + WHERE id = %s AND target_id = %s AND status = 'pending' + """, (appointment_id, session['user_id'])) + if not cursor.fetchone(): + cursor.close() + conn.close() + return jsonify({'error': 'Appointment not found'}), 404 + + cursor.execute("UPDATE appointments SET status = %s WHERE id = %s", (status, appointment_id)) + conn.commit() + cursor.close() + conn.close() + + return jsonify({'success': True}) + except Error as e: + return jsonify({'error': str(e)}), 500 + + +# Routes - Public event listing +@app.route('/events') +def list_events(): + """List all upcoming public events.""" + try: + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + cursor.execute(""" + SELECT e.*, o.name as organizer_name, + (SELECT COUNT(*) FROM attendees WHERE event_id = e.id) as attendee_count + FROM events e + JOIN organizers o ON e.organizer_id = o.id + WHERE e.start_time >= NOW() + ORDER BY e.start_time ASC + """) + events = cursor.fetchall() + cursor.close() + conn.close() + return render_template('index.html', events=events, show_events=True) + except Error as e: + flash(f'Database error: {e}') + return render_template('index.html') + + +@app.route('/event/register/', methods=['GET', 'POST']) +@app.route('/event/register//', methods=['GET', 'POST']) +def register_event(code, type_code=None): + """Registration page for an event with breakout session registration.""" + # Look up attendee type if type_code is provided + preselected_type = None + if type_code: + try: + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + cursor.execute("SELECT * FROM attendee_types WHERE code = %s", (type_code,)) + preselected_type = cursor.fetchone() + cursor.close() + conn.close() + + if not preselected_type: + flash('Invalid attendee type.') + return redirect(url_for('index')) + except Error as e: + flash(f'Database error: {e}') + return redirect(url_for('index')) + + try: + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + cursor.execute("SELECT * FROM events WHERE code = %s", (code,)) + event = cursor.fetchone() + cursor.close() + conn.close() + + if not event: + flash('Event not found.') + return redirect(url_for('index')) + + # Verify type_code belongs to this event + if preselected_type and preselected_type['event_id'] != event['id']: + flash('Invalid attendee type for this event.') + return redirect(url_for('index')) + except Error as e: + flash(f'Database error: {e}') + return redirect(url_for('index')) + + # Get breakout sessions for the event + try: + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + cursor.execute(""" + SELECT bs.*, + (SELECT COUNT(*) FROM breakout_session_rsvps WHERE breakout_session_id = bs.id AND status = 'registered') as rsvp_count + FROM breakout_sessions bs + WHERE bs.event_id = %s + ORDER BY bs.start_time + """, (event['id'],)) + sessions = cursor.fetchall() + cursor.close() + conn.close() + except Error as e: + sessions = [] + + # Check if user is already registered (for showing breakout sessions) + registered = False + user_id = session.get('user_id') + user_type = session.get('user_type') + + if user_id and user_type == 'attendee' and session.get('event_id') == event['id']: + registered = True + # Add my_rsvp_status to sessions + for session_item in sessions: + try: + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + cursor.execute(""" + SELECT status FROM breakout_session_rsvps + WHERE breakout_session_id = %s AND attendee_id = %s + """, (session_item['id'], user_id)) + result = cursor.fetchone() + session_item['my_rsvp_status'] = result['status'] if result else None + cursor.close() + conn.close() + except Error: + session_item['my_rsvp_status'] = None + else: + for session_item in sessions: + session_item['my_rsvp_status'] = None + + # Handle registration form submission + if request.method == 'POST' and not registered: + first_name = request.form.get('first_name', '').strip() + last_name = request.form.get('last_name', '').strip() + email = request.form.get('email', '').strip() + password = request.form.get('password', '') + confirm_password = request.form.get('confirm_password', '') + organisation = request.form.get('organisation', '').strip() + role = request.form.get('role', '').strip() + phone = request.form.get('phone', '').strip() + linkedin = request.form.get('linkedin', '').strip() + introduction = request.form.get('introduction', '').strip() + selected_sessions = request.form.getlist('breakout_sessions') + + if not all([first_name, last_name, email, password]): + flash('First name, last name, email, and password are required.') + return render_template('attendee/event_register.html', event=event, sessions=sessions, registered=False, preselected_type=preselected_type) + + if password != confirm_password: + flash('Passwords do not match.') + return render_template('attendee/event_register.html', event=event, sessions=sessions, registered=False, preselected_type=preselected_type) + + try: + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + # Check if email already registered for this event + cursor.execute( + "SELECT id FROM attendees WHERE email = %s AND event_id = %s", + (email, event['id']) + ) + if cursor.fetchone(): + flash('Email already registered for this event. Please log in.') + cursor.close() + conn.close() + return redirect(url_for('login')) + + # Check if this type requires payment + if preselected_type and preselected_type.get('price') and preselected_type.get('price') > 0: + # Store registration data in session for payment processing + session['pending_registration'] = { + 'event_id': event['id'], + 'email': email, + 'password': password, + 'first_name': first_name, + 'last_name': last_name, + 'organisation': organisation, + 'role': role, + 'phone': phone, + 'linkedin': linkedin, + 'introduction': introduction, + 'selected_sessions': selected_sessions, + 'attendee_type_id': preselected_type['id'], + 'type_name': preselected_type['name'], + 'price': preselected_type['price'] + } + cursor.close() + conn.close() + return redirect(url_for('payment_page', event_code=event['code'])) + + # Complete registration directly for free types + confirmation_token = uuid.uuid4().hex + password_hash = hash_password(password) + attendee_code = generate_attendee_code() + cursor.execute(""" + INSERT INTO attendees (event_id, email, password_hash, first_name, last_name, organisation, role, phone, linkedin, introduction, confirmation_token, attendee_code, attendee_type_id) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + """, (event['id'], email, password_hash, first_name, last_name, organisation, role, phone, linkedin, introduction, confirmation_token, attendee_code, preselected_type['id'] if preselected_type else None)) + attendee_id = cursor.lastrowid + + # Process breakout session selections + for session_id in selected_sessions: + cursor.execute("SELECT * FROM breakout_sessions WHERE id = %s AND event_id = %s", + (session_id, event['id'])) + session_data = cursor.fetchone() + + if session_data: + if session_data['max_attendees']: + cursor.execute(""" + SELECT COUNT(*) as count FROM breakout_session_rsvps + WHERE breakout_session_id = %s AND status = 'registered' + """, (session_id,)) + count = cursor.fetchone()['count'] + if count >= session_data['max_attendees']: + continue + + cursor.execute(""" + INSERT INTO breakout_session_rsvps (breakout_session_id, attendee_id, status) + VALUES (%s, %s, 'registered') + """, (session_id, attendee_id)) + + conn.commit() + cursor.close() + conn.close() + + event_date = localized_date(event['start_time']) if event['start_time'] else 'To be announced' + personal_page_url = url_for('attendee_personal_page', token=confirmation_token, _external=True) + send_attendee_confirmation_email(email, first_name, event['name'], event_date, event['location'], personal_page_url) + + session['user_id'] = attendee_id + session['user_type'] = 'attendee' + session['event_id'] = event['id'] + + flash('Registration successful!') + return redirect(url_for('attendee_personal_page', token=confirmation_token)) + except Error as e: + flash(f'Database error: {e}') + + return render_template('attendee/event_register.html', event=event, sessions=sessions, registered=registered, preselected_type=preselected_type) + + +@app.route('/event//payment', methods=['GET', 'POST']) +def payment_page(code): + """Payment page for paid attendee types.""" + pending = session.get('pending_registration') + + if not pending or pending.get('event_id') is None: + flash('No pending registration found.') + return redirect(url_for('index')) + + try: + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + cursor.execute("SELECT * FROM events WHERE code = %s", (code,)) + event = cursor.fetchone() + cursor.close() + conn.close() + + if not event: + flash('Event not found.') + return redirect(url_for('index')) + except Error as e: + flash(f'Database error: {e}') + return redirect(url_for('index')) + + if request.method == 'POST': + # Process payment (simulated - in production, integrate with payment gateway like Stripe) + # For now, we just complete the registration + try: + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + # Generate confirmation token + confirmation_token = uuid.uuid4().hex + password_hash = hash_password(pending['password']) + attendee_code = generate_attendee_code() + + cursor.execute(""" + INSERT INTO attendees (event_id, email, password_hash, first_name, last_name, organisation, role, phone, linkedin, introduction, confirmation_token, attendee_code, attendee_type_id) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + """, (pending['event_id'], pending['email'], password_hash, pending['first_name'], pending['last_name'], + pending['organisation'], pending['role'], pending['phone'], pending['linkedin'], + pending['introduction'], confirmation_token, attendee_code, pending['attendee_type_id'])) + attendee_id = cursor.lastrowid + + # Process breakout session selections + for session_id in pending.get('selected_sessions', []): + cursor.execute("SELECT * FROM breakout_sessions WHERE id = %s AND event_id = %s", + (session_id, pending['event_id'])) + session_data = cursor.fetchone() + + if session_data: + if session_data['max_attendees']: + cursor.execute(""" + SELECT COUNT(*) as count FROM breakout_session_rsvps + WHERE breakout_session_id = %s AND status = 'registered' + """, (session_id,)) + count = cursor.fetchone()['count'] + if count >= session_data['max_attendees']: + continue + + cursor.execute(""" + INSERT INTO breakout_session_rsvps (breakout_session_id, attendee_id, status) + VALUES (%s, %s, 'registered') + """, (session_id, attendee_id)) + + conn.commit() + cursor.close() + conn.close() + + # Clear pending registration + session.pop('pending_registration', None) + + # Send confirmation email + event_date = localized_date(event['start_time']) if event['start_time'] else 'To be announced' + personal_page_url = url_for('attendee_personal_page', token=confirmation_token, _external=True) + send_attendee_confirmation_email(pending['email'], pending['first_name'], event['name'], event_date, event['location'], personal_page_url) + + # Auto-login the attendee + session['user_id'] = attendee_id + session['user_type'] = 'attendee' + session['event_id'] = pending['event_id'] + + flash('Registration and payment successful!') + return redirect(url_for('attendee_personal_page', token=confirmation_token)) + except Error as e: + flash(f'Database error: {e}') + + return render_template('attendee/payment.html', event=event, pending=pending) + + +@app.route('/attendee/personal/') +def attendee_personal_page(token): + """Personal page accessed via confirmation email link.""" + try: + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + # Find attendee by confirmation token + cursor.execute("SELECT * FROM attendees WHERE confirmation_token = %s", (token,)) + attendee = cursor.fetchone() + + if not attendee: + flash('Invalid or expired 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() + + # Auto-login the attendee + session['user_id'] = attendee['id'] + session['user_type'] = 'attendee' + session['event_id'] = attendee['event_id'] + + # Get all events for this attendee + cursor.execute(""" + SELECT e.* FROM events e + WHERE e.id = %s + """, (attendee['event_id'],)) + events = cursor.fetchall() + + # For each event, get breakout sessions with RSVP status + for event in events: + cursor.execute(""" + SELECT bs.*, + (SELECT COUNT(*) FROM breakout_session_rsvps + WHERE breakout_session_id = bs.id AND status = 'registered') as rsvp_count, + (SELECT status FROM breakout_session_rsvps + WHERE breakout_session_id = bs.id AND attendee_id = %s) as my_rsvp_status + FROM breakout_sessions bs + WHERE bs.event_id = %s + ORDER BY bs.start_time + """, (attendee['id'], event['id'])) + event['breakout_sessions'] = cursor.fetchall() + + cursor.close() + conn.close() + + return render_template('attendee/personal.html', attendee=attendee, events=events) + + except Error as e: + flash(f'Database error: {e}') + return redirect(url_for('index')) + + +@app.route('/event/') +def view_event(event_id): + """View event details.""" + try: + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + cursor.execute(""" + SELECT e.*, o.name as organizer_name, + (SELECT COUNT(*) FROM attendees WHERE event_id = e.id) as attendee_count + FROM events e + JOIN organizers o ON e.organizer_id = o.id + WHERE e.id = %s + """, (event_id,)) + event = cursor.fetchone() + cursor.close() + conn.close() + + if not event: + flash('Event not found.') + return redirect(url_for('index')) + + return render_template('attendee/event.html', event=event) + except Error as e: + flash(f'Database error: {e}') + return redirect(url_for('index')) + + +if __name__ == '__main__': + app.run(debug=True, host='0.0.0.0', port=5002) diff --git a/config.py b/config.py new file mode 100644 index 0000000..98d655c --- /dev/null +++ b/config.py @@ -0,0 +1,35 @@ +import os + +class Config: + # Internationalization + BABEL_DEFAULT_LOCALE = 'en' + BABEL_SUPPORTED_LOCALES = ['en', 'nl', 'de', 'fr', 'es', 'it', 'pl'] + BABEL_TRANSLATION_DIRECTORIES = 'translations' + SECRET_KEY = os.environ.get('SECRET_KEY') or 'netevent-secret-key-change-in-production' + + # MySQL Database Configuration + DB_HOST = 'roast.duckdns.org' + DB_PORT = 33062 + DB_USER = 'root' + DB_PASSWORD = 'Tiegl!!!111...' + DB_NAME = 'netevent' + + # Dedicated database user (created by init_db.py) + DB_APP_USER = 'netevent_app' + DB_APP_PASSWORD = 'netevent_pass_2024' + + # Email Configuration (Brevo/Sendinblue) + MAIL_SERVER = 'smtp-relay.brevo.com' + MAIL_PORT = 587 + MAIL_USE_TLS = True + MAIL_USERNAME = 'a6000b001@smtp-brevo.com' + MAIL_PASSWORD = 'xsmtpsib-c242e6135185589b9d66ea911d84696b7582fc9ac4d8fd27ace4c5e745bd5f49-xtHwZO3Hu9KCN3W9' + MAIL_DEFAULT_SENDER = 'paul@bokel.nl' + + UPLOAD_FOLDER = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'static', 'uploads') + MAX_CONTENT_LENGTH = 16 * 1024 * 1024 # 16MB max file size + ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif'} + + # Google reCAPTCHA Configuration + RECAPTCHA_SITE_KEY = os.environ.get('RECAPTCHA_SITE_KEY', '') + RECAPTCHA_SECRET_KEY = os.environ.get('RECAPTCHA_SECRET_KEY', '') diff --git a/favicon.svg b/favicon.svg new file mode 100644 index 0000000..6cc9cd5 --- /dev/null +++ b/favicon.svg @@ -0,0 +1,9 @@ + + + NE + + + + + + \ No newline at end of file diff --git a/init_db.py b/init_db.py new file mode 100644 index 0000000..24c8906 --- /dev/null +++ b/init_db.py @@ -0,0 +1,302 @@ +#!/usr/bin/env python3 +"""Database initialization script for NetEvent platform.""" + +import mysql.connector +from mysql.connector import Error + +DB_HOST = 'roast.duckdns.org' +DB_PORT = 33062 +DB_USER = 'root' +DB_PASSWORD = 'Tiegl!!!111...' +DB_NAME = 'netevent' +APP_USER = 'netevent_app' +APP_PASSWORD = 'netevent_pass_2024' + +def create_database(): + """Create the database and dedicated user.""" + try: + conn = mysql.connector.connect( + host=DB_HOST, + port=DB_PORT, + user=DB_USER, + password=DB_PASSWORD + ) + cursor = conn.cursor() + + # Create database if not exists + cursor.execute(f"CREATE DATABASE IF NOT EXISTS {DB_NAME}") + print(f"Database '{DB_NAME}' created or already exists.") + + # Create dedicated application user + cursor.execute(f"CREATE USER IF NOT EXISTS '{APP_USER}'@'%' IDENTIFIED BY '{APP_PASSWORD}'") + cursor.execute(f"GRANT ALL PRIVILEGES ON {DB_NAME}.* TO '{APP_USER}'@'%'") + cursor.execute("FLUSH PRIVILEGES") + print(f"User '{APP_USER}' created with privileges on '{DB_NAME}'.") + + conn.commit() + cursor.close() + conn.close() + return True + except Error as e: + print(f"Error creating database/user: {e}") + return False + +def create_tables(): + """Create all required tables.""" + tables = [ + """ + CREATE TABLE IF NOT EXISTS organizers ( + id INT PRIMARY KEY AUTO_INCREMENT, + email VARCHAR(255) UNIQUE NOT NULL, + password_hash VARCHAR(255) NOT NULL, + name VARCHAR(255) NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + """, + """ + CREATE TABLE IF NOT EXISTS events ( + id INT PRIMARY KEY AUTO_INCREMENT, + organizer_id INT NOT NULL, + code VARCHAR(10) UNIQUE NOT NULL, + name VARCHAR(255) NOT NULL, + description TEXT, + start_time DATETIME NOT NULL, + end_time DATETIME, + location VARCHAR(255), + max_attendees INT DEFAULT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (organizer_id) REFERENCES organizers(id) ON DELETE CASCADE + ) + """, + """ + CREATE TABLE IF NOT EXISTS attendees ( + id INT PRIMARY KEY AUTO_INCREMENT, + event_id INT NOT NULL, + email VARCHAR(255) NOT NULL, + password_hash VARCHAR(255) NOT NULL, + first_name VARCHAR(100) NOT NULL, + last_name VARCHAR(100) NOT NULL, + organisation VARCHAR(255), + role VARCHAR(255), + introduction TEXT, + profile_picture VARCHAR(255) DEFAULT NULL, + checked_in BOOLEAN DEFAULT FALSE, + attendance_status ENUM('attending', 'not_attending') DEFAULT 'attending', + confirmation_token VARCHAR(64) DEFAULT NULL, + attendee_code VARCHAR(10) UNIQUE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (event_id) REFERENCES events(id) ON DELETE CASCADE, + UNIQUE KEY unique_attendee_event (event_id, email) + ) + """, + """ + CREATE TABLE IF NOT EXISTS connections ( + id INT PRIMARY KEY AUTO_INCREMENT, + attendee_id INT NOT NULL, + connected_attendee_id INT NOT NULL, + status ENUM('pending', 'accepted', 'rejected') DEFAULT 'pending', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (attendee_id) REFERENCES attendees(id) ON DELETE CASCADE, + FOREIGN KEY (connected_attendee_id) REFERENCES attendees(id) ON DELETE CASCADE, + UNIQUE KEY unique_connection (attendee_id, connected_attendee_id) + ) + """, + """ + CREATE TABLE IF NOT EXISTS appointments ( + id INT PRIMARY KEY AUTO_INCREMENT, + event_id INT NOT NULL, + requester_id INT NOT NULL, + target_id INT NOT NULL, + appointment_time DATETIME NOT NULL, + location VARCHAR(255), + notes TEXT, + status ENUM('pending', 'accepted', 'rejected') DEFAULT 'pending', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (event_id) REFERENCES events(id) ON DELETE CASCADE, + FOREIGN KEY (requester_id) REFERENCES attendees(id) ON DELETE CASCADE, + FOREIGN KEY (target_id) REFERENCES attendees(id) ON DELETE CASCADE + ) + """, + """ + CREATE TABLE IF NOT EXISTS breakout_sessions ( + id INT PRIMARY KEY AUTO_INCREMENT, + code VARCHAR(10) NOT NULL UNIQUE, + event_id INT NOT NULL, + name VARCHAR(255) NOT NULL, + description TEXT, + start_time DATETIME NOT NULL, + end_time DATETIME NOT NULL, + location VARCHAR(255), + max_attendees INT DEFAULT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (event_id) REFERENCES events(id) ON DELETE CASCADE + ) + """, + """ + CREATE TABLE IF NOT EXISTS breakout_session_organizers ( + id INT PRIMARY KEY AUTO_INCREMENT, + breakout_session_id INT NOT NULL, + organizer_id INT NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (breakout_session_id) REFERENCES breakout_sessions(id) ON DELETE CASCADE, + FOREIGN KEY (organizer_id) REFERENCES organizers(id) ON DELETE CASCADE, + UNIQUE KEY unique_session_organizer (breakout_session_id, organizer_id) + ) + """, + """ + CREATE TABLE IF NOT EXISTS breakout_session_rsvps ( + id INT PRIMARY KEY AUTO_INCREMENT, + breakout_session_id INT NOT NULL, + attendee_id INT NOT NULL, + status ENUM('registered', 'cancelled') DEFAULT 'registered', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (breakout_session_id) REFERENCES breakout_sessions(id) ON DELETE CASCADE, + FOREIGN KEY (attendee_id) REFERENCES attendees(id) ON DELETE CASCADE, + UNIQUE KEY unique_rsvp (breakout_session_id, attendee_id) + ) + """, + """ + CREATE TABLE IF NOT EXISTS staff ( + id INT PRIMARY KEY AUTO_INCREMENT, + event_id INT NOT NULL, + email VARCHAR(255) NOT NULL, + password_hash VARCHAR(255) DEFAULT NULL, + first_name VARCHAR(100) NOT NULL, + last_name VARCHAR(100) NOT NULL, + invite_token VARCHAR(64) DEFAULT NULL, + invite_used BOOLEAN DEFAULT FALSE, + preferred_language VARCHAR(5) DEFAULT 'en', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (event_id) REFERENCES events(id) ON DELETE CASCADE, + UNIQUE KEY unique_staff_event (event_id, email) + ) + """, + # Languages table + """ + CREATE TABLE IF NOT EXISTS languages ( + code VARCHAR(5) PRIMARY KEY, + name VARCHAR(50) NOT NULL, + native_name VARCHAR(50) NOT NULL, + is_active BOOLEAN DEFAULT TRUE, + is_default BOOLEAN DEFAULT FALSE, + date_format VARCHAR(30) DEFAULT '%B %d, %Y at %H:%M', + sort_order INT DEFAULT 0 + ) + """, + """ + CREATE TABLE IF NOT EXISTS attendee_types ( + id INT PRIMARY KEY AUTO_INCREMENT, + event_id INT NOT NULL, + code VARCHAR(10) UNIQUE NOT NULL, + name VARCHAR(100) NOT NULL, + price DECIMAL(10,2) DEFAULT 0.00, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (event_id) REFERENCES events(id) ON DELETE CASCADE + ) + """ + ] + + # Alter existing tables to add preferred_language column + alter_statements = [ + "ALTER TABLE organizers ADD COLUMN preferred_language VARCHAR(5) DEFAULT 'en'", + "ALTER TABLE attendees ADD COLUMN preferred_language VARCHAR(5) DEFAULT 'en'", + "ALTER TABLE attendees ADD COLUMN attendee_code VARCHAR(10) UNIQUE", + "ALTER TABLE attendees ADD COLUMN phone VARCHAR(50) DEFAULT ''", + "ALTER TABLE attendees ADD COLUMN linkedin VARCHAR(255) DEFAULT ''", + "ALTER TABLE attendees ADD COLUMN attendee_type_id INT DEFAULT NULL", + ] + + try: + conn = mysql.connector.connect( + host=DB_HOST, + port=DB_PORT, + user=APP_USER, + password=APP_PASSWORD, + database=DB_NAME + ) + cursor = conn.cursor() + + for table_sql in tables: + cursor.execute(table_sql) + + # Alter existing tables + for alter_sql in alter_statements: + try: + cursor.execute(alter_sql) + except Error as e: + # Column might already exist + if e.errno != 1060: # Duplicate column name + print(f"Warning: {e}") + + # Add foreign key constraint for attendee_type_id + try: + cursor.execute(""" + ALTER TABLE attendees + ADD CONSTRAINT fk_attendee_type + FOREIGN KEY (attendee_type_id) REFERENCES attendee_types(id) ON DELETE SET NULL + """) + except Error as e: + if e.errno != 1060 and e.errno != 1826: # Not duplicate column or duplicate FK + print(f"Warning adding FK: {e}") + + conn.commit() + cursor.close() + conn.close() + print("All tables created successfully.") + return True + except Error as e: + print(f"Error creating tables: {e}") + return False + + +def seed_languages(): + """Seed the languages table with EU languages.""" + languages = [ + ('en', 'English', 'English', True, True, '%B %d, %Y at %H:%M', 1), + ('nl', 'Dutch', 'Nederlands', True, False, '%d %B %Y om %H:%M', 2), + ('de', 'German', 'Deutsch', True, False, '%d. %B %Y um %H:%M', 3), + ('fr', 'French', 'Français', True, False, '%d %B %Y à %H:%M', 4), + ('es', 'Spanish', 'Español', True, False, '%d de %B de %Y a las %H:%M', 5), + ('it', 'Italian', 'Italiano', True, False, '%d %B %Y alle %H:%M', 6), + ('pl', 'Polish', 'Polski', True, False, '%d %B %Y o %H:%M', 7), + ] + + try: + conn = mysql.connector.connect( + host=DB_HOST, + port=DB_PORT, + user=APP_USER, + password=APP_PASSWORD, + database=DB_NAME + ) + cursor = conn.cursor() + + for lang in languages: + cursor.execute(""" + INSERT IGNORE INTO languages (code, name, native_name, is_active, is_default, date_format, sort_order) + VALUES (%s, %s, %s, %s, %s, %s, %s) + """, lang) + + conn.commit() + cursor.close() + conn.close() + print("Languages seeded successfully.") + return True + except Error as e: + print(f"Error seeding languages: {e}") + return False + + +def init_database(): + """Initialize the complete database.""" + print("Initializing NetEvent database...") + if create_database(): + if create_tables(): + seed_languages() + print("Database initialization complete!") + return True + print("Database initialization failed.") + return False + +if __name__ == '__main__': + init_database() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..a821005 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,8 @@ +Flask==3.0.0 +Flask-Cors==4.0.0 +mysql-connector-python==8.2.0 +bcrypt==4.1.2 +Werkzeug==3.0.1 +python-dateutil==2.8.2 +qrcode==7.4.2 +reportlab==4.0.7 diff --git a/static/css/style.css b/static/css/style.css new file mode 100644 index 0000000..0a8cf83 --- /dev/null +++ b/static/css/style.css @@ -0,0 +1,745 @@ +/* Reset and Base Styles */ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +:root { + --primary-color: #2563eb; + --secondary-color: #64748b; + --success-color: #22c55e; + --danger-color: #ef4444; + --warning-color: #f59e0b; + --bg-color: #f8fafc; + --card-bg: #ffffff; + --text-color: #1e293b; + --text-muted: #64748b; + --border-color: #e2e8f0; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif; + line-height: 1.6; + color: var(--text-color); + background-color: var(--bg-color); + min-height: 100vh; + display: flex; + flex-direction: column; +} + +.container { + max-width: 1200px; + margin: 0 auto; + padding: 0 20px; +} + +@media (min-width: 1400px) { + .container { + max-width: none; + padding: 0 40px; + } +} + +/* Navigation */ +.navbar { + background-color: var(--card-bg); + box-shadow: 0 1px 3px rgba(0,0,0,0.1); + padding: 1rem 0; + margin-bottom: 2rem; +} + +.sticky-navbar { + position: sticky; + top: 0; + z-index: 1000; + background-color: var(--card-bg); + box-shadow: 0 1px 3px rgba(0,0,0,0.1); + padding: 1rem 0; +} + +.navbar .container { + display: flex; + justify-content: space-between; + align-items: center; +} + +.logo { + font-size: 1.5rem; + font-weight: bold; + color: var(--primary-color); + text-decoration: none; +} + +.nav-links { + display: flex; + list-style: none; + gap: 1.5rem; +} + +.nav-links a { + color: var(--text-color); + text-decoration: none; + transition: color 0.2s; +} + +.nav-links a:hover { + color: var(--primary-color); +} + +/* Main Content */ +main { + flex: 1; + padding-bottom: 2rem; +} + +/* Footer */ +.footer { + background-color: var(--card-bg); + border-top: 1px solid var(--border-color); + padding: 1.5rem 0; + margin-top: auto; + text-align: center; + color: var(--text-muted); +} + +/* Flash Messages */ +.flash-messages { + margin-bottom: 1.5rem; +} + +.flash-message { + padding: 1rem; + border-radius: 6px; + margin-bottom: 0.5rem; + background-color: #fef3c7; + border-left: 4px solid var(--warning-color); +} + +/* Buttons */ +.btn { + display: inline-block; + padding: 0.75rem 1.5rem; + border-radius: 6px; + text-decoration: none; + font-weight: 500; + text-align: center; + cursor: pointer; + border: none; + transition: all 0.2s; +} + +.btn-primary { + background-color: var(--primary-color); + color: white; +} + +.btn-primary:hover { + background-color: #1d4ed8; +} + +.btn-secondary { + background-color: var(--secondary-color); + color: white; +} + +.btn-secondary:hover { + background-color: #475569; +} + +.btn-outline { + background-color: transparent; + border: 1px solid var(--primary-color); + color: var(--primary-color); +} + +.btn-outline:hover { + background-color: var(--primary-color); + color: white; +} + +.btn-success { + background-color: var(--success-color); + color: white; +} + +.btn-danger { + background-color: var(--danger-color); + color: white; +} + +.btn-block { + display: block; + width: 100%; +} + +.btn-sm { + padding: 0.35rem 1rem; + font-size: 0.875rem; + line-height: 1.2; + box-sizing: border-box; + vertical-align: middle; +} + +/* Hero Section */ +.hero { + text-align: center; + padding: 3rem 0; +} + +.hero h1 { + font-size: 2.5rem; + margin-bottom: 1rem; +} + +.hero-buttons { + display: flex; + gap: 1rem; + justify-content: center; + margin-top: 2rem; +} + +/* Cards */ +.event-card, .feature-card, .connection-card, .attendee-card { + background-color: var(--card-bg); + border-radius: 8px; + padding: 1.5rem; + box-shadow: 0 1px 3px rgba(0,0,0,0.1); +} + +.event-card h3, .connection-card h4 { + margin-bottom: 0.5rem; +} + +.event-date, .event-location { + color: var(--text-muted); + margin-bottom: 0.5rem; +} + +.event-meta { + display: flex; + gap: 1rem; + color: var(--text-muted); + font-size: 0.875rem; + margin-top: 1rem; +} + +.event-card-actions { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin-top: 1rem; +} + +.event-card-actions .btn, +.event-card-actions form .btn { + width: 160px; + min-height: 44px; + height: auto; + box-sizing: border-box; + text-align: center; + justify-content: center; + display: flex; + flex-direction: column; + white-space: normal; + padding: 0.5rem; +} + +.event-card-actions form { + display: flex !important; + width: 160px; + min-height: 44px; + box-sizing: border-box; +} + +.event-card-actions form .btn { + width: 100%; + font-size: inherit; + font-family: inherit; + font-weight: 500; + line-height: inherit; +} + +/* Grids */ +.events-grid, .features-grid, .connections-grid, .attendees-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); + gap: 1.5rem; + margin-top: 1.5rem; +} + +/* Auth Forms */ +.auth-container { + display: flex; + justify-content: center; + align-items: center; + min-height: 60vh; +} + +.auth-box { + background-color: var(--card-bg); + padding: 2rem; + border-radius: 8px; + box-shadow: 0 4px 6px rgba(0,0,0,0.1); + width: 100%; + max-width: 400px; +} + +.auth-box h2 { + text-align: center; + margin-bottom: 1.5rem; +} + +.user-type-tabs { + display: flex; + margin-bottom: 1.5rem; +} + +.tab-btn { + flex: 1; + padding: 0.75rem; + border: 1px solid var(--border-color); + background: var(--bg-color); + cursor: pointer; + transition: all 0.2s; +} + +.tab-btn:first-child { + border-radius: 6px 0 0 6px; +} + +.tab-btn:last-child { + border-radius: 0 6px 6px 0; +} + +.tab-btn.active { + background-color: var(--primary-color); + color: white; + border-color: var(--primary-color); +} + +.auth-footer { + text-align: center; + margin-top: 1.5rem; + color: var(--text-muted); +} + +/* Forms */ +.form-group { + margin-bottom: 1.25rem; +} + +.form-group label { + display: block; + margin-bottom: 0.5rem; + font-weight: 500; +} + +.form-group input, +.form-group textarea, +.form-group select { + width: 100%; + padding: 0.75rem; + border: 1px solid var(--border-color); + border-radius: 6px; + font-size: 1rem; + font-family: inherit; +} + +.form-group input:focus, +.form-group textarea:focus { + outline: none; + border-color: var(--primary-color); + box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1); +} + +.form-actions { + display: flex; + gap: 1rem; + margin-top: 1.5rem; +} + +/* reCAPTCHA */ +.recaptcha-container { + margin-bottom: 1rem; +} + +.recaptcha-container .g-recaptcha { + transform-origin: left top; +} + +/* Tables */ +.attendees-table { + width: 100%; + border-collapse: collapse; + background-color: var(--card-bg); + border-radius: 8px; + overflow: hidden; +} + +.attendees-table th, +.attendees-table td { + padding: 1rem; + text-align: left; + border-bottom: 1px solid var(--border-color); +} + +.attendees-table th { + background-color: var(--bg-color); + font-weight: 600; +} + +.attendees-table tr:last-child td { + border-bottom: none; +} + +/* Badges */ +.badge { + display: inline-block; + padding: 0.25rem 0.75rem; + border-radius: 9999px; + font-size: 0.75rem; + font-weight: 500; +} + +.badge-success { + background-color: #dcfce7; + color: #166534; +} + +.badge-pending { + background-color: #fef3c7; + color: #92400e; +} + +.badge-rejected { + background-color: #fee2e2; + color: #991b1b; +} + +/* Dashboard */ +.dashboard h1 { + margin-bottom: 1.5rem; +} + +.dashboard-actions { + display: flex; + gap: 1rem; + margin-bottom: 2rem; +} + +.events-list { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.event-item { + display: flex; + justify-content: space-between; + align-items: center; + background-color: var(--card-bg); + padding: 1.5rem; + border-radius: 8px; + box-shadow: 0 1px 3px rgba(0,0,0,0.1); +} + +.event-actions { + display: flex; + gap: 0.5rem; +} + +/* Badges Print Page */ +.badges-page { + padding: 2rem; +} + +.badges-header { + text-align: center; + margin-bottom: 2rem; + padding-bottom: 1rem; + border-bottom: 2px solid var(--border-color); +} + +.badges-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); + gap: 1.5rem; +} + +.badge-card { + border: 2px solid #333; + border-radius: 8px; + padding: 1.5rem; + background: white; +} + +.badge-header { + border-bottom: 1px solid var(--border-color); + padding-bottom: 0.75rem; + margin-bottom: 0.75rem; +} + +.badge-header h3 { + margin: 0; + font-size: 1.25rem; +} + +.badge-body { + min-height: 80px; +} + +.badge-org { + font-weight: 600; + font-size: 1rem; + margin-bottom: 0.25rem; +} + +.badge-role { + color: var(--text-muted); + font-size: 0.875rem; + margin-bottom: 0.5rem; +} + +.badge-intro { + font-size: 0.75rem; + color: var(--text-muted); + font-style: italic; +} + +.badge-footer { + display: flex; + justify-content: space-between; + align-items: center; + padding-top: 0.75rem; + border-top: 1px solid var(--border-color); + font-size: 0.75rem; +} + +.badge-check { + color: var(--text-muted); +} + +.badge-check.checked-in { + color: var(--success-color); + font-weight: 600; +} + +/* Profile Page */ +.profile-page h1 { + margin-bottom: 1.5rem; +} + +.profile-container { + display: grid; + grid-template-columns: 250px 1fr; + gap: 2rem; + background-color: var(--card-bg); + padding: 2rem; + border-radius: 8px; + box-shadow: 0 1px 3px rgba(0,0,0,0.1); +} + +.profile-photo-section { + text-align: center; +} + +.current-photo { + margin-bottom: 1rem; +} + +.profile-img { + width: 150px; + height: 150px; + border-radius: 50%; + object-fit: cover; +} + +.no-photo { + width: 150px; + height: 150px; + border-radius: 50%; + background-color: var(--bg-color); + display: flex; + align-items: center; + justify-content: center; + margin: 0 auto; + font-size: 2rem; + color: var(--text-muted); +} + +.photo-upload-form { + margin-top: 1rem; +} + +/* Attendees Page */ +.attendees-page h1 { + margin-bottom: 0.5rem; +} + +.attendees-page > p { + color: var(--text-muted); + margin-bottom: 2rem; +} + +.attendee-card { + display: flex; + flex-direction: column; + align-items: center; + text-align: center; +} + +.attendee-photo { + width: 80px; + height: 80px; + margin-bottom: 1rem; +} + +.attendee-photo img { + width: 100%; + height: 100%; + border-radius: 50%; + object-fit: cover; +} + +.attendee-photo .no-photo { + width: 80px; + height: 80px; + font-size: 1.5rem; +} + +.attendee-info h3 { + margin-bottom: 0.25rem; +} + +.attendee-org { + font-weight: 600; + color: var(--primary-color); +} + +.attendee-role { + font-size: 0.875rem; + color: var(--text-muted); +} + +.attendee-intro { + font-size: 0.75rem; + color: var(--text-muted); + margin-top: 0.5rem; +} + +.attendee-actions { + margin-top: 1rem; +} + +/* Profile Page */ +.profile-page h1 { + margin-bottom: 1.5rem; +} + +/* Appointments & Connections */ +.pending-section, .connections-section, .appointments-section { + margin-top: 2rem; +} + +.pending-section h2, .connections-section h2, .appointments-section h2 { + margin-bottom: 1rem; +} + +.pending-list, .appointments-list { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.pending-item, .appointment-item { + display: flex; + justify-content: space-between; + align-items: center; + background-color: var(--card-bg); + padding: 1rem 1.5rem; + border-radius: 8px; + box-shadow: 0 1px 3px rgba(0,0,0,0.1); +} + +.inline-form { + display: inline-block; +} + +.pending-info, .apt-info { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.pending-actions, .apt-actions { + display: flex; + gap: 0.5rem; +} + +.section-actions { + margin-bottom: 1rem; +} + +.text-muted { + color: var(--text-muted); + font-size: 0.875rem; +} + +.no-data { + color: var(--text-muted); + font-style: italic; +} + +/* Event Meta Box */ +.event-meta-box, .event-description-box { + background-color: var(--card-bg); + padding: 1.5rem; + border-radius: 8px; + margin-bottom: 1.5rem; +} + +.event-meta-box p, .event-description-box p { + margin-bottom: 0.5rem; +} + +/* Event Detail Page */ +.event-header { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 1rem; + margin-bottom: 2rem; +} + +.event-header h1 { + margin: 0; +} + +.event-actions { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +/* Responsive */ +@media (max-width: 768px) { + .profile-container { + grid-template-columns: 1fr; + } + + .event-item { + flex-direction: column; + align-items: flex-start; + gap: 1rem; + } + + .attendees-table { + font-size: 0.875rem; + } + + .attendees-table th, + .attendees-table td { + padding: 0.75rem 0.5rem; + } +} diff --git a/static/favicon.svg b/static/favicon.svg new file mode 100644 index 0000000..6cc9cd5 --- /dev/null +++ b/static/favicon.svg @@ -0,0 +1,9 @@ + + + NE + + + + + + \ No newline at end of file diff --git a/static/js/main.js b/static/js/main.js new file mode 100644 index 0000000..10bb70b --- /dev/null +++ b/static/js/main.js @@ -0,0 +1,93 @@ +// NetEvent - Main JavaScript + +document.addEventListener('DOMContentLoaded', function() { + // Auto-hide flash messages after 5 seconds + const flashMessages = document.querySelectorAll('.flash-message'); + flashMessages.forEach(msg => { + setTimeout(() => { + msg.style.transition = 'opacity 0.5s'; + msg.style.opacity = '0'; + setTimeout(() => msg.remove(), 500); + }, 5000); + }); + + // Form validation + const forms = document.querySelectorAll('form'); + forms.forEach(form => { + form.addEventListener('submit', function(e) { + const requiredInputs = form.querySelectorAll('[required]'); + let isValid = true; + + requiredInputs.forEach(input => { + if (!input.value.trim()) { + isValid = false; + input.style.borderColor = '#ef4444'; + } else { + input.style.borderColor = ''; + } + }); + + if (!isValid && !e.target.dataset.noValidate) { + e.preventDefault(); + alert('Please fill in all required fields.'); + } + }); + }); + + // Photo upload form + const photoForm = document.querySelector('.photo-upload-form'); + if (photoForm) { + photoForm.addEventListener('submit', async function(e) { + e.preventDefault(); + + const formData = new FormData(this); + + try { + const response = await fetch(this.action, { + method: 'POST', + body: formData + }); + + const data = await response.json(); + + if (data.success) { + // Reload page to show new photo + location.reload(); + } else { + alert(data.error || 'Error uploading photo'); + } + } catch (error) { + alert('Error uploading photo'); + } + }); + } + + // Connection request buttons + const connectForms = document.querySelectorAll('.connect-form'); + connectForms.forEach(form => { + form.addEventListener('submit', async function(e) { + e.preventDefault(); + + const formData = new FormData(this); + + try { + const response = await fetch(this.action, { + method: 'POST', + body: formData + }); + + const data = await response.json(); + + if (data.success) { + const card = this.closest('.attendee-card'); + const actionsDiv = card.querySelector('.attendee-actions'); + actionsDiv.innerHTML = 'Request Pending'; + } else { + alert(data.error || 'Error sending connection request'); + } + } catch (error) { + alert('Error sending connection request'); + } + }); + }); +}); diff --git a/static/uploads/19985df3-c12e-44f4-8d12-23ff6a7099a7.jpg b/static/uploads/19985df3-c12e-44f4-8d12-23ff6a7099a7.jpg new file mode 100644 index 0000000000000000000000000000000000000000..7951f2eccf9bcf2b8fa3be06eeadf24cab937115 GIT binary patch literal 32267 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 ZM*fTk(J=>!ui8;}+_m-*(XT(j|JjlRmyQ4c literal 0 HcmV?d00001 diff --git a/static/uploads/4077c4be-c151-492b-b96a-ade05037c574.jpg b/static/uploads/4077c4be-c151-492b-b96a-ade05037c574.jpg new file mode 100644 index 0000000000000000000000000000000000000000..7951f2eccf9bcf2b8fa3be06eeadf24cab937115 GIT binary patch literal 32267 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 ZM*fTk(J=>!ui8;}+_m-*(XT(j|JjlRmyQ4c literal 0 HcmV?d00001 diff --git a/static/uploads/d247ee5b-e3bc-49b8-be4e-7b5f32d12f8c.jpg b/static/uploads/d247ee5b-e3bc-49b8-be4e-7b5f32d12f8c.jpg new file mode 100644 index 0000000000000000000000000000000000000000..7951f2eccf9bcf2b8fa3be06eeadf24cab937115 GIT binary patch literal 32267 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 ZM*fTk(J=>!ui8;}+_m-*(XT(j|JjlRmyQ4c literal 0 HcmV?d00001 diff --git a/templates/attendee/attendees.html b/templates/attendee/attendees.html new file mode 100644 index 0000000..f9c6ffe --- /dev/null +++ b/templates/attendee/attendees.html @@ -0,0 +1,50 @@ +{% extends "base.html" %} + +{% block title %}{{ 'attendees'|t }} - NetEvents{% endblock %} + +{% block content %} +
+

{{ 'event_attendees'|t }}

+

{{ 'connect_with_attendees'|t }}

+ + {% if attendees %} +
+ {% for att in attendees %} +
+
+ {% if att.profile_picture %} + Photo + {% else %} +
{{ att.first_name[0] }}{{ att.last_name[0] }}
+ {% endif %} +
+
+

{{ att.first_name }} {{ att.last_name }}

+

{{ (att.organisation)|spacify if att.organisation else '' }}

+

{{ (att.role)|spacify if att.role else '' }}

+ {% if att.introduction %} +

{{ att.introduction[:100] }}{% if att.introduction|length > 100 %}...{% endif %}

+ {% endif %} +
+
+ {% if att.my_status == 'accepted' %} + {{ 'connected'|t }} + {% elif att.my_status == 'pending' %} + {{ 'request_pending'|t }} + {% elif att.my_status == 'rejected' %} + {{ 'rejected'|t }} + {% else %} +
+ + +
+ {% endif %} +
+
+ {% endfor %} +
+ {% else %} +

{{ 'no_other_attendees'|t }}

+ {% endif %} +
+{% endblock %} \ No newline at end of file diff --git a/templates/attendee/breakout_sessions.html b/templates/attendee/breakout_sessions.html new file mode 100644 index 0000000..28c4f57 --- /dev/null +++ b/templates/attendee/breakout_sessions.html @@ -0,0 +1,85 @@ +{% extends "base.html" %} + +{% block title %}{{ 'breakout_sessions'|t }} - {{ event.name }}{% endblock %} + +{% block content %} +
+

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

+ + {% with messages = get_flashed_messages() %} + {% if messages %} +
+ {% for message in messages %} +

{{ message }}

+ {% endfor %} +
+ {% endif %} + {% endwith %} + + {% if sessions %} +
+ {% for session in sessions %} +
+
+

{{ session.name }}

+

{{ 'time'|t }}: {{ session.start_time|localized_date('%H:%M') if session.start_time else '' }} - {{ session.end_time|localized_date('%H:%M') if session.end_time else '' }}

+

{{ 'location'|t }}: {{ session.location }}

+ {% if session.max_attendees %} +

{{ 'capacity'|t }}: {{ session.rsvp_count }} / {{ session.max_attendees }}

+ {% else %} +

{{ 'registered'|t }}: {{ session.rsvp_count }}

+ {% endif %} + {% if session.description %} +

{{ session.description }}

+ {% endif %} +
+
+ {% if session.my_rsvp_status == 'registered' %} + + {% elif session.my_rsvp_status == 'cancelled' %} + + {% else %} + {% if not session.max_attendees or session.rsvp_count < session.max_attendees %} + + {% else %} + {{ 'session_full'|t }} + {% endif %} + {% endif %} +
+
+ {% endfor %} +
+ {% else %} +

{{ 'no_breakout_sessions'|t }}

+ {% endif %} + + +
+ + +{% endblock %} \ No newline at end of file diff --git a/templates/attendee/connection_requests.html b/templates/attendee/connection_requests.html new file mode 100644 index 0000000..af0e72b --- /dev/null +++ b/templates/attendee/connection_requests.html @@ -0,0 +1,154 @@ +{% extends "base.html" %} + +{% block title %}{{ 'connection_requests'|t }} - NetEvents{% endblock %} + +{% block content %} +
+

{{ 'connection_requests'|t }}

+

{{ 'people_scanned_qr'|t }}

+ + {% with messages = get_flashed_messages() %} + {% if messages %} +
+ {% for message in messages %} +

{{ message }}

+ {% endfor %} +
+ {% endif %} + {% endwith %} + + {% if requests %} +
+ {% for req in requests %} +
+
+ {% if req.profile_picture %} + {{ req.first_name }} + {% else %} +
{{ req.first_name[0] }}{{ req.last_name[0] }}
+ {% endif %} +
+
+

{{ req.first_name }} {{ req.last_name }}

+

{{ req.email }}

+

{{ (req.organisation)|spacify if req.organisation else '' }}{% if req.role %} - {{ (req.role)|spacify }}{% endif %}

+ {% if req.introduction %} +

"{{ req.introduction }}"

+ {% endif %} +

{{ 'requested'|t }} {{ req.created_at|localized_date if req.created_at else 'recently' }}

+
+
+ + +
+
+ {% endfor %} +
+ {% else %} +

{{ 'no_pending_requests'|t }}

+ {% endif %} + + +
+ + + + +{% endblock %} \ No newline at end of file diff --git a/templates/attendee/dashboard.html b/templates/attendee/dashboard.html new file mode 100644 index 0000000..9bf389f --- /dev/null +++ b/templates/attendee/dashboard.html @@ -0,0 +1,102 @@ +{% extends "base.html" %} + +{% block title %}{{ 'attendee_dashboard'|t }} - NetEvents{% endblock %} + +{% block content %} +
+

{{ 'attendee_dashboard'|t }}

+ + {% if event %} +
+

{{ 'current_event'|t }}

+
+

{{ event.name }}

+

{{ 'start'|t }}: {{ event.start_time|localized_date if event.start_time else 'TBD' }}

+ {% if event.end_time %} +

{{ 'end'|t }}: {{ event.end_time|localized_date }}

+ {% endif %} +

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

+ +
+
+ {% endif %} + + {% if pending_connections %} +
+

{{ 'incoming_connection_requests'|t }}

+

{{ 'review_scan_requests'|t }}

+
+ {% endif %} + +
+

{{ 'my_connections'|t }}

+ + + {% if connections %} +
+ {% for conn in connections %} +
+

{{ conn.first_name }} {{ conn.last_name }}

+

{{ (conn.organisation)|spacify if conn.organisation else '' }}

+

{{ (conn.role)|spacify if conn.role else '' }}

+
+ {% endfor %} +
+ {% else %} +

{{ 'no_connections_yet'|t }}

+ {% endif %} +
+ +
+

{{ 'my_appointments'|t }}

+ + + {% if appointments %} +
+ {% for apt in appointments %} +
+
+

+ {% if apt.requester_id == session.user_id %} + {{ 'with'|t }} {{ apt.target_first_name }} {{ apt.target_last_name }} + {% else %} + {{ 'with'|t }} {{ apt.requester_first_name }} {{ apt.requester_last_name }} + {% endif %} +

+

{{ 'time'|t }}: {{ apt.appointment_time|localized_date if apt.appointment_time else 'TBD' }}

+

{{ 'location'|t }}: {{ apt.location or 'TBD' }}

+

{{ (apt.status)|spacify if apt.status else '' }}

+
+ {% if apt.status == 'pending' and apt.target_id == session.user_id %} +
+
+ + +
+
+ + +
+
+ {% endif %} +
+ {% endfor %} +
+ {% else %} +

{{ 'no_appointments'|t }}

+ {% endif %} +
+
+{% endblock %} diff --git a/templates/attendee/event.html b/templates/attendee/event.html new file mode 100644 index 0000000..7fb52af --- /dev/null +++ b/templates/attendee/event.html @@ -0,0 +1,37 @@ +{% extends "base.html" %} + +{% block title %}{{ event.name }} - NetEvents{% endblock %} + +{% block content %} +
+

{{ event.name }}

+ +
+

{{ 'start'|t }}: {{ event.start_time|localized_date if event.start_time else 'TBD' }}

+ {% if event.end_time %} +

{{ 'end'|t }}: {{ event.end_time|localized_date }}

+ {% endif %} +

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

+

{{ 'organizer'|t }}: {{ event.organizer_name }}

+

{{ 'attendees'|t }}: {{ event.attendee_count }}

+ {% if event.max_attendees %} +

{{ 'spots_remaining'|t }}: {{ event.max_attendees - event.attendee_count }}

+ {% endif %} +
+ + {% if event.description %} +
+

{{ 'about_this_event'|t }}

+

{{ event.description }}

+
+ {% endif %} + + {% if not session.user_id %} +
+

{{ 'want_to_attend'|t }}

+ {{ 'register_as_attendee'|t }} + +
+ {% endif %} +
+{% endblock %} \ No newline at end of file diff --git a/templates/attendee/event_register.html b/templates/attendee/event_register.html new file mode 100644 index 0000000..dbe89d5 --- /dev/null +++ b/templates/attendee/event_register.html @@ -0,0 +1,488 @@ +{% extends "base.html" %} + +{% block title %}{{ 'register_for'|t }} {{ event.name }} - NetEvents{% endblock %} + +{% block content %} +
+
+

{{ event.name }}

+
+

{{ 'start'|t }}: {{ event.start_time|localized_date if event.start_time else 'TBD' }}

+ {% if event.end_time %} +

{{ 'end'|t }}: {{ event.end_time|localized_date }}

+ {% endif %} +

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

+
+ {% if event.description %} +
+

{{ event.description }}

+
+ {% endif %} +
+ +
+ {% with messages = get_flashed_messages() %} + {% if messages %} +
+ {% for message in messages %} +

{{ message }}

+ {% endfor %} +
+ {% endif %} + {% endwith %} + + {% if not registered %} + +
+

{{ 'register_as_attendee'|t }}

+ {% if preselected_type %} +
+

{{ 'registration_type'|t }}: {{ preselected_type.name }} + {% if preselected_type.price and preselected_type.price > 0 %} + {{ preselected_type.price|format_currency }} + {% else %} + {{ 'free'|t }} + {% endif %} +

+
+ {% endif %} +
+
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + + 0/254 +
+ +
+ + +
+ +
+ + +
+ + {% if sessions %} +
+ +

{{ 'select_breakout_sessions_hint'|t }}

+
+ {% for session in sessions %} +
+ = session.max_attendees %}disabled{% endif %}> + +
+ {% endfor %} +
+
+ {% endif %} + + +
+ +
+ {% else %} + +
+

{{ 'breakout_sessions'|t }}

+

{{ 'choose_sessions_intro'|t }}

+ + {% if sessions %} +
+ {% for session in sessions %} +
+
+

{{ session.name }}

+

{{ 'time'|t }}: {{ session.start_time|localized_date('%H:%M') if session.start_time else '' }} - {{ session.end_time|localized_date('%H:%M') if session.end_time else '' }}

+

{{ 'location'|t }}: {{ session.location }}

+ {% if session.max_attendees %} +

{{ 'capacity'|t }}: {{ session.rsvp_count }} / {{ session.max_attendees }}

+ {% else %} +

{{ 'registered'|t }}: {{ session.rsvp_count }}

+ {% endif %} + {% if session.description %} +

{{ session.description }}

+ {% endif %} +
+
+ {% if session.my_rsvp_status == 'registered' %} + + {% elif session.my_rsvp_status == 'cancelled' %} + + {% else %} + {% if not session.max_attendees or session.rsvp_count < session.max_attendees %} + + {% else %} + {{ 'session_full'|t }} + {% endif %} + {% endif %} +
+
+ {% endfor %} +
+ {% else %} +

{{ 'no_breakout_sessions'|t }}

+ {% endif %} + +
+

{{ 'you_are_registered'|t }} {{ event.name }}!

+ {{ 'go_to_login'|t }} +
+
+ {% endif %} +
+
+ + + + +{% endblock %} \ No newline at end of file diff --git a/templates/attendee/payment.html b/templates/attendee/payment.html new file mode 100644 index 0000000..b486c8d --- /dev/null +++ b/templates/attendee/payment.html @@ -0,0 +1,191 @@ +{% extends "base.html" %} + +{% block title %}{{ 'payment'|t }} - {{ event.name }} - NetEvents{% endblock %} + +{% block content %} + + +
+ ← {{ 'back_to_registration'|t }} + +
+

{{ 'complete_payment'|t }}

+ +
+

{{ 'order_summary'|t }}

+
+ {{ 'event_registration'|t }} + {{ event.name }} +
+
+ {{ 'attendee_type'|t }}: + {{ pending.type_name }} +
+
+ {{ 'attendee'|t }}: + {{ pending.first_name }} {{ pending.last_name }} +
+
+ {{ 'email'|t }}: + {{ pending.email }} +
+
+ {{ 'total'|t }}: + {{ pending.price|format_currency }} {{ get_currency_symbol() }} +
+
+ +
+
+ + +
+ +
+ + +
+ +
+
+ + +
+
+ + +
+
+ + +
+ +

{{ 'payment_secure'|t }}

+
+
+ + +{% endblock %} diff --git a/templates/attendee/personal.html b/templates/attendee/personal.html new file mode 100644 index 0000000..1df0f44 --- /dev/null +++ b/templates/attendee/personal.html @@ -0,0 +1,201 @@ +{% extends "base.html" %} + +{% block title %}{{ 'my_events'|t }} - NetEvents{% endblock %} + +{% block content %} +
+

{{ 'welcome'|t }}, {{ attendee.first_name }}!

+

{{ 'registration_confirmed'|t }}

+ + {% for event in events %} +
+

{{ event.name }}

+
+

{{ 'date'|t }}: {{ event.start_time|localized_date if event.start_time else 'TBD' }}

+

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

+

{{ 'status'|t }}: {{ 'registered'|t }}

+
+ +

{{ 'breakout_sessions'|t }}

+ {% if event.breakout_sessions %} +
+ {% for session in event.breakout_sessions %} +
+
+ {{ session.name }} + {{ session.start_time|localized_date('%H:%M') if session.start_time else '' }} - {{ session.end_time|localized_date('%H:%M') if session.end_time else '' }} + {{ session.location }} + {% if session.description %} + {{ session.description }} + {% endif %} +
+
+ {% if session.my_rsvp_status == 'registered' %} + {{ 'registered'|t }} + {% else %} + {{ 'not_registered'|t }} + {% endif %} +
+
+ {% endfor %} +
+ {% else %} +

{{ 'no_breakout_sessions'|t }}

+ {% endif %} +
+ {% endfor %} + + +
+{% endblock %} + +{% block extra_styles %} + +{% endblock %} \ No newline at end of file diff --git a/templates/attendee/profile.html b/templates/attendee/profile.html new file mode 100644 index 0000000..2d649e6 --- /dev/null +++ b/templates/attendee/profile.html @@ -0,0 +1,321 @@ +{% extends "base.html" %} + +{% block title %}{{ 'profile'|t }} - NetEvents{% endblock %} + +{% block content %} +
+

{{ 'my_profile'|t }}

+ +
+
+
+ {% if attendee.profile_picture %} + {{ 'profile_photo'|t }} + {% else %} + +
{{ 'no_photo'|t }}
+ {% endif %} +
+ + + +
+ + 100% + + +
+ +
+
+ + +
+ +
+
+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +
+
+
+
+ + + + +{% endblock %} \ No newline at end of file diff --git a/templates/attendee/scan.html b/templates/attendee/scan.html new file mode 100644 index 0000000..aca5fbc --- /dev/null +++ b/templates/attendee/scan.html @@ -0,0 +1,229 @@ +{% extends "base.html" %} + +{% block title %}{{ 'scan_qr'|t }} - NetEvents{% endblock %} + +{% block content %} +
+
+

{{ 'scan_attendee'|t }}

+

{{ 'scan_qr_description'|t }}

+ {{ 'back_to_dashboard'|t }} +
+ +
+
+ +
+ +
+

{{ 'scan_info_text'|t }}

+

{{ 'scan_info_warning'|t }}

+
+
+ + + + + +{% endblock %} \ No newline at end of file diff --git a/templates/auth/login.html b/templates/auth/login.html new file mode 100644 index 0000000..2d8f7f5 --- /dev/null +++ b/templates/auth/login.html @@ -0,0 +1,61 @@ +{% extends "base.html" %} + +{% block title %}{{ 'login'|t }} - NetEvents{% endblock %} + +{% block content %} +
+
+

{{ 'login'|t }}

+ +
+ + + + +
+ +
+ + +
+ + +
+ +
+ + +
+ + +
+ + +
+
+ + +{% endblock %} \ No newline at end of file diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 0000000..a5cb7d8 --- /dev/null +++ b/templates/base.html @@ -0,0 +1,56 @@ + + + + + + {% block title %}NetEvents{% endblock %} + + + + + + +
+ {% with messages = get_flashed_messages() %} + {% if messages %} +
+ {% for message in messages %} +
{{ message }}
+ {% endfor %} +
+ {% endif %} + {% endwith %} + + {% block content %}{% endblock %} +
+ +
+
+

© 2024 NetEvents - Networking Event Platform

+
+
+ + + + diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..453be74 --- /dev/null +++ b/templates/index.html @@ -0,0 +1,75 @@ +{% extends "base.html" %} + +{% block title %}{{ 'welcome'|t }} - NetEvents{% endblock %} + +{% block content %} +
+

{{ 'welcome'|t }}

+

{{ 'connect_with_professionals'|t }}

+ {% if not session.user_id %} + + + {% endif %} +
+ +{% if show_events %} +
+

{{ 'upcoming_events'|t }}

+ {% if events %} +
+ {% for event in events %} +
+

{{ event.name }}

+

{{ event.start_time|localized_date if event.start_time else 'TBD' }}

+

{{ event.location }}

+

{{ event.description[:150] }}{% if event.description and event.description|length > 150 %}...{% endif %}

+
+ {{ 'organiser'|t }}: {{ event.organizer_name }} + {{ event.attendee_count }} {{ 'attendees'|t }} +
+ {{ 'view_event'|t }} +
+ {% endfor %} +
+ {% else %} +

{{ 'no_upcoming_events'|t }}

+ {% endif %} +
+{% endif %} + +{% if not session.user_id and not show_events %} +
+

{{ 'platform_features'|t }}

+
+
+

{{ 'for_organizers'|t }}

+
    +
  • {{ 'create_manage_events'|t }}
  • +
  • {{ 'view_attendee_lists'|t }}
  • +
  • {{ 'print_badges'|t }}
  • +
  • {{ 'track_statistics'|t }}
  • +
+
+
+

{{ 'for_attendees'|t }}

+
    +
  • {{ 'rsvp_events'|t }}
  • +
  • {{ 'manage_profile'|t }}
  • +
  • {{ 'connect_attendees'|t }}
  • +
  • {{ 'schedule_appointments'|t }}
  • +
+
+
+
+{% endif %} +{% endblock %} diff --git a/templates/organizer/all_attendee_types.html b/templates/organizer/all_attendee_types.html new file mode 100644 index 0000000..d4cbd5c --- /dev/null +++ b/templates/organizer/all_attendee_types.html @@ -0,0 +1,169 @@ +{% extends "base.html" %} + +{% block title %}{{ 'attendee_types'|t }} - NetEvents{% endblock %} + +{% block content %} + + +
+ ← {{ 'back_to_dashboard'|t }} + + + + {% if events %} + {% for event in events %} +
+

+ {{ event.name }} + {{ 'view_event'|t }} +

+ + {% if event.attendee_types %} + + + + + + + + + + + + {% for at in event.attendee_types %} + + + + + + + + {% endfor %} + +
{{ 'type_name'|t }}{{ 'price'|t }}{{ 'registration_link'|t }}{{ 'attendees'|t }}{{ 'actions'|t }}
{{ at.name }} + {% if at.price and at.price > 0 %} + {{ at.price|format_currency }} + {% else %} + {{ 'free'|t }} + {% endif %} + + + {{ url_for('register_event', code=event.code, type_code=at.code, _external=True) }} + + + {{ at.attendee_count }} + {{ 'manage'|t }} +
+ {% else %} +

{{ 'no_attendee_types_for_event'|t }} {{ 'create_first_type'|t }}

+ {% endif %} +
+ {% endfor %} + {% else %} +

+ {{ 'no_events_yet'|t }} +

+ {% endif %} +
+ + +{% endblock %} diff --git a/templates/organizer/attendee_types.html b/templates/organizer/attendee_types.html new file mode 100644 index 0000000..4842c7b --- /dev/null +++ b/templates/organizer/attendee_types.html @@ -0,0 +1,182 @@ +{% extends "base.html" %} + +{% block title %}{{ 'attendee_types'|t }} - {{ event.name }} - NetEvents{% endblock %} + +{% block content %} + + +
+ ← {{ 'back_to_event'|t }} + +

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

+ +
+

{{ 'create_attendee_type'|t }}

+
+
+
+ + +
+
+ + + {{ 'leave_empty_for_free'|t }} +
+
+ +
+
+ +
+

{{ 'existing_types'|t }}

+ + {% if attendee_types %} + + + + + + + + + + + {% for at in attendee_types %} + + + + + + + {% endfor %} + +
{{ 'name'|t }}{{ 'price'|t }}{{ 'registration_link'|t }}{{ 'actions'|t }}
{{ at.name }} + {% if at.price and at.price > 0 %} + {{ at.price|format_currency }} + {% else %} + {{ 'free'|t }} + {% endif %} + + + {{ url_for('register_event', code=event.code, type_code=at.code, _external=True) }} + + + +
+ +
+
+ {% else %} +

+ {{ 'no_attendee_types_yet'|t }} +

+ {% endif %} +
+
+ + +{% endblock %} diff --git a/templates/organizer/badges.html b/templates/organizer/badges.html new file mode 100644 index 0000000..92a3db9 --- /dev/null +++ b/templates/organizer/badges.html @@ -0,0 +1,104 @@ +{% extends "base.html" %} + +{% block title %}{{ 'badges'|t }} - {{ event.name }}{% endblock %} + +{% block content %} +
+
+

{{ event.name }}

+

{{ 'attendee_badges'|t }}

+
+ 📷 {{ 'scan_qr_codes'|t }} + +
+
+ +
+ {% for attendee in attendees %} +
+
+

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

+
+
+ QR Code +
+
+

{{ (attendee.organisation)|spacify if attendee.organisation else '' }}

+

{{ (attendee.role)|spacify if attendee.role else '' }}

+ {% if attendee.introduction %} +

{{ attendee.introduction[:80] }}{% if attendee.introduction|length > 80 %}...{% endif %}

+ {% endif %} +
+ +
+ {% endfor %} +
+ + + +
+{% endblock %} \ No newline at end of file diff --git a/templates/organizer/breakout_session_detail.html b/templates/organizer/breakout_session_detail.html new file mode 100644 index 0000000..c62f643 --- /dev/null +++ b/templates/organizer/breakout_session_detail.html @@ -0,0 +1,58 @@ +{% extends "base.html" %} + +{% block title %}{{ session.name }} - {{ 'breakout_session'|t }}{% endblock %} + +{% block content %} +
+ + +
+

{{ 'event'|t }}: {{ session.event_name }}

+

{{ 'time'|t }}: {{ session.start_time|localized_date if session.start_time else 'TBD' }} - {{ session.end_time|localized_date('%H:%M') if session.end_time else 'TBD' }}

+

{{ 'location'|t }}: {{ session.location }}

+

{{ 'capacity'|t }}: + {% if session.max_attendees %} + {{ rsvps|length }} / {{ session.max_attendees }} + {% else %} + {{ rsvps|length }} ({{ 'unlimited'|t }}) + {% endif %} +

+ {% if session.description %} +

{{ 'description'|t }}: {{ session.description }}

+ {% endif %} +
+ +
+

{{ 'registered_attendees'|t }}

+ + {% if rsvps %} + + + + + + + + + + + {% for rsvp in rsvps %} + + + + + + + {% endfor %} + +
{{ 'name'|t }}{{ 'email'|t }}{{ 'organisation'|t }}{{ 'role'|t }}
{{ rsvp.first_name }} {{ rsvp.last_name }}{{ rsvp.email }}{{ (rsvp.organisation)|spacify if rsvp.organisation else '-' }}{{ (rsvp.role)|spacify if rsvp.role else '-' }}
+ {% else %} +

{{ 'no_attendees_yet'|t }}

+ {% endif %} +
+
+{% endblock %} \ No newline at end of file diff --git a/templates/organizer/breakout_sessions.html b/templates/organizer/breakout_sessions.html new file mode 100644 index 0000000..07db238 --- /dev/null +++ b/templates/organizer/breakout_sessions.html @@ -0,0 +1,43 @@ +{% extends "base.html" %} + +{% block title %}{{ 'breakout_sessions'|t }} - {{ event.name }}{% endblock %} + +{% block content %} +
+ + + {% if sessions %} +
+ {% for session in sessions %} +
+
+

{{ session.name }}

+

{{ 'time'|t }}: {{ session.start_time|localized_date('%H:%M') if session.start_time else '' }} - {{ session.end_time|localized_date('%H:%M') if session.end_time else '' }}

+

{{ 'location'|t }}: {{ session.location }}

+ {% if session.max_attendees %} +

{{ 'capacity'|t }}: {{ session.rsvp_count }} / {{ session.max_attendees }}

+ {% else %} +

{{ 'registered'|t }}: {{ session.rsvp_count }}

+ {% endif %} + {% if session.description %} +

{{ session.description }}

+ {% endif %} +
+ +
+ {% endfor %} +
+ {% else %} +

{{ 'no_breakout_sessions_yet'|t }}

+ {% endif %} + + +
+{% endblock %} \ No newline at end of file diff --git a/templates/organizer/create_breakout_session.html b/templates/organizer/create_breakout_session.html new file mode 100644 index 0000000..43c7458 --- /dev/null +++ b/templates/organizer/create_breakout_session.html @@ -0,0 +1,47 @@ +{% extends "base.html" %} + +{% block title %}{{ 'create_breakout_session'|t }} - NetEvents{% endblock %} + +{% block content %} +
+

{{ 'create_breakout_session'|t }}

+

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

+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + {{ 'cancel'|t }} +
+
+
+{% endblock %} \ No newline at end of file diff --git a/templates/organizer/create_event.html b/templates/organizer/create_event.html new file mode 100644 index 0000000..3a47fc1 --- /dev/null +++ b/templates/organizer/create_event.html @@ -0,0 +1,46 @@ +{% extends "base.html" %} + +{% block title %}{{ 'create_event'|t }} - NetEvents{% endblock %} + +{% block content %} +
+

{{ 'create_new_event'|t }}

+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + {{ 'cancel'|t }} +
+
+
+{% endblock %} \ No newline at end of file diff --git a/templates/organizer/dashboard.html b/templates/organizer/dashboard.html new file mode 100644 index 0000000..5c1c9da --- /dev/null +++ b/templates/organizer/dashboard.html @@ -0,0 +1,307 @@ +{% extends "base.html" %} + +{% block title %}{{ 'dashboard'|t }} - {{ 'organizer'|t }} - NetEvents{% endblock %} + +{% block content %} + + + + +
+

{{ 'dashboard'|t }} - {{ 'organizer'|t }}

+ + + +
+

{{ 'my_events'|t }}

+ {% if events %} +
+ {% for event in events %} +
+
+

{{ event.name }}

+ +
+
+

{{ event.start_time|localized_date if event.start_time else 'TBD' }}

+

{{ event.location }}

+ + {% if event.max_attendees %} + {% set percent = (event.attendee_count / event.max_attendees * 100)|round|int %} +
+ {{ event.attendee_count }} / {{ event.max_attendees }} {{ 'attendees'|t }} + ({{ percent }}%) +
+
+
+
+ {% else %} +
+ {{ event.attendee_count }} {{ 'attendees'|t }} ({{ 'unlimited'|t }}) +
+ {% endif %} +
+ + {% if event.staff %} +
+
+ Staff ({{ event.staff|length }}) +
+ +
+ {% endif %} + + {% if event.breakout_sessions %} +
+
+ {{ 'breakout_sessions'|t }} ({{ event.breakout_sessions|length }}) +
+ +
+ {% endif %} + + {% if event.attendees %} +
+
+ {{ 'attendees'|t }} ({{ event.attendees|length }}) +
+ +
+ {% endif %} +
+ {% endfor %} +
+ {% else %} +

{{ 'no_events_yet'|t }} + {{ 'create_first_event'|t }} +

+ {% endif %} +
+
+{% endblock %} \ No newline at end of file diff --git a/templates/organizer/edit_attendee.html b/templates/organizer/edit_attendee.html new file mode 100644 index 0000000..36794d5 --- /dev/null +++ b/templates/organizer/edit_attendee.html @@ -0,0 +1,46 @@ +{% extends "base.html" %} + +{% block title %}{{ 'edit_attendee'|t }} - NetEvents{% endblock %} + +{% block content %} +
+

{{ 'edit_attendee'|t }}

+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + {{ 'cancel'|t }} +
+
+
+{% endblock %} \ No newline at end of file diff --git a/templates/organizer/edit_breakout_session.html b/templates/organizer/edit_breakout_session.html new file mode 100644 index 0000000..1767445 --- /dev/null +++ b/templates/organizer/edit_breakout_session.html @@ -0,0 +1,47 @@ +{% extends "base.html" %} + +{% block title %}{{ 'edit_breakout_session'|t }} - NetEvents{% endblock %} + +{% block content %} +
+

{{ 'edit_breakout_session'|t }}

+

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

+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + {{ 'cancel'|t }} +
+
+
+{% endblock %} \ No newline at end of file diff --git a/templates/organizer/edit_breakout_session_confirm.html b/templates/organizer/edit_breakout_session_confirm.html new file mode 100644 index 0000000..a03e816 --- /dev/null +++ b/templates/organizer/edit_breakout_session_confirm.html @@ -0,0 +1,126 @@ +{% extends "base.html" %} + +{% block title %}{{ 'confirm_changes'|t }} - {{ session.name }}{% endblock %} + +{% block content %} + + + + + +{% endblock %} \ No newline at end of file diff --git a/templates/organizer/edit_event.html b/templates/organizer/edit_event.html new file mode 100644 index 0000000..9030f06 --- /dev/null +++ b/templates/organizer/edit_event.html @@ -0,0 +1,36 @@ +{% extends "base.html" %} + +{% block title %}{{ 'edit_event'|t }} - NetEvents{% endblock %} + +{% block content %} +
+

{{ 'edit_event'|t }}

+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + {{ 'cancel'|t }} +
+
+
+{% endblock %} \ No newline at end of file diff --git a/templates/organizer/edit_event_confirm.html b/templates/organizer/edit_event_confirm.html new file mode 100644 index 0000000..5684e9d --- /dev/null +++ b/templates/organizer/edit_event_confirm.html @@ -0,0 +1,126 @@ +{% extends "base.html" %} + +{% block title %}{{ 'confirm_changes'|t }} - {{ event.name }}{% endblock %} + +{% block content %} + + + + + +{% endblock %} \ No newline at end of file diff --git a/templates/organizer/edit_staff.html b/templates/organizer/edit_staff.html new file mode 100644 index 0000000..a1ce125 --- /dev/null +++ b/templates/organizer/edit_staff.html @@ -0,0 +1,31 @@ +{% extends "base.html" %} + +{% block title %}{{ 'edit_staff'|t }} - {{ event.name }}{% endblock %} + +{% block content %} +
+

{{ 'edit_staff_member'|t }}

+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + {{ 'cancel'|t }} +
+
+
+{% endblock %} \ No newline at end of file diff --git a/templates/organizer/event_detail.html b/templates/organizer/event_detail.html new file mode 100644 index 0000000..657871a --- /dev/null +++ b/templates/organizer/event_detail.html @@ -0,0 +1,647 @@ +{% extends "base.html" %} + +{% block title %}{{ event.name }} - NetEvents{% endblock %} + +{% block content %} + +
+
+

{{ event.name }}

+ +
+ + {% if event.description %} +
+

{{ 'description'|t }}

+

{{ event.description }}

+
+ {% endif %} + +
+

{{ 'start'|t }}: {{ event.start_time|localized_date if event.start_time else 'TBD' }}

+ {% if event.end_time %} +

{{ 'end'|t }}: {{ event.end_time|localized_date }}

+ {% endif %} +

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

+

{{ 'max_attendees'|t }}: {{ event.max_attendees or ('unlimited'|t) }}

+

{{ 'registered_attendees'|t }}: {{ attendees|length }}

+

{{ 'registration_link'|t }}: {{ url_for('register_event', code=event.code, _external=True) }}

+
+ +
+
+

{{ 'attendee_types'|t }}

+ + + {% if attendee_types %} + + + + + + + + + + {% for at in attendee_types %} + + + + + + {% endfor %} + +
{{ 'type_name'|t }}{{ 'price'|t }}{{ 'registration_link'|t }}
{{ at.name }} + {% if at.price and at.price > 0 %} + {{ at.price|format_currency }} + {% else %} + {{ 'free'|t }} + {% endif %} + + + {{ url_for('register_event', code=event.code, type_code=at.code, _external=True) }} + + +
+ {% else %} +

{{ 'no_attendee_types_defined'|t }} {{ 'create_first_type'|t }}

+ {% endif %} +
+
+ +
+
+

{{ 'staff'|t }}

+
+ +
+ + + + {% if staff %} + + + + + + + + + + + {% for member in staff %} + + + + + + + {% endfor %} + +
{{ 'name'|t }} {{ 'email'|t }} {{ 'status'|t }} {{ 'actions'|t }}
{{ member.first_name }} {{ member.last_name }}{{ member.email }} + {% if member.invite_token %} + {{ 'invite_pending'|t }} + {% else %} + {{ 'active'|t }} + {% endif %} + + {{ 'view'|t }} + +
+ {% else %} +

{{ 'no_staff_yet'|t }}

+ {% endif %} +
+
+ + + +
+
+

{{ 'breakout_sessions'|t }}

+ + + {% if breakout_sessions %} + + + + + + + + + + + + + {% for session in breakout_sessions %} + + + + + + + + + {% endfor %} + +
{{ 'name'|t }}{{ 'time'|t }}{{ 'location'|t }}{{ 'max_attendees'|t }}{{ 'registered'|t }}{{ 'actions'|t }}
{{ session.name }}{{ session.start_time|localized_date }}{{ session.location or '-' }}{{ session.max_attendees or ('unlimited'|t) }}{{ session.registered_count|default(0) }} + {{ 'view'|t }} +
+ {% else %} +

{{ 'no_breakout_sessions_yet'|t }}

+ {% endif %} +
+
+ +
+
+

{{ 'registered_attendees'|t }}

+ + + {% if attendees %} +
+
+ + + {{ 'assign_type'|t }}: + + + +
+
+ + + + + + + + + + + + + + + {% for attendee in attendees %} + + + + + + + + + + {% endfor %} + +
{{ 'name'|t }} {{ 'organisation'|t }} {{ 'role'|t }} {{ 'type'|t }} {{ 'status'|t }} {{ 'actions'|t }}
+ + {{ attendee.first_name }} {{ attendee.last_name }}{{ (attendee.organisation)|spacify if attendee.organisation else '-' }}{{ (attendee.role)|spacify if attendee.role else '-' }} + {% if attendee.attendee_type_name %} + {{ attendee.attendee_type_name }} + {% else %} + - + {% endif %} + + {% if attendee.checked_in %} + {{ 'checked_in'|t }} + {% else %} + {{ 'not_checked_in'|t }} + {% endif %} + + {% if not attendee.checked_in %} + + {% else %} + {{ 'checked_in'|t }} + {% endif %} +
+ {% else %} +

{{ 'no_attendees_yet'|t }}

+ {% endif %} +
+
+
+ + + + + +{% endblock %} diff --git a/templates/organizer/event_staff.html b/templates/organizer/event_staff.html new file mode 100644 index 0000000..2d62b35 --- /dev/null +++ b/templates/organizer/event_staff.html @@ -0,0 +1,192 @@ +{% extends "base.html" %} + +{% block title %}{{ 'staff'|t }} - {{ event.name }}{% endblock %} + +{% block content %} + +
+
+

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

+ {{ 'back_to_event'|t }} +
+ +
+

{{ 'add_staff_member'|t }}

+
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ +
+
+
+ +
+

{{ 'current_staff'|t }} ({{ staff_members|length }})

+ {% if staff_members %} + + + + + + + + + + + {% for staff in staff_members %} + + + + + + + {% endfor %} + +
{{ 'name'|t }} {{ 'email'|t }} {{ 'status'|t }} {{ 'actions'|t }}
{{ staff.first_name }} {{ staff.last_name }}{{ staff.email }} + {% if staff.invite_used %} + {{ 'active'|t }} + {% else %} + {{ 'invite_pending'|t }} + {% endif %} + + {{ 'edit'|t }} + +
+ {% else %} +

{{ 'no_staff_yet'|t }}

+ {% endif %} +
+
+ + + + + +{% endblock %} \ No newline at end of file diff --git a/templates/presenter/dashboard.html b/templates/presenter/dashboard.html new file mode 100644 index 0000000..93aa962 --- /dev/null +++ b/templates/presenter/dashboard.html @@ -0,0 +1,34 @@ +{% extends "base.html" %} + +{% block title %}{{ 'presenter_dashboard'|t }} - NetEvents{% endblock %} + +{% block content %} +
+

{{ 'presenter_dashboard'|t }}

+

{{ 'welcome'|t }}, {{ presenter_name }}

+ +
+

{{ 'my_breakout_sessions'|t }}

+ {% if sessions %} +
+ {% for session in sessions %} +
+

{{ session.name }}

+

{{ 'event'|t }}: {{ session.event_name }}

+

{{ 'time'|t }}: {{ session.start_time|localized_date if session.start_time else 'TBD' }}

+

{{ 'location'|t }}: {{ session.location }}

+

{{ 'registered'|t }}: {{ session.rsvp_count }}{% if session.max_attendees %} / {{ session.max_attendees }}{% endif %}

+ {{ 'view_details'|t }} +
+ {% endfor %} +
+ {% else %} +

{{ 'no_sessions_assigned'|t }}

+ {% endif %} +
+ + +
+{% endblock %} \ No newline at end of file diff --git a/templates/register.html b/templates/register.html new file mode 100644 index 0000000..40a787d --- /dev/null +++ b/templates/register.html @@ -0,0 +1,85 @@ +{% extends "base.html" %} + +{% block title %}{{ 'register'|t }} - NetEvents{% endblock %} + +{% block content %} +
+
+

{{ 'register'|t }}{% if user_type == 'organizer' %} {{ 'as_organizer'|t }}{% elif event %} {{ 'for'|t }} {{ event.name }}{% endif %}

+ + {% if user_type == 'organizer' %} +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ + +
+ {% elif event %} +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ + +
+ {% endif %} + + +
+
+{% endblock %} \ No newline at end of file diff --git a/templates/staff/dashboard.html b/templates/staff/dashboard.html new file mode 100644 index 0000000..c13cd60 --- /dev/null +++ b/templates/staff/dashboard.html @@ -0,0 +1,17 @@ +{% extends "base.html" %} + +{% block title %}{{ 'staff_dashboard'|t }} - {{ event.name }}{% endblock %} + +{% block content %} +
+
+

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

+

{{ 'welcome'|t }}, {{ staff_name }}

+
+ + + +
+{% endblock %} \ No newline at end of file diff --git a/templates/staff/scan.html b/templates/staff/scan.html new file mode 100644 index 0000000..1faec1b --- /dev/null +++ b/templates/staff/scan.html @@ -0,0 +1,387 @@ +{% extends "base.html" %} + +{% block title %}{{ 'scan_qr'|t }} - {{ event.name }}{% endblock %} + +{% block content %} +
+
+

{{ event.name }}

+

{{ 'qr_scanner_checkin'|t }}

+ {{ 'back_to_dashboard'|t }} +
+ +
+
+ +
+ +
+
+ {{ attendees|length }} + {{ 'total'|t }} +
+
+ {{ attendees|selectattr('checked_in')|list|length }} + {{ 'checked_in'|t }} +
+
+ {{ attendees|rejectattr('checked_in')|list|length }} + {{ 'remaining'|t }} +
+
+ +
+

{{ 'recent_checkins'|t }}

+
    +
  • {{ 'no_checkins_yet'|t }}
  • +
+
+
+ + + + + +{% endblock %} \ No newline at end of file diff --git a/templates/staff/staff_events.html b/templates/staff/staff_events.html new file mode 100644 index 0000000..ee59514 --- /dev/null +++ b/templates/staff/staff_events.html @@ -0,0 +1,86 @@ +{% extends "base.html" %} + +{% block title %}{{ 'staff_dashboard'|t }} - NetEvents{% endblock %} + +{% block content %} +
+
+

{{ 'staff_dashboard'|t }}

+

{{ 'welcome'|t }}, {{ staff_name }}

+
+ +
+

{{ 'select_event'|t }}

+ + {% if events %} +
+ {% for event in events %} +
+
+

{{ event.name }}

+

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

+

{{ 'start'|t }}: {{ event.start_time|localized_date if event.start_time else 'TBD' }}

+
+ +
+ {% endfor %} +
+ {% else %} +

{{ 'no_events_assigned'|t }}

+ {% endif %} +
+
+ + +{% endblock %} diff --git a/templates/staff_invite.html b/templates/staff_invite.html new file mode 100644 index 0000000..21e6125 --- /dev/null +++ b/templates/staff_invite.html @@ -0,0 +1,34 @@ +{% extends "base.html" %} + +{% block title %}{{ 'complete_registration'|t }} - NetEvents{% endblock %} + +{% block content %} +
+
+

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

+

{{ 'set_password_complete'|t }}

+ +
+
+ + +
+
+ + +
+ {% if recaptcha_site_key %} +
+
+
+ {% endif %} +
+ +
+
+
+
+{% if recaptcha_site_key %} + +{% endif %} +{% endblock %} \ No newline at end of file