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:
@@ -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
|
||||||
@@ -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
|
|
||||||
```
|
|
||||||
@@ -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'))
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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 %}
|
||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 %}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 %}
|
||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
Reference in New Issue
Block a user