From 934848b7f551aed91842c6496c211e55f293f7ff Mon Sep 17 00:00:00 2001 From: Paul Bokel Date: Tue, 19 May 2026 19:52:42 +0000 Subject: [PATCH] Add password reset flow, attendee profile enhancements, and spec - Add forgot_password and reset_password templates and routes - Enhance profile.html with photo crop/zoom functionality - Add attendee type management to edit_attendee.html - Update templates styling and layout consistency - Document database schema in SPEC.md - Add CLAUDE.md project guidance Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 72 +++++ SPEC.md | 221 +++++++++++---- app.py | 267 ++++++++++++++++-- init_db.py | 2 + static/css/style.css | 69 ++++- templates/attendee/attendees.html | 108 ++++--- templates/attendee/connection_requests.html | 2 +- templates/attendee/dashboard.html | 4 +- templates/attendee/profile.html | 55 ++++ templates/auth/forgot_password.html | 26 ++ templates/auth/login.html | 4 + templates/auth/reset_password.html | 24 ++ templates/organizer/badges.html | 4 +- .../organizer/breakout_session_detail.html | 4 +- .../organizer/create_breakout_session.html | 2 +- templates/organizer/edit_attendee.html | 5 + .../organizer/edit_breakout_session.html | 2 +- templates/organizer/event_detail.html | 6 +- templates/register.html | 5 + 19 files changed, 758 insertions(+), 124 deletions(-) create mode 100644 CLAUDE.md create mode 100644 templates/auth/forgot_password.html create mode 100644 templates/auth/reset_password.html diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..5743b39 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,72 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview +NetEvent is a Flask-based networking event platform with MySQL backend. It supports organizers, attendees, and staff with features including event management, QR-based check-in, breakout sessions, connection requests, appointments, and multi-language support. + +## Running the Application +```bash +python app.py +``` +The server runs on the port configured in the memory system (auto-restarts on same port after edits). + +## Database Setup +```bash +python init_db.py +``` +- Creates `netevent` database and `netevent_app` user +- Creates all tables including breakout_sessions, staff, languages, attendee_types +- Seeds languages table with 7 EU languages +- Uses ALTER statements to add columns to existing tables (safe to re-run) + +## Key Architecture Patterns + +### Database Connections +- `get_db_connection()` at line 557 - creates connections using `Config.DB_APP_USER` credentials +- All database queries use parameterized queries to prevent SQL injection + +### Authentication +- `login_required` decorator (line 648) - protects routes requiring login +- Session stores `user_id` and `user_type` ('organizer', 'attendee', or 'staff') +- `get_current_user()` returns (user_id, user_type) from session +- Passwords hashed with bcrypt via `hash_password()` / `verify_password()` + +### Internationalization +- Flask-Babel with 7 locales: en, nl, de, fr, es, it, pl +- Locale detection order: URL `?lang=` param → session → cookie → user preference → default +- Translation function `_()` aliased as `t()` for use in templates +- In templates: `{{ 'key'|t }}` for translations + +### User Types +- **Organizer**: Creates events, manages attendees, prints badges, scans QR codes +- **Attendee**: Registers for events, manages profile, connects with others, books appointments +- **Staff**: Assigned to events via invite codes, can scan attendee QR codes for check-in + +### Key Templates +- `templates/base.html` - base template with navigation and translation support +- `templates/attendee/profile.html` - profile with photo crop/zoom (client-side) +- `templates/organizer/badges.html` - printable badge layout (PDF-ready via reportlab) +- `templates/organizer/scan.html` - QR scanner for check-in (camera-based) + +### QR Code System +- Attendees have unique `attendee_code` (10 chars) stored in `attendees` table +- QR codes encode the attendee code for scanning +- Organizer/staff scan to check in attendees via `/organizer/event//scan` + +### Email +- Uses Brevo SMTP (smtp-relay.brevo.com) configured in config.py +- Used for password reset tokens and event communications + +### Password Reset Flow +1. User submits email to `/forgot-password` +2. Backend creates `reset_token` and `reset_token_expiry` on organizer record +3. Email sent with link to `/reset-password/` +4. Token validated at `reset-password` route, password updated + +## Security Notes +- Passwords hashed with bcrypt (never stored in plaintext) +- Session-based auth with SECRET_KEY in config.py +- reCAPTCHA validation available for forms (configured via env vars) +- Rate limiting on login attempts (in-memory `failed_login_attempts` dict) +- File uploads restricted to images (png, jpg, jpeg, gif) with 16MB limit \ No newline at end of file diff --git a/SPEC.md b/SPEC.md index 017c519..b588944 100644 --- a/SPEC.md +++ b/SPEC.md @@ -3,8 +3,8 @@ ## 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 +- **Core Functionality**: A platform for organizing networking events where attendees can RSVP, connect, schedule appointments, and attend breakout sessions +- **Target Users**: Event organizers, event staff, and event attendees ## Technology Stack - **Backend**: Python Flask @@ -22,6 +22,10 @@ | email | VARCHAR(255) UNIQUE | Organizer email | | password_hash | VARCHAR(255) | Hashed password | | name | VARCHAR(255) | Organizer name | +| staff_code | VARCHAR(10) UNIQUE | Staff access code | +| preferred_language | VARCHAR(5) DEFAULT 'en' | Preferred language code | +| reset_token | VARCHAR(64) | Password reset token | +| reset_token_expiry | DATETIME | Password reset token expiry | | created_at | TIMESTAMP | Creation timestamp | #### `events` @@ -29,7 +33,7 @@ |--------|------|-------------| | 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 | +| code | VARCHAR(10) UNIQUE | Unique 10-char alphanumeric event code | | name | VARCHAR(255) | Event name | | description | TEXT | Event description | | start_time | DATETIME | Event start date/time | @@ -51,6 +55,14 @@ | role | VARCHAR(255) | Role/Profession | | introduction | TEXT | Short introduction | | profile_picture | VARCHAR(255) | Profile picture path | +| checked_in | BOOLEAN DEFAULT FALSE | Check-in status | +| attendance_status | ENUM('attending', 'not_attending') | Attendance status | +| confirmation_token | VARCHAR(64) | Email confirmation token | +| attendee_code | VARCHAR(10) UNIQUE | Unique attendee code for QR | +| phone | VARCHAR(50) | Phone number | +| linkedin | VARCHAR(255) | LinkedIn profile URL | +| attendee_type_id | INT FOREIGN KEY | Reference to attendee_types | +| preferred_language | VARCHAR(5) DEFAULT 'en' | Preferred language code | | created_at | TIMESTAMP | Creation timestamp | #### `connections` @@ -75,68 +87,142 @@ | status | ENUM('pending','accepted','rejected') | Appointment status | | created_at | TIMESTAMP | Creation timestamp | +#### `breakout_sessions` +| Column | Type | Description | +|--------|------|-------------| +| id | INT PRIMARY KEY AUTO_INCREMENT | Session ID | +| code | VARCHAR(10) UNIQUE | Unique session code | +| event_id | INT FOREIGN KEY | Reference to events | +| name | VARCHAR(255) | Session name | +| description | TEXT | Session description | +| start_time | DATETIME | Session start time | +| end_time | DATETIME | Session end time | +| location | VARCHAR(255) | Session location | +| max_attendees | INT | Maximum attendees (NULL = unlimited) | +| created_at | TIMESTAMP | Creation timestamp | + +#### `breakout_session_organizers` +| Column | Type | Description | +|--------|------|-------------| +| id | INT PRIMARY KEY AUTO_INCREMENT | ID | +| breakout_session_id | INT FOREIGN KEY | Reference to breakout_sessions | +| organizer_id | INT FOREIGN KEY | Reference to organizers | +| created_at | TIMESTAMP | Creation timestamp | + +#### `breakout_session_rsvps` +| Column | Type | Description | +|--------|------|-------------| +| id | INT PRIMARY KEY AUTO_INCREMENT | RSVP ID | +| breakout_session_id | INT FOREIGN KEY | Reference to breakout_sessions | +| attendee_id | INT FOREIGN KEY | Reference to attendees | +| status | ENUM('registered','cancelled') | RSVP status | +| created_at | TIMESTAMP | Creation timestamp | + +#### `staff` +| Column | Type | Description | +|--------|------|-------------| +| id | INT PRIMARY KEY AUTO_INCREMENT | Staff ID | +| event_id | INT FOREIGN KEY | Reference to events | +| email | VARCHAR(255) | Staff email | +| password_hash | VARCHAR(255) | Hashed password | +| first_name | VARCHAR(100) | First name | +| last_name | VARCHAR(100) | Last name | +| staff_code | VARCHAR(10) UNIQUE | Staff access code | +| invite_token | VARCHAR(64) | Invite token | +| invite_used | BOOLEAN DEFAULT FALSE | Invite used flag | +| preferred_language | VARCHAR(5) DEFAULT 'en' | Preferred language code | +| created_at | TIMESTAMP | Creation timestamp | + +#### `languages` +| Column | Type | Description | +|--------|------|-------------| +| code | VARCHAR(5) PRIMARY KEY | Language code | +| name | VARCHAR(50) | English language name | +| native_name | VARCHAR(50) | Native language name | +| is_active | BOOLEAN | Active status | +| is_default | BOOLEAN | Default language flag | +| date_format | VARCHAR(30) | Date format string | +| sort_order | INT | Display sort order | + +#### `attendee_types` +| Column | Type | Description | +|--------|------|-------------| +| id | INT PRIMARY KEY AUTO_INCREMENT | Type ID | +| event_id | INT FOREIGN KEY | Reference to events | +| code | VARCHAR(10) | Type code (unique per event) | +| name | VARCHAR(100) | Type name | +| price | DECIMAL(10,2) | Ticket price | +| created_at | TIMESTAMP | Creation timestamp | + ## Functionality Specification -### Organiser Features +### Organizer Features 1. **Authentication**: Login/logout for organizers 2. **Event Management**: Create, edit, delete events -3. **Attendee List**: View all attendees for their events +3. **Attendee Management**: View, edit attendees; manage attendee types 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 +5. **QR Code Scanning**: Mobile camera-based check-in by scanning attendee QR codes +6. **Breakout Sessions**: Create/manage breakout sessions within events +7. **Staff Management**: Add staff members to events via invite codes +8. **Event Staff View**: See which staff are assigned to events + +### Staff Features +1. **Authentication**: Login via staff invite code +2. **QR Scanning**: Scan attendee QR codes for check-in +3. **Event Dashboard**: View assigned events and scan functionality ### 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 +2. **Event Registration**: RSVP for events with optional payment +3. **Profile Management**: Update profile with name, org, role, intro, photo (with crop/zoom) 4. **Connections**: Send/accept/reject connection requests 5. **Appointments**: Request/accept/reject meeting appointments +6. **Breakout Sessions**: RSVP for breakout sessions +7. **Password Reset**: Request password reset via email ### User Interactions & Flows -#### Organiser Flow -1. Login → Dashboard → Create Event → View Attendees → Print Badges +#### Organizer Flow +1. Login → Dashboard → Create Event → Manage Attendees → Print Badges / Scan Check-in +2. Create breakout sessions, manage attendee types, invite staff + +#### Staff Flow +1. Use invite code → Login → View assigned events → Scan attendees for check-in #### Attendee Flow 1. Register → Login → RSVP to Event → Manage Profile → Connect with Attendees → Request Appointments +2. Browse and register for breakout sessions -## API Endpoints +## Key Pages ### 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 +- `/auth/login` - Organizer/attendee login +- `/auth/forgot-password` - Request password reset +- `/auth/reset-password/` - Reset password with token -### 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 +### Organizer +- `/organizer/dashboard` - Event overview and management +- `/organizer/events/create` - Create new event +- `/organizer/events/` - Event details and management +- `/organizer/events//edit` - Edit event +- `/organizer/events//attendees` - View attendee list +- `/organizer/events//badges` - Print badges +- `/organizer/events//scan` - QR scanner for check-in +- `/organizer/events//edit-attendee/` - Edit attendee +- `/organizer/events//attendee-types` - Manage attendee types +- `/organizer/events//breakout-sessions` - Manage breakout sessions +- `/organizer/events//breakout-sessions/create` - Create breakout session +- `/organizer/events//breakout-sessions/` - Edit breakout session +- `/organizer/events//staff` - Event staff management -### 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 +### Attendee +- `/attendee/dashboard` - Event dashboard +- `/attendee/profile` - Profile management with photo crop/zoom +- `/attendee/connections` - View accepted connections +- `/attendee/connection-requests` - Manage incoming connection requests +- `/attendee/appointments` - View appointments +- `/register` - Event registration +- `/attendee/personal` - Personal information page ## Security - Password hashing with bcrypt @@ -154,24 +240,51 @@ ├── static/ │ ├── css/ │ │ └── style.css -│ └── js/ -│ └── main.js +│ ├── js/ +│ │ └── main.js +│ └── uploads/ # User uploaded files └── templates/ ├── base.html ├── index.html + ├── register.html + ├── attendees.html ├── auth/ │ ├── login.html - │ └── register.html + │ ├── forgot_password.html + │ └── reset_password.html ├── organizer/ │ ├── dashboard.html │ ├── create_event.html + │ ├── edit_event.html + │ ├── edit_event_confirm.html │ ├── event_detail.html + │ ├── event_staff.html │ ├── badges.html + │ ├── scan.html + │ ├── edit_attendee.html + │ ├── attendee_types.html + │ ├── all_attendee_types.html + │ ├── edit_attendee_type.html + │ ├── breakout_sessions.html + │ ├── breakout_session_detail.html + │ ├── create_breakout_session.html + │ ├── edit_breakout_session.html + │ ├── edit_breakout_session_confirm.html + │ └── edit_staff.html + ├── attendee/ + │ ├── dashboard.html + │ ├── profile.html + │ ├── connections.html + │ ├── connection_requests.html + │ ├── event.html + │ ├── event_register.html + │ ├── payment.html + │ ├── personal.html + │ └── request_link.html + ├── staff/ + │ ├── dashboard.html + │ ├── staff_events.html │ └── scan.html - └── attendee/ - ├── dashboard.html - ├── event.html - ├── profile.html - ├── connections.html - └── appointments.html -``` + └── presenter/ + └── dashboard.html +``` \ No newline at end of file diff --git a/app.py b/app.py index 593c773..96c0661 100644 --- a/app.py +++ b/app.py @@ -6,13 +6,15 @@ import smtplib import string import uuid import requests -from datetime import datetime +from datetime import datetime, timedelta 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 secrets + import bcrypt import mysql.connector import qrcode @@ -351,6 +353,12 @@ ENGLISH_TRANSLATIONS = { 'payment_secure': 'Your payment is secure and encrypted', 'john_doe': 'John Doe', 'mm/yy': 'MM/YY', + 'search_attendees': 'Search attendees', + 'search_min_chars': 'Enter at least %d characters to search', + 'search_too_broad': 'Too many results. Please enter more specific search criteria.', + 'no_attendees_found': 'No attendees found matching your search.', + 'event_attendees': 'Event Attendees', + 'visible_to_attendees': 'Visible to other attendees', } @@ -765,6 +773,83 @@ def clear_failed_logins(ip_address): del failed_login_attempts[ip_address] +# Password reset rate limiting +password_reset_attempts = {} # {ip_address: [(timestamp, count), ...]} +PASSWORD_RESET_BLOCK_DURATION = 60 * 60 # 1 hour in seconds +PASSWORD_RESET_MAX_ATTEMPTS = 5 # Max requests per hour before blocking + + +def is_password_reset_blocked(ip_address): + """Check if an IP is blocked from password reset requests.""" + if ip_address not in password_reset_attempts: + return False + + current_time = datetime.now() + password_reset_attempts[ip_address] = [ + (ts, count) for ts, count in password_reset_attempts[ip_address] + if (current_time - ts).total_seconds() < PASSWORD_RESET_BLOCK_DURATION + ] + + if not password_reset_attempts[ip_address]: + del password_reset_attempts[ip_address] + return False + + total_attempts = sum(count for _, count in password_reset_attempts[ip_address]) + return total_attempts >= PASSWORD_RESET_MAX_ATTEMPTS + + +def record_password_reset_attempt(ip_address): + """Record a password reset request attempt.""" + current_time = datetime.now() + if ip_address not in password_reset_attempts: + password_reset_attempts[ip_address] = [] + password_reset_attempts[ip_address].append((current_time, 1)) + + +def generate_password_reset_token(): + """Generate a secure random token for password reset.""" + return secrets.token_urlsafe(32) + + +def send_password_reset_email(organizer_email, organizer_name, reset_token): + """Send password reset email to organizer.""" + reset_url = url_for('reset_password', token=reset_token, _external=True) + + subject = "Password Reset - NetEvents" + body = f"""Hello {organizer_name}, + +You requested a password reset for your NetEvents organizer account. + +Click the link below to set a new password: +{reset_url} + +This link will expire in 1 hour. + +If you did not request a password reset, please ignore this email. + +Best regards, +The NetEvents Team +""" + + msg = MIMEMultipart() + msg['From'] = Config.MAIL_DEFAULT_SENDER + msg['To'] = organizer_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 ensure_staff_code_column(): """Ensure the staff_code column exists in organizers table.""" try: @@ -1483,6 +1568,115 @@ def login(): return render_template('auth/login.html', default_type=default_type) +@app.route('/forgot-password', methods=['GET', 'POST']) +def forgot_password(): + """Forgot password page - allows organizer to request a reset link.""" + client_ip = request.remote_addr + + if is_password_reset_blocked(client_ip): + flash('Too many password reset requests. Please try again later.') + return render_template('auth/forgot_password.html') + + if request.method == 'POST': + email = request.form.get('email', '').strip() + + if not email: + flash('Please enter your email address.') + return render_template('auth/forgot_password.html') + + try: + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + cursor.execute("SELECT * FROM organizers WHERE email = %s", (email,)) + organizer = cursor.fetchone() + cursor.close() + conn.close() + + if organizer: + # Generate and store token + token = generate_password_reset_token() + expiry = datetime.now() + timedelta(hours=1) + + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute(""" + UPDATE organizers SET reset_token = %s, reset_token_expiry = %s + WHERE id = %s + """, (token, expiry, organizer['id'])) + conn.commit() + cursor.close() + conn.close() + + # Send email + send_password_reset_email(organizer['email'], organizer['name'], token) + record_password_reset_attempt(client_ip) + + # Always show same message to prevent email enumeration + flash('If an account exists with that email, a password reset link has been sent.') + return redirect(url_for('login')) + + except Error as e: + flash(f'Database error: {e}') + return render_template('auth/forgot_password.html') + + return render_template('auth/forgot_password.html') + + +@app.route('/reset-password/', methods=['GET', 'POST']) +def reset_password(token): + """Reset password page - validates token and allows new password entry.""" + try: + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + # Find organizer with this valid token + cursor.execute(""" + SELECT id, email, name FROM organizers + WHERE reset_token = %s AND reset_token_expiry > NOW() + """, (token,)) + organizer = cursor.fetchone() + cursor.close() + conn.close() + + if not organizer: + flash('Invalid or expired reset link. Please request a new one.') + return redirect(url_for('forgot_password')) + + if request.method == 'POST': + password = request.form.get('password', '') + confirm_password = request.form.get('confirm_password', '') + + if not password or len(password) < 6: + flash('Password must be at least 6 characters.') + return render_template('auth/reset_password.html', token=token) + + if password != confirm_password: + flash('Passwords do not match.') + return render_template('auth/reset_password.html', token=token) + + # Update password and invalidate token + password_hash = hash_password(password) + + conn = get_db_connection() + cursor = conn.cursor() + cursor.execute(""" + UPDATE organizers SET password_hash = %s, reset_token = NULL, reset_token_expiry = NULL + WHERE id = %s + """, (password_hash, organizer['id'])) + conn.commit() + cursor.close() + conn.close() + + flash('Password successfully reset. Please log in with your new password.') + return redirect(url_for('login')) + + return render_template('auth/reset_password.html', token=token) + + except Error as e: + flash(f'Database error: {e}') + return redirect(url_for('login')) + + @app.route('/presenter/dashboard') @login_required def presenter_dashboard(): @@ -1666,6 +1860,7 @@ def register_attendee(code): organisation = request.form.get('organisation', '').strip() role = request.form.get('role', '').strip() introduction = request.form.get('introduction', '').strip() + visible_to_others = request.form.get('visible_to_others') == '1' if not all([first_name, last_name, email, password]): flash('First name, last name, email, and password are required.') @@ -1693,9 +1888,9 @@ def register_attendee(code): 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)) + INSERT INTO attendees (event_id, email, password_hash, first_name, last_name, organisation, role, introduction, attendee_code, visible_to_others) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + """, (event['id'], email, password_hash, first_name, last_name, organisation, role, introduction, attendee_code, visible_to_others)) conn.commit() cursor.close() conn.close() @@ -3234,12 +3429,13 @@ def edit_attendee(attendee_id): role = request.form.get('role', '').strip() introduction = request.form.get('introduction', '').strip() attendee_type_id = request.form.get('attendee_type_id', '').strip() + visible_to_others = request.form.get('visible_to_others') == '1' cursor.execute(""" UPDATE attendees - SET first_name = %s, last_name = %s, email = %s, organisation = %s, role = %s, introduction = %s, attendee_type_id = %s + SET first_name = %s, last_name = %s, email = %s, organisation = %s, role = %s, introduction = %s, attendee_type_id = %s, visible_to_others = %s WHERE id = %s - """, (first_name, last_name, email, organisation, role, introduction, attendee_type_id if attendee_type_id else None, attendee_id)) + """, (first_name, last_name, email, organisation, role, introduction, attendee_type_id if attendee_type_id else None, visible_to_others, attendee_id)) conn.commit() cursor.close() conn.close() @@ -3829,13 +4025,14 @@ def attendee_profile(): organisation = request.form.get('organisation', '').strip() role = request.form.get('role', '').strip() introduction = request.form.get('introduction', '').strip() + visible_to_others = request.form.get('visible_to_others') == '1' conn = get_db_connection() cursor = conn.cursor() cursor.execute(""" - UPDATE attendees SET first_name = %s, last_name = %s, organisation = %s, role = %s, introduction = %s, phone = %s, linkedin = %s + UPDATE attendees SET first_name = %s, last_name = %s, organisation = %s, role = %s, introduction = %s, phone = %s, linkedin = %s, visible_to_others = %s WHERE id = %s - """, (first_name, last_name, organisation, role, introduction, request.form.get('phone', '').strip(), request.form.get('linkedin', '').strip(), session['user_id'])) + """, (first_name, last_name, organisation, role, introduction, request.form.get('phone', '').strip(), request.form.get('linkedin', '').strip(), visible_to_others, session['user_id'])) conn.commit() cursor.close() conn.close() @@ -3894,26 +4091,62 @@ def upload_photo(): @app.route('/attendees') @login_required def list_attendees(): - """List other attendees at the same event.""" + """List other attendees at the same event based on search criteria.""" if session.get('user_type') != 'attendee': flash('Access denied.') return redirect(url_for('index')) + # Check if user has visibility enabled + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + cursor.execute("SELECT visible_to_others FROM attendees WHERE id = %s", (session['user_id'],)) + user = cursor.fetchone() + cursor.close() + conn.close() + + if not user or not user['visible_to_others']: + flash('You must enable visibility to see other attendees.') + return redirect(url_for('attendee_dashboard')) + + search_query = request.args.get('q', '').strip() + attendees = [] + too_broad = False + min_chars = 5 + 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() + # Get event details + cursor.execute("SELECT * FROM events WHERE id = %s", (session['event_id'],)) + event = cursor.fetchone() + + if len(search_query) >= min_chars: + # Search in first_name, last_name, and organisation + search_pattern = f'%{search_query}%' + 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 + AND a.visible_to_others = TRUE + AND (a.first_name LIKE %s OR a.last_name LIKE %s OR a.organisation LIKE %s) + """, (session['user_id'], session['event_id'], session['user_id'], search_pattern, search_pattern, search_pattern)) + attendees = cursor.fetchall() + + # Check if more than 2 attendees found + if len(attendees) > 2: + too_broad = True + attendees = [] + else: + # Don't show any attendees if search is less than 5 chars + attendees = [] + cursor.close() conn.close() - return render_template('attendee/attendees.html', attendees=attendees) + return render_template('attendee/attendees.html', attendees=attendees, event=event, + search_query=search_query, too_broad=too_broad, min_chars=min_chars) except Error as e: flash(f'Database error: {e}') return redirect(url_for('attendee_dashboard')) diff --git a/init_db.py b/init_db.py index 718f3aa..7c645be 100644 --- a/init_db.py +++ b/init_db.py @@ -207,6 +207,8 @@ def create_tables(): "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", + "ALTER TABLE organizers ADD COLUMN reset_token VARCHAR(64) DEFAULT NULL", + "ALTER TABLE organizers ADD COLUMN reset_token_expiry DATETIME DEFAULT NULL", ] try: diff --git a/static/css/style.css b/static/css/style.css index 0f03ff0..8b05384 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -463,6 +463,15 @@ main { box-shadow: 0 1px 3px rgba(0,0,0,0.1); } +/* Form Container */ +.form-container, +.event-staff { + background-color: var(--card-bg); + padding: 2rem; + border-radius: 8px; + box-shadow: 0 1px 3px rgba(0,0,0,0.1); +} + .event-actions { display: flex; gap: 0.5rem; @@ -592,15 +601,73 @@ main { } /* Attendees Page */ +.attendees-page { + padding: 0 var(--page-padding, 2rem); +} + .attendees-page h1 { margin-bottom: 0.5rem; } -.attendees-page > p { +.event-info-header { + margin-bottom: 1.5rem; + padding-bottom: 1rem; + border-bottom: 1px solid var(--border-color); +} + +.event-info-header h1 { + margin-bottom: 0.5rem; +} + +.event-meta { + display: flex; + gap: 1.5rem; + color: var(--text-muted); + font-size: 0.9rem; + margin-bottom: 0.5rem; +} + +.event-description { + margin-top: 0.5rem; + color: var(--text-muted); +} + +.page-subtitle { color: var(--text-muted); margin-bottom: 2rem; } +.attendee-search { + margin-bottom: 2rem; +} + +.attendee-search form { + display: flex; + gap: 1rem; +} + +.attendee-search input { + flex: 1; + max-width: 400px; + padding: 0.75rem; + border: 1px solid var(--border-color); + border-radius: 4px; + font-size: 1rem; +} + +.search-hint { + color: var(--text-muted); + font-size: 0.85rem; + margin-top: 0.5rem; +} + +.search-too-broad { + color: var(--error-color, #e74c3c); + padding: 1rem; + background: #fdf2f2; + border-radius: 4px; +} + .attendee-card { display: flex; flex-direction: column; diff --git a/templates/attendee/attendees.html b/templates/attendee/attendees.html index f9c6ffe..e8f30ca 100644 --- a/templates/attendee/attendees.html +++ b/templates/attendee/attendees.html @@ -4,47 +4,75 @@ {% 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 %} + {% if event %} +
+

{{ event.name }}

+
+ {% if event.location %} + {{ event.location }} + {% endif %} + {% if event.start_date %} + {{ event.start_date|format_date }} + {% endif %}
- {% else %} -

{{ 'no_other_attendees'|t }}

+ {% if event.description %} +

{{ event.description }}

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

{{ 'connect_with_attendees'|t }}

+ + + + {% if search_query and search_query|length >= min_chars %} + {% if too_broad %} +

{{ 'search_too_broad'|t }}

+ {% elif 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 if att.organisation else '' }}

+

{{ att.role 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_attendees_found'|t }}

+ {% endif %} {% endif %}
{% endblock %} \ No newline at end of file diff --git a/templates/attendee/connection_requests.html b/templates/attendee/connection_requests.html index af0e72b..36a81e4 100644 --- a/templates/attendee/connection_requests.html +++ b/templates/attendee/connection_requests.html @@ -31,7 +31,7 @@

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

{{ req.email }}

-

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

+

{{ req.organisation if req.organisation else '' }}{% if req.role %} - {{ req.role }}{% endif %}

{% if req.introduction %}

"{{ req.introduction }}"

{% endif %} diff --git a/templates/attendee/dashboard.html b/templates/attendee/dashboard.html index 07a7753..3d3fcf3 100644 --- a/templates/attendee/dashboard.html +++ b/templates/attendee/dashboard.html @@ -54,8 +54,8 @@ {% for conn in connections %}

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

-

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

-

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

+

{{ conn.organisation if conn.organisation else '' }}

+

{{ conn.role if conn.role else '' }}

{% endfor %}
diff --git a/templates/attendee/profile.html b/templates/attendee/profile.html index 4a9a0a2..77357f7 100644 --- a/templates/attendee/profile.html +++ b/templates/attendee/profile.html @@ -102,6 +102,14 @@
+
+ + +
+
@@ -110,6 +118,53 @@