commit dec6446d7d6f222010f120914eabc161d52cca1e Author: Paul Bokel Date: Sat Apr 18 14:53:41 2026 +0000 Initial commit: conference app with Flask Co-Authored-By: Claude Opus 4.6 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 0000000..7951f2e Binary files /dev/null and b/static/uploads/19985df3-c12e-44f4-8d12-23ff6a7099a7.jpg differ 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 0000000..7951f2e Binary files /dev/null and b/static/uploads/4077c4be-c151-492b-b96a-ade05037c574.jpg differ 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 0000000..7951f2e Binary files /dev/null and b/static/uploads/d247ee5b-e3bc-49b8-be4e-7b5f32d12f8c.jpg differ 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