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 <noreply@anthropic.com>
This commit is contained in:
2026-05-19 19:52:42 +00:00
parent 335658f2bf
commit 934848b7f5
19 changed files with 758 additions and 124 deletions
+72
View File
@@ -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/<id>/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/<token>`
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
+167 -54
View File
@@ -3,8 +3,8 @@
## Project Overview ## Project Overview
- **Project Name**: NetEvent - **Project Name**: NetEvent
- **Type**: Full-stack web application (Flask + MySQL) - **Type**: Full-stack web application (Flask + MySQL)
- **Core Functionality**: A platform for organizing networking events where attendees can RSVP, connect, and schedule appointments - **Core Functionality**: A platform for organizing networking events where attendees can RSVP, connect, schedule appointments, and attend breakout sessions
- **Target Users**: Event organizers and event attendees - **Target Users**: Event organizers, event staff, and event attendees
## Technology Stack ## Technology Stack
- **Backend**: Python Flask - **Backend**: Python Flask
@@ -22,6 +22,10 @@
| email | VARCHAR(255) UNIQUE | Organizer email | | email | VARCHAR(255) UNIQUE | Organizer email |
| password_hash | VARCHAR(255) | Hashed password | | password_hash | VARCHAR(255) | Hashed password |
| name | VARCHAR(255) | Organizer name | | 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 | | created_at | TIMESTAMP | Creation timestamp |
#### `events` #### `events`
@@ -29,7 +33,7 @@
|--------|------|-------------| |--------|------|-------------|
| id | INT PRIMARY KEY AUTO_INCREMENT | Event ID | | id | INT PRIMARY KEY AUTO_INCREMENT | Event ID |
| organizer_id | INT FOREIGN KEY | Reference to organizers | | 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 | | name | VARCHAR(255) | Event name |
| description | TEXT | Event description | | description | TEXT | Event description |
| start_time | DATETIME | Event start date/time | | start_time | DATETIME | Event start date/time |
@@ -51,6 +55,14 @@
| role | VARCHAR(255) | Role/Profession | | role | VARCHAR(255) | Role/Profession |
| introduction | TEXT | Short introduction | | introduction | TEXT | Short introduction |
| profile_picture | VARCHAR(255) | Profile picture path | | 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 | | created_at | TIMESTAMP | Creation timestamp |
#### `connections` #### `connections`
@@ -75,68 +87,142 @@
| status | ENUM('pending','accepted','rejected') | Appointment status | | status | ENUM('pending','accepted','rejected') | Appointment status |
| created_at | TIMESTAMP | Creation timestamp | | 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 ## Functionality Specification
### Organiser Features ### Organizer Features
1. **Authentication**: Login/logout for organizers 1. **Authentication**: Login/logout for organizers
2. **Event Management**: Create, edit, delete events 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) 4. **Badge Printing**: Generate printable badge list (PDF-ready HTML)
5. **Attendance Stats**: See check-in counts and attendee statistics 5. **QR Code Scanning**: Mobile camera-based check-in by scanning attendee QR codes
6. **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 ### Attendee Features
1. **Authentication**: Register/login for attendees 1. **Authentication**: Register/login for attendees
2. **Event RSVP**: Register for events 2. **Event Registration**: RSVP for events with optional payment
3. **Profile Management**: Update profile with name, org, role, intro, photo 3. **Profile Management**: Update profile with name, org, role, intro, photo (with crop/zoom)
4. **Connections**: Send/accept/reject connection requests 4. **Connections**: Send/accept/reject connection requests
5. **Appointments**: Request/accept/reject meeting appointments 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 ### User Interactions & Flows
#### Organiser Flow #### Organizer Flow
1. Login → Dashboard → Create Event → View Attendees → Print Badges 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 #### Attendee Flow
1. Register → Login → RSVP to Event → Manage Profile → Connect with Attendees → Request Appointments 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 ### Auth
- `POST /api/auth/organizer/register` - Register organizer - `/auth/login` - Organizer/attendee login
- `POST /api/auth/organizer/login` - Login organizer - `/auth/forgot-password` - Request password reset
- `POST /api/auth/attendee/register` - Register attendee - `/auth/reset-password/<token>` - Reset password with token
- `POST /api/auth/attendee/login` - Login attendee
- `POST /api/auth/logout` - Logout
### Events ### Organizer
- `GET /api/events` - List public events - `/organizer/dashboard` - Event overview and management
- `POST /api/events` - Create event (organizer) - `/organizer/events/create` - Create new event
- `GET /api/events/<id>` - Get event details - `/organizer/events/<id>` - Event details and management
- `PUT /api/events/<id>` - Update event - `/organizer/events/<id>/edit` - Edit event
- `DELETE /api/events/<id>` - Delete event - `/organizer/events/<id>/attendees` - View attendee list
- `/organizer/events/<id>/badges` - Print badges
- `/organizer/events/<id>/scan` - QR scanner for check-in
- `/organizer/events/<id>/edit-attendee/<attendee_id>` - Edit attendee
- `/organizer/events/<id>/attendee-types` - Manage attendee types
- `/organizer/events/<id>/breakout-sessions` - Manage breakout sessions
- `/organizer/events/<id>/breakout-sessions/create` - Create breakout session
- `/organizer/events/<id>/breakout-sessions/<session_id>` - Edit breakout session
- `/organizer/events/<id>/staff` - Event staff management
### Attendees ### Attendee
- `GET /api/events/<id>/attendees` - List attendees for event - `/attendee/dashboard` - Event dashboard
- `GET /api/attendees/<id>` - Get attendee profile - `/attendee/profile` - Profile management with photo crop/zoom
- `PUT /api/attendees/<id>` - Update attendee profile - `/attendee/connections` - View accepted connections
- `POST /api/attendees/<id>/photo` - Upload profile photo - `/attendee/connection-requests` - Manage incoming connection requests
- `/attendee/appointments` - View appointments
### Connections - `/register` - Event registration
- `GET /api/connections` - List my connections - `/attendee/personal` - Personal information page
- `POST /api/connections` - Send connection request
- `PUT /api/connections/<id>` - Accept/reject connection
- `GET /api/attendees` - Search attendees
### Appointments
- `GET /api/appointments` - List my appointments
- `POST /api/appointments` - Request appointment
- `PUT /api/appointments/<id>` - Accept/reject appointment
### Organizer Tools
- `GET /api/organizer/events/<id>/badges` - Get badge printable view
- `GET /api/organizer/events/<id>/stats` - Get attendance stats
- `GET /api/organizer/events/<id>/scan` - QR code scanner page for check-in
## Security ## Security
- Password hashing with bcrypt - Password hashing with bcrypt
@@ -154,24 +240,51 @@
├── static/ ├── static/
│ ├── css/ │ ├── css/
│ │ └── style.css │ │ └── style.css
── js/ ── js/
└── main.js └── main.js
│ └── uploads/ # User uploaded files
└── templates/ └── templates/
├── base.html ├── base.html
├── index.html ├── index.html
├── register.html
├── attendees.html
├── auth/ ├── auth/
│ ├── login.html │ ├── login.html
── register.html ── forgot_password.html
│ └── reset_password.html
├── organizer/ ├── organizer/
│ ├── dashboard.html │ ├── dashboard.html
│ ├── create_event.html │ ├── create_event.html
│ ├── edit_event.html
│ ├── edit_event_confirm.html
│ ├── event_detail.html │ ├── event_detail.html
│ ├── event_staff.html
│ ├── badges.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 │ └── scan.html
└── attendee/ └── presenter/
── dashboard.html ── dashboard.html
├── event.html ```
├── profile.html
├── connections.html
└── appointments.html
```
+250 -17
View File
@@ -6,13 +6,15 @@ import smtplib
import string import string
import uuid import uuid
import requests import requests
from datetime import datetime from datetime import datetime, timedelta
from email.encoders import encode_base64 from email.encoders import encode_base64
from email.mime.multipart import MIMEMultipart from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText from email.mime.text import MIMEText
from email.mime.base import MIMEBase from email.mime.base import MIMEBase
from functools import wraps from functools import wraps
import secrets
import bcrypt import bcrypt
import mysql.connector import mysql.connector
import qrcode import qrcode
@@ -351,6 +353,12 @@ ENGLISH_TRANSLATIONS = {
'payment_secure': 'Your payment is secure and encrypted', 'payment_secure': 'Your payment is secure and encrypted',
'john_doe': 'John Doe', 'john_doe': 'John Doe',
'mm/yy': 'MM/YY', '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] 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(): def ensure_staff_code_column():
"""Ensure the staff_code column exists in organizers table.""" """Ensure the staff_code column exists in organizers table."""
try: try:
@@ -1483,6 +1568,115 @@ def login():
return render_template('auth/login.html', default_type=default_type) 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/<token>', 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') @app.route('/presenter/dashboard')
@login_required @login_required
def presenter_dashboard(): def presenter_dashboard():
@@ -1666,6 +1860,7 @@ def register_attendee(code):
organisation = request.form.get('organisation', '').strip() organisation = request.form.get('organisation', '').strip()
role = request.form.get('role', '').strip() role = request.form.get('role', '').strip()
introduction = request.form.get('introduction', '').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]): if not all([first_name, last_name, email, password]):
flash('First name, last name, email, and password are required.') flash('First name, last name, email, and password are required.')
@@ -1693,9 +1888,9 @@ def register_attendee(code):
password_hash = hash_password(password) password_hash = hash_password(password)
attendee_code = generate_attendee_code() attendee_code = generate_attendee_code()
cursor.execute(""" cursor.execute("""
INSERT INTO attendees (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) 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)) """, (event['id'], email, password_hash, first_name, last_name, organisation, role, introduction, attendee_code, visible_to_others))
conn.commit() conn.commit()
cursor.close() cursor.close()
conn.close() conn.close()
@@ -3234,12 +3429,13 @@ def edit_attendee(attendee_id):
role = request.form.get('role', '').strip() role = request.form.get('role', '').strip()
introduction = request.form.get('introduction', '').strip() introduction = request.form.get('introduction', '').strip()
attendee_type_id = request.form.get('attendee_type_id', '').strip() attendee_type_id = request.form.get('attendee_type_id', '').strip()
visible_to_others = request.form.get('visible_to_others') == '1'
cursor.execute(""" cursor.execute("""
UPDATE attendees 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 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() conn.commit()
cursor.close() cursor.close()
conn.close() conn.close()
@@ -3829,13 +4025,14 @@ def attendee_profile():
organisation = request.form.get('organisation', '').strip() organisation = request.form.get('organisation', '').strip()
role = request.form.get('role', '').strip() role = request.form.get('role', '').strip()
introduction = request.form.get('introduction', '').strip() introduction = request.form.get('introduction', '').strip()
visible_to_others = request.form.get('visible_to_others') == '1'
conn = get_db_connection() conn = get_db_connection()
cursor = conn.cursor() cursor = conn.cursor()
cursor.execute(""" 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 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() conn.commit()
cursor.close() cursor.close()
conn.close() conn.close()
@@ -3894,26 +4091,62 @@ def upload_photo():
@app.route('/attendees') @app.route('/attendees')
@login_required @login_required
def list_attendees(): 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': if session.get('user_type') != 'attendee':
flash('Access denied.') flash('Access denied.')
return redirect(url_for('index')) 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: try:
conn = get_db_connection() conn = get_db_connection()
cursor = conn.cursor(dictionary=True) cursor = conn.cursor(dictionary=True)
cursor.execute(""" # Get event details
SELECT a.*, cursor.execute("SELECT * FROM events WHERE id = %s", (session['event_id'],))
(SELECT status FROM connections WHERE attendee_id = %s AND connected_attendee_id = a.id) as my_status event = cursor.fetchone()
FROM attendees a
WHERE a.event_id = %s AND a.id != %s if len(search_query) >= min_chars:
""", (session['user_id'], session['event_id'], session['user_id'])) # Search in first_name, last_name, and organisation
attendees = cursor.fetchall() 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() cursor.close()
conn.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: except Error as e:
flash(f'Database error: {e}') flash(f'Database error: {e}')
return redirect(url_for('attendee_dashboard')) return redirect(url_for('attendee_dashboard'))
+2
View File
@@ -207,6 +207,8 @@ def create_tables():
"ALTER TABLE attendees ADD COLUMN phone VARCHAR(50) DEFAULT ''", "ALTER TABLE attendees ADD COLUMN phone VARCHAR(50) DEFAULT ''",
"ALTER TABLE attendees ADD COLUMN linkedin VARCHAR(255) DEFAULT ''", "ALTER TABLE attendees ADD COLUMN linkedin VARCHAR(255) DEFAULT ''",
"ALTER TABLE attendees ADD COLUMN attendee_type_id INT DEFAULT NULL", "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: try:
+68 -1
View File
@@ -463,6 +463,15 @@ main {
box-shadow: 0 1px 3px rgba(0,0,0,0.1); 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 { .event-actions {
display: flex; display: flex;
gap: 0.5rem; gap: 0.5rem;
@@ -592,15 +601,73 @@ main {
} }
/* Attendees Page */ /* Attendees Page */
.attendees-page {
padding: 0 var(--page-padding, 2rem);
}
.attendees-page h1 { .attendees-page h1 {
margin-bottom: 0.5rem; 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); color: var(--text-muted);
margin-bottom: 2rem; 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 { .attendee-card {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
+68 -40
View File
@@ -4,47 +4,75 @@
{% block content %} {% block content %}
<div class="attendees-page"> <div class="attendees-page">
<h1>{{ 'event_attendees'|t }}</h1> {% if event %}
<p>{{ 'connect_with_attendees'|t }}</p> <div class="event-info-header">
<h1>{{ event.name }}</h1>
{% if attendees %} <div class="event-meta">
<div class="attendees-grid"> {% if event.location %}
{% for att in attendees %} <span class="event-location">{{ event.location }}</span>
<div class="attendee-card"> {% endif %}
<div class="attendee-photo"> {% if event.start_date %}
{% if att.profile_picture %} <span class="event-date">{{ event.start_date|format_date }}</span>
<img src="{{ url_for('static', filename='uploads/' + att.profile_picture) }}" alt="Photo"> {% endif %}
{% else %}
<div class="no-photo">{{ att.first_name[0] }}{{ att.last_name[0] }}</div>
{% endif %}
</div>
<div class="attendee-info">
<h3>{{ att.first_name }} {{ att.last_name }}</h3>
<p class="attendee-org">{{ (att.organisation)|spacify if att.organisation else '' }}</p>
<p class="attendee-role">{{ (att.role)|spacify if att.role else '' }}</p>
{% if att.introduction %}
<p class="attendee-intro">{{ att.introduction[:100] }}{% if att.introduction|length > 100 %}...{% endif %}</p>
{% endif %}
</div>
<div class="attendee-actions">
{% if att.my_status == 'accepted' %}
<span class="badge badge-success">{{ 'connected'|t }}</span>
{% elif att.my_status == 'pending' %}
<span class="badge badge-pending">{{ 'request_pending'|t }}</span>
{% elif att.my_status == 'rejected' %}
<span class="badge badge-rejected">{{ 'rejected'|t }}</span>
{% else %}
<form method="POST" action="{{ url_for('create_connection') }}" class="connect-form">
<input type="hidden" name="connected_attendee_id" value="{{ att.id }}">
<button type="submit" class="btn btn-sm btn-primary">{{ 'connect'|t }}</button>
</form>
{% endif %}
</div>
</div>
{% endfor %}
</div> </div>
{% else %} {% if event.description %}
<p class="no-attendees">{{ 'no_other_attendees'|t }}</p> <p class="event-description">{{ event.description }}</p>
{% endif %}
</div>
{% endif %}
<p class="page-subtitle">{{ 'connect_with_attendees'|t }}</p>
<div class="attendee-search">
<form method="GET" action="{{ url_for('list_attendees') }}">
<input type="text" name="q" value="{{ search_query }}" placeholder="{{ 'search_attendees'|t }}">
<button type="submit" class="btn btn-primary">{{ 'search'|t }}</button>
</form>
<p class="search-hint">{{ 'search_min_chars'|t|format(min_chars) }}</p>
</div>
{% if search_query and search_query|length >= min_chars %}
{% if too_broad %}
<p class="search-too-broad">{{ 'search_too_broad'|t }}</p>
{% elif attendees %}
<div class="attendees-grid">
{% for att in attendees %}
<div class="attendee-card">
<div class="attendee-photo">
{% if att.profile_picture %}
<img src="{{ url_for('static', filename='uploads/' + att.profile_picture) }}" alt="Photo">
{% else %}
<div class="no-photo">{{ att.first_name[0] }}{{ att.last_name[0] }}</div>
{% endif %}
</div>
<div class="attendee-info">
<h3>{{ att.first_name }} {{ att.last_name }}</h3>
<p class="attendee-org">{{ att.organisation if att.organisation else '' }}</p>
<p class="attendee-role">{{ att.role if att.role else '' }}</p>
{% if att.introduction %}
<p class="attendee-intro">{{ att.introduction[:100] }}{% if att.introduction|length > 100 %}...{% endif %}</p>
{% endif %}
</div>
<div class="attendee-actions">
{% if att.my_status == 'accepted' %}
<span class="badge badge-success">{{ 'connected'|t }}</span>
{% elif att.my_status == 'pending' %}
<span class="badge badge-pending">{{ 'request_pending'|t }}</span>
{% elif att.my_status == 'rejected' %}
<span class="badge badge-rejected">{{ 'rejected'|t }}</span>
{% else %}
<form method="POST" action="{{ url_for('create_connection') }}" class="connect-form">
<input type="hidden" name="connected_attendee_id" value="{{ att.id }}">
<button type="submit" class="btn btn-sm btn-primary">{{ 'connect'|t }}</button>
</form>
{% endif %}
</div>
</div>
{% endfor %}
</div>
{% else %}
<p class="no-attendees">{{ 'no_attendees_found'|t }}</p>
{% endif %}
{% endif %} {% endif %}
</div> </div>
{% endblock %} {% endblock %}
+1 -1
View File
@@ -31,7 +31,7 @@
<div class="request-info"> <div class="request-info">
<h3>{{ req.first_name }} {{ req.last_name }}</h3> <h3>{{ req.first_name }} {{ req.last_name }}</h3>
<p class="request-email">{{ req.email }}</p> <p class="request-email">{{ req.email }}</p>
<p class="request-org">{{ (req.organisation)|spacify if req.organisation else '' }}{% if req.role %} - {{ (req.role)|spacify }}{% endif %}</p> <p class="request-org">{{ req.organisation if req.organisation else '' }}{% if req.role %} - {{ req.role }}{% endif %}</p>
{% if req.introduction %} {% if req.introduction %}
<p class="request-intro">"{{ req.introduction }}"</p> <p class="request-intro">"{{ req.introduction }}"</p>
{% endif %} {% endif %}
+2 -2
View File
@@ -54,8 +54,8 @@
{% for conn in connections %} {% for conn in connections %}
<div class="connection-card"> <div class="connection-card">
<h4 style="margin-bottom: 12px;">{{ conn.first_name }} {{ conn.last_name }}</h4> <h4 style="margin-bottom: 12px;">{{ conn.first_name }} {{ conn.last_name }}</h4>
<p class="connection-org" style="margin-bottom: 12px;">{{ (conn.organisation)|spacify if conn.organisation else '' }}</p> <p class="connection-org" style="margin-bottom: 12px;">{{ conn.organisation if conn.organisation else '' }}</p>
<p class="connection-role">{{ (conn.role)|spacify if conn.role else '' }}</p> <p class="connection-role">{{ conn.role if conn.role else '' }}</p>
</div> </div>
{% endfor %} {% endfor %}
</div> </div>
+55
View File
@@ -102,6 +102,14 @@
<textarea id="introduction" name="introduction" rows="4">{{ attendee.introduction or '' }}</textarea> <textarea id="introduction" name="introduction" rows="4">{{ attendee.introduction or '' }}</textarea>
</div> </div>
<div class="form-group switch-group">
<label for="visible_to_others">{{ 'visible_to_attendees'|t }}</label>
<label class="switch">
<input type="checkbox" id="visible_to_others" name="visible_to_others" value="1" {% if attendee.visible_to_others %}checked{% endif %}>
<span class="slider"></span>
</label>
</div>
<div class="form-actions"> <div class="form-actions">
<button type="submit" class="btn btn-primary">{{ 'update_profile'|t }}</button> <button type="submit" class="btn btn-primary">{{ 'update_profile'|t }}</button>
</div> </div>
@@ -110,6 +118,53 @@
</div> </div>
<style> <style>
.switch-group {
display: flex;
align-items: center;
gap: 1rem;
}
.switch-group label {
margin-bottom: 0;
}
.switch {
position: relative;
display: inline-block;
width: 50px;
height: 26px;
}
.switch input {
opacity: 0;
width: 0;
height: 0;
}
.slider {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: #ccc;
transition: 0.3s;
border-radius: 26px;
}
.slider:before {
position: absolute;
content: "";
height: 20px;
width: 20px;
left: 3px;
bottom: 3px;
background-color: white;
transition: 0.3s;
border-radius: 50%;
}
input:checked + .slider {
background-color: #2ecc71;
}
input:checked + .slider:before {
transform: translateX(24px);
}
.current-photo { .current-photo {
width: 150px; width: 150px;
height: 150px; height: 150px;
+26
View File
@@ -0,0 +1,26 @@
{% extends "base.html" %}
{% block title %}{{ 'forgot_password'|t }} - NetEvents{% endblock %}
{% block content %}
<div class="auth-container">
<div class="auth-box">
<h2>{{ 'forgot_password'|t }}</h2>
<p class="auth-subtitle">{{ 'forgot_password_desc'|t }}</p>
<form method="POST" action="{{ url_for('forgot_password') }}">
<div class="form-group">
<label for="email">{{ 'email'|t }}</label>
<input type="email" id="email" name="email" required>
</div>
<button type="submit" class="btn btn-primary btn-block">{{ 'send_reset_link'|t }}</button>
</form>
<p class="auth-footer">
{{ 'remember_password'|t }}
<a href="{{ url_for('login') }}">{{ 'login'|t }}</a>
</p>
</div>
</div>
{% endblock %}
+4
View File
@@ -28,6 +28,10 @@
<a href="{{ url_for('register_organizer') }}">{{ 'register_as_organizer'|t }}</a> <a href="{{ url_for('register_organizer') }}">{{ 'register_as_organizer'|t }}</a>
</p> </p>
<p class="forgot-password-link">
<a href="{{ url_for('forgot_password') }}">{{ 'forgot_password'|t }}</a>
</p>
<div class="attendee-link-box"> <div class="attendee-link-box">
<p>{{ 'attendee_profile_link'|t }}</p> <p>{{ 'attendee_profile_link'|t }}</p>
<p><a href="{{ url_for('request_attendee_link') }}"><strong>{{ 'click_here'|t }}</strong></a></p> <p><a href="{{ url_for('request_attendee_link') }}"><strong>{{ 'click_here'|t }}</strong></a></p>
+24
View File
@@ -0,0 +1,24 @@
{% extends "base.html" %}
{% block title %}{{ 'reset_password'|t }} - NetEvents{% endblock %}
{% block content %}
<div class="auth-container">
<div class="auth-box">
<h2>{{ 'set_new_password'|t }}</h2>
<form method="POST" action="{{ url_for('reset_password', token=token) }}">
<div class="form-group">
<label for="password">{{ 'new_password'|t }}</label>
<input type="password" id="password" name="password" required minlength="6">
</div>
<div class="form-group">
<label for="confirm_password">{{ 'confirm_password'|t }}</label>
<input type="password" id="confirm_password" name="confirm_password" required minlength="6">
</div>
<button type="submit" class="btn btn-primary btn-block">{{ 'reset_password'|t }}</button>
</form>
</div>
</div>
{% endblock %}
+2 -2
View File
@@ -28,8 +28,8 @@
<img src="{{ attendee.qr_code }}" alt="QR Code" width="100" height="100"> <img src="{{ attendee.qr_code }}" alt="QR Code" width="100" height="100">
</div> </div>
<div class="badge-body"> <div class="badge-body">
<p class="badge-org">{{ (attendee.organisation)|spacify if attendee.organisation else '' }}</p> <p class="badge-org">{{ attendee.organisation if attendee.organisation else '' }}</p>
<p class="badge-role">{{ (attendee.role)|spacify if attendee.role else '' }}</p> <p class="badge-role">{{ attendee.role if attendee.role else '' }}</p>
{% if attendee.introduction %} {% if attendee.introduction %}
<p class="badge-intro">{{ attendee.introduction[:80] }}{% if attendee.introduction|length > 80 %}...{% endif %}</p> <p class="badge-intro">{{ attendee.introduction[:80] }}{% if attendee.introduction|length > 80 %}...{% endif %}</p>
{% endif %} {% endif %}
@@ -45,8 +45,8 @@
<tr> <tr>
<td>{{ rsvp.first_name }} {{ rsvp.last_name }}</td> <td>{{ rsvp.first_name }} {{ rsvp.last_name }}</td>
<td>{{ rsvp.email }}</td> <td>{{ rsvp.email }}</td>
<td>{{ (rsvp.organisation)|spacify if rsvp.organisation else '-' }}</td> <td>{{ rsvp.organisation if rsvp.organisation else '-' }}</td>
<td>{{ (rsvp.role)|spacify if rsvp.role else '-' }}</td> <td>{{ rsvp.role if rsvp.role else '-' }}</td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
@@ -3,7 +3,7 @@
{% block title %}{{ 'create_breakout_session'|t }} - NetEvents{% endblock %} {% block title %}{{ 'create_breakout_session'|t }} - NetEvents{% endblock %}
{% block content %} {% block content %}
<div class="form-container" style="padding: 20px;"> <div class="form-container">
<h1>{{ 'create_breakout_session'|t }}</h1> <h1>{{ 'create_breakout_session'|t }}</h1>
<p class="event-info">{{ 'event'|t }}: {{ event.name }}</p> <p class="event-info">{{ 'event'|t }}: {{ event.name }}</p>
+5
View File
@@ -47,6 +47,11 @@
</select> </select>
</div> </div>
<div class="form-group checkbox-group">
<input type="checkbox" id="visible_to_others" name="visible_to_others" value="1" {% if attendee.visible_to_others %}checked{% endif %}>
<label for="visible_to_others">{{ 'visible_to_attendees'|t }}</label>
</div>
<div class="form-actions"> <div class="form-actions">
<button type="submit" class="btn btn-primary">{{ 'save_changes'|t }}</button> <button type="submit" class="btn btn-primary">{{ 'save_changes'|t }}</button>
<a href="{{ url_for('organizer_dashboard') }}" class="btn btn-outline">{{ 'cancel'|t }}</a> <a href="{{ url_for('organizer_dashboard') }}" class="btn btn-outline">{{ 'cancel'|t }}</a>
@@ -3,7 +3,7 @@
{% block title %}{{ 'edit_breakout_session'|t }} - NetEvents{% endblock %} {% block title %}{{ 'edit_breakout_session'|t }} - NetEvents{% endblock %}
{% block content %} {% block content %}
<div class="form-container" style="padding: 20px;"> <div class="form-container">
<h1>{{ 'edit_breakout_session'|t }}</h1> <h1>{{ 'edit_breakout_session'|t }}</h1>
<p class="event-info">{{ 'event'|t }}: {{ event.name }}</p> <p class="event-info">{{ 'event'|t }}: {{ event.name }}</p>
+3 -3
View File
@@ -372,7 +372,7 @@ th.sort-desc .sort-icon::before {
<section class="attendees-section"> <section class="attendees-section">
<div class="section-box"> <div class="section-box">
<h2>{{ 'registered_attendees'|t }}</h2> <h2>{{ 'registered_attendees'|t }} ({{ attendees|length }})</h2>
<div class="section-actions"> <div class="section-actions">
<a href="{{ url_for('register_attendee', code=event.code) }}" class="btn btn-primary">{{ 'add_attendee'|t }}</a> <a href="{{ url_for('register_attendee', code=event.code) }}" class="btn btn-primary">{{ 'add_attendee'|t }}</a>
<a href="{{ url_for('event_badges', event_id=event.id) }}" class="btn btn-secondary">🖨️ {{ 'print_badges'|t }}</a> <a href="{{ url_for('event_badges', event_id=event.id) }}" class="btn btn-secondary">🖨️ {{ 'print_badges'|t }}</a>
@@ -416,8 +416,8 @@ th.sort-desc .sort-icon::before {
<input type="checkbox" name="attendee_ids" value="{{ attendee.id }}" class="attendee-checkbox" form="batch-assign-form"> <input type="checkbox" name="attendee_ids" value="{{ attendee.id }}" class="attendee-checkbox" form="batch-assign-form">
</td> </td>
<td>{{ attendee.first_name }} {{ attendee.last_name }}</td> <td>{{ attendee.first_name }} {{ attendee.last_name }}</td>
<td>{{ (attendee.organisation)|spacify if attendee.organisation else '-' }}</td> <td>{{ attendee.organisation if attendee.organisation else '-' }}</td>
<td>{{ (attendee.role)|spacify if attendee.role else '-' }}</td> <td>{{ attendee.role if attendee.role else '-' }}</td>
<td> <td>
{% if attendee.attendee_type_name %} {% if attendee.attendee_type_name %}
<span class="badge" style="background: #3498db; color: white;">{{ attendee.attendee_type_name }}</span> <span class="badge" style="background: #3498db; color: white;">{{ attendee.attendee_type_name }}</span>
+5
View File
@@ -63,6 +63,11 @@
<textarea id="introduction" name="introduction" rows="3"></textarea> <textarea id="introduction" name="introduction" rows="3"></textarea>
</div> </div>
<div class="form-group checkbox-group">
<input type="checkbox" id="visible_to_others" name="visible_to_others" value="1" checked>
<label for="visible_to_others">{{ 'visible_to_attendees'|t }}</label>
</div>
<div class="form-group"> <div class="form-group">
<label for="password">{{ 'password'|t }}</label> <label for="password">{{ 'password'|t }}</label>
<input type="password" id="password" name="password" required minlength="6"> <input type="password" id="password" name="password" required minlength="6">