Add attendee type management and staff codes

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-25 07:17:47 +00:00
parent dec6446d7d
commit 64ab1d0412
16 changed files with 844 additions and 78 deletions
+612 -17
View File
@@ -23,6 +23,8 @@ from flask_babel import Babel, gettext as _, ngettext, force_locale
from reportlab.lib.pagesizes import A4 from reportlab.lib.pagesizes import A4
from reportlab.lib.units import mm from reportlab.lib.units import mm
from reportlab.pdfgen import canvas from reportlab.pdfgen import canvas
import openpyxl
from openpyxl.styles import Font, PatternFill, Alignment
from mysql.connector import Error from mysql.connector import Error
from config import Config from config import Config
@@ -116,6 +118,7 @@ ENGLISH_TRANSLATIONS = {
'dashboard': 'Dashboard', 'dashboard': 'Dashboard',
'logout': 'Logout', 'logout': 'Logout',
'login': 'Login', 'login': 'Login',
'organizer_login': 'Organizer Login',
'register': 'Register', 'register': 'Register',
'events': 'Events', 'events': 'Events',
'attendees': 'Attendees', 'attendees': 'Attendees',
@@ -296,6 +299,12 @@ ENGLISH_TRANSLATIONS = {
'copy': 'Copy', 'copy': 'Copy',
'delete': 'Delete', 'delete': 'Delete',
'confirm_delete_type': 'Are you sure you want to delete this attendee type?', 'confirm_delete_type': 'Are you sure you want to delete this attendee type?',
'edit_attendee_type': 'Edit Attendee Type',
'back_to_types': 'Back to Types',
'type_details': 'Type Details',
'save_changes': 'Save Changes',
'edit': 'Edit',
'cannot_delete_type_with_attendees': 'Cannot delete: %d attendees assigned to this type',
'no_attendee_types_yet': 'No attendee types defined yet.', 'no_attendee_types_yet': 'No attendee types defined yet.',
'create_first_type': 'Create the first type', 'create_first_type': 'Create the first type',
'back_to_event': 'Back to Event', 'back_to_event': 'Back to Event',
@@ -583,6 +592,22 @@ def generate_attendee_code():
conn.close() conn.close()
def generate_staff_code():
"""Generate a unique 10-character alphanumeric staff code."""
chars = string.ascii_uppercase + string.digits
while True:
code = ''.join(random.choices(chars, k=10))
conn = get_db_connection()
cursor = conn.cursor()
cursor.execute("SELECT id FROM organizers WHERE staff_code = %s", (code,))
if not cursor.fetchone():
cursor.close()
conn.close()
return code
cursor.close()
conn.close()
def generate_type_code(): def generate_type_code():
"""Generate a unique 10-character alphanumeric attendee type code.""" """Generate a unique 10-character alphanumeric attendee type code."""
chars = string.ascii_uppercase + string.digits chars = string.ascii_uppercase + string.digits
@@ -648,6 +673,11 @@ def verify_recaptcha(recaptcha_response):
# Rate limiting for failed login attempts # Rate limiting for failed login attempts
failed_login_attempts = {} # {ip_address: [(timestamp, count), ...]} failed_login_attempts = {} # {ip_address: [(timestamp, count), ...]}
# Rate limiting for failed personal page attempts (30-minute block)
failed_personal_page_attempts = {} # {ip_address: [(timestamp, count), ...]}
PERSONAL_PAGE_BLOCK_DURATION = 30 * 60 # 30 minutes in seconds
PERSONAL_PAGE_MAX_ATTEMPTS = 5 # Max failed attempts before blocking
def is_ip_blocked(ip_address): def is_ip_blocked(ip_address):
"""Check if an IP address is blocked due to too many failed login attempts.""" """Check if an IP address is blocked due to too many failed login attempts."""
if ip_address not in failed_login_attempts: if ip_address not in failed_login_attempts:
@@ -671,6 +701,43 @@ def is_ip_blocked(ip_address):
return False return False
def is_ip_blocked_for_personal_page(ip_address):
"""Check if an IP address is blocked due to too many failed personal page attempts."""
if ip_address not in failed_personal_page_attempts:
return False
# Clean up old entries (older than 30 minutes)
current_time = datetime.now()
failed_personal_page_attempts[ip_address] = [
(ts, count) for ts, count in failed_personal_page_attempts[ip_address]
if (current_time - ts).total_seconds() < PERSONAL_PAGE_BLOCK_DURATION
]
# Check if still blocked
if len(failed_personal_page_attempts[ip_address]) >= PERSONAL_PAGE_MAX_ATTEMPTS:
return True
# Remove empty entries
if not failed_personal_page_attempts[ip_address]:
del failed_personal_page_attempts[ip_address]
return False
def record_failed_personal_page_attempt(ip_address):
"""Record a failed personal page attempt for an IP address."""
if ip_address not in failed_personal_page_attempts:
failed_personal_page_attempts[ip_address] = []
failed_personal_page_attempts[ip_address].append((datetime.now(), 1))
def clear_failed_personal_page_attempts(ip_address):
"""Clear failed personal page attempts after successful access."""
if ip_address in failed_personal_page_attempts:
del failed_personal_page_attempts[ip_address]
def record_failed_login(ip_address): def record_failed_login(ip_address):
"""Record a failed login attempt for an IP address.""" """Record a failed login attempt for an IP address."""
if ip_address not in failed_login_attempts: if ip_address not in failed_login_attempts:
@@ -685,6 +752,82 @@ def clear_failed_logins(ip_address):
del failed_login_attempts[ip_address] del failed_login_attempts[ip_address]
def ensure_staff_code_column():
"""Ensure the staff_code column exists in organizers table."""
try:
conn = get_db_connection()
cursor = conn.cursor()
cursor.execute("""
SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = %s AND TABLE_NAME = 'organizers' AND COLUMN_NAME = 'staff_code'
""", (Config.DB_NAME,))
if not cursor.fetchone():
cursor.execute("ALTER TABLE organizers ADD COLUMN staff_code VARCHAR(10) UNIQUE DEFAULT NULL")
conn.commit()
print("Added staff_code column to organizers table")
cursor.close()
conn.close()
except Error as e:
print(f"Error ensuring staff_code column: {e}")
def ensure_all_organizers_have_staff_code():
"""Ensure all existing organizers have a staff_code."""
try:
conn = get_db_connection()
cursor = conn.cursor(dictionary=True)
cursor.execute("SELECT id FROM organizers WHERE staff_code IS NULL")
organizers_without_code = cursor.fetchall()
for org in organizers_without_code:
staff_code = generate_staff_code()
cursor.execute("UPDATE organizers SET staff_code = %s WHERE id = %s", (staff_code, org['id']))
if organizers_without_code:
conn.commit()
print(f"Generated staff_codes for {len(organizers_without_code)} organizers")
cursor.close()
conn.close()
except Error as e:
print(f"Error generating staff codes: {e}")
def ensure_staff_staff_code_column():
"""Ensure the staff_code column exists in staff table."""
try:
conn = get_db_connection()
cursor = conn.cursor()
cursor.execute("""
SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = %s AND TABLE_NAME = 'staff' AND COLUMN_NAME = 'staff_code'
""", (Config.DB_NAME,))
if not cursor.fetchone():
cursor.execute("ALTER TABLE staff ADD COLUMN staff_code VARCHAR(10) UNIQUE DEFAULT NULL")
conn.commit()
print("Added staff_code column to staff table")
cursor.close()
conn.close()
except Error as e:
print(f"Error ensuring staff staff_code column: {e}")
def ensure_all_staff_have_staff_code():
"""Ensure all existing staff members have a staff_code."""
try:
conn = get_db_connection()
cursor = conn.cursor(dictionary=True)
cursor.execute("SELECT id FROM staff WHERE staff_code IS NULL")
staff_without_code = cursor.fetchall()
for s in staff_without_code:
staff_code = generate_staff_code()
cursor.execute("UPDATE staff SET staff_code = %s WHERE id = %s", (staff_code, s['id']))
if staff_without_code:
conn.commit()
print(f"Generated staff_codes for {len(staff_without_code)} staff members")
cursor.close()
conn.close()
except Error as e:
print(f"Error generating staff codes: {e}")
def allowed_file(filename): def allowed_file(filename):
"""Check if file extension is allowed.""" """Check if file extension is allowed."""
return '.' in filename and filename.rsplit('.', 1)[1].lower() in Config.ALLOWED_EXTENSIONS return '.' in filename and filename.rsplit('.', 1)[1].lower() in Config.ALLOWED_EXTENSIONS
@@ -1001,6 +1144,157 @@ def generate_badge_pdf(attendee, event=None):
return buffer return buffer
def generate_rectangular_badges_pdf(attendees, event=None, attendee_types_map=None):
"""Generate an A4 PDF with rectangular labels.
Label dimensions: 80mm x 50mm
Layout: 2 columns x 5 rows = 10 badges per A4 page.
Each badge contains: attendee type (black bar), name, organization, role, and QR code.
"""
buffer = io.BytesIO()
c = canvas.Canvas(buffer, pagesize=A4)
width, height = A4 # 595.28 x 841.89 points
# Label dimensions: 80mm x 50mm
label_width_mm = 80
label_height_mm = 50
label_width = label_width_mm * mm
label_height = label_height_mm * mm
badges_per_row = 2
badges_per_col = 5
badges_per_page = badges_per_row * badges_per_col
# Page margins
margin_left_mm = 15
margin_right_mm = 15
margin_top_mm = 12
margin_bottom_mm = 12
# Calculate spacing
available_width = width - (margin_left_mm * mm) - (margin_right_mm * mm)
available_height = height - (margin_top_mm * mm) - (margin_bottom_mm * mm)
# Gap between labels
gap_x = (available_width - badges_per_row * label_width) / (badges_per_row + 1)
gap_y = (available_height - badges_per_col * label_height) / (badges_per_col + 1)
# Badge positions (starting from top-left)
badge_positions = []
for row in range(badges_per_col):
for col in range(badges_per_row):
x = margin_left_mm * mm + gap_x + col * (label_width + gap_x)
y = height - margin_top_mm * mm - gap_y - row * (label_height + gap_y) - label_height
badge_positions.append((x, y))
# Draw badges for each attendee
for i, attendee in enumerate(attendees):
badge_index = i % badges_per_page
# New page if needed
if badge_index == 0 and i > 0:
c.showPage()
if badge_index < len(badge_positions):
x, y = badge_positions[badge_index]
# Draw label border (light gray)
c.setStrokeColorRGB(0.7, 0.7, 0.7)
c.setLineWidth(0.5)
c.rect(x, y, label_width, label_height, fill=False, stroke=True)
# Get attendee type name from mapping
attendee_type_id = attendee.get('attendee_type_id')
attendee_type_name = ''
if attendee_type_id and attendee_types_map:
type_info = attendee_types_map.get(attendee_type_id, {})
attendee_type_name = type_info.get('name', '') or ''
# Draw black bar at top with attendee type in white
if attendee_type_name:
bar_height = 8 * mm
c.setFillColorRGB(0, 0, 0)
c.rect(x, y + label_height - bar_height, label_width, bar_height, fill=True, stroke=False)
c.setFillColorRGB(1, 1, 1)
c.setFont("Helvetica-Bold", 16)
c.drawCentredString(x + label_width / 2, y + label_height - bar_height + 1.5 * mm, attendee_type_name[:20])
# Draw attendee name (20pt)
first_name = attendee.get('first_name', '') or ''
last_name = attendee.get('last_name', '') or ''
full_name = f"{first_name} {last_name}".strip()
font_size = 20
max_name_width = label_width - 2 * mm
name_width = c.stringWidth(full_name, "Helvetica-Bold", font_size)
if name_width > max_name_width:
# Scale down to fit
font_size = int(font_size * max_name_width / name_width)
c.setFont("Helvetica-Bold", font_size)
c.setFillColorRGB(0, 0, 0)
name_y = y + label_height - 22 * mm
c.drawCentredString(x + label_width / 2, name_y, full_name)
# Draw organization
org = attendee.get('organisation', '') or ''
c.setFont("Helvetica", 14)
org_y = name_y - 8 * mm
if org:
c.drawCentredString(x + label_width / 2, org_y, org[:22])
# Draw role
role = attendee.get('role', '') or ''
c.setFont("Helvetica", 14)
if role:
role_y = org_y - 7 * mm
c.drawCentredString(x + label_width / 2, role_y, role[:22])
# Generate QR code at bottom-right
attendee_code = attendee.get('attendee_code', '')
if attendee_code:
qr_size = 12 * mm
qr_x = x + label_width - qr_size - 2 * mm
qr_y = y + 10 * mm
qr = qrcode.QRCode(version=1, box_size=2, border=0)
qr.add_data(attendee_code)
qr.make(fit=True)
qr_img = qr.make_image(fill_color="black", back_color="white")
qr_buffer = io.BytesIO()
qr_img.save(qr_buffer, format='PNG')
qr_buffer.seek(0)
from reportlab.lib.utils import ImageReader
qr_reader = ImageReader(qr_buffer)
c.drawImage(qr_reader, qr_x, qr_y, width=qr_size, height=qr_size)
# Draw black bar at bottom with event name and start date
bar_height = 8 * mm
c.setFillColorRGB(0, 0, 0)
c.rect(x, y, label_width, bar_height, fill=True, stroke=False)
c.setFillColorRGB(1, 1, 1)
c.setFont("Helvetica-Bold", 14)
event_name = (event.get('name', '') or '') if event else ''
start_time = event.get('start_time', '') if event else ''
event_date_str = ''
if start_time:
if hasattr(start_time, 'strftime'):
event_date_str = start_time.strftime('%d-%m-%Y')
else:
event_date_str = str(start_time)[:10]
bar_text = f"{event_name}{event_date_str}" if event_date_str else event_name
c.drawCentredString(x + label_width / 2, y + bar_height / 2 - 1.5 * mm, bar_text)
c.save()
buffer.seek(0)
return buffer
def send_badge_email(attendee_email, attendee_name, event_name, pdf_buffer): def send_badge_email(attendee_email, attendee_name, event_name, pdf_buffer):
"""Send email with badge PDF attachment.""" """Send email with badge PDF attachment."""
subject = f"Your Badge for {event_name}" subject = f"Your Badge for {event_name}"
@@ -1052,6 +1346,65 @@ def index():
return render_template('index.html') return render_template('index.html')
@app.route('/staff/<staff_code>')
def staff_login(staff_code):
"""Staff/organizer login via staff_code (no password required)."""
client_ip = request.remote_addr
# Check if IP is blocked (reuse login rate limiting)
if is_ip_blocked(client_ip):
flash('Too many failed login attempts. Please try again later (blocked for 1 hour).')
return redirect(url_for('index'))
try:
conn = get_db_connection()
cursor = conn.cursor(dictionary=True)
# First check if it's an organizer
cursor.execute("SELECT * FROM organizers WHERE staff_code = %s", (staff_code,))
organizer = cursor.fetchone()
if organizer:
cursor.close()
conn.close()
clear_failed_logins(client_ip)
# Log in the organizer
session['user_id'] = organizer['id']
session['user_type'] = 'organizer'
session['event_id'] = None
flash(f'Welcome back, {organizer["name"]}!')
return redirect(url_for('organizer_dashboard'))
# Check if it's a staff member
cursor.execute("SELECT * FROM staff WHERE staff_code = %s", (staff_code,))
staff_member = cursor.fetchone()
cursor.close()
conn.close()
if not staff_member:
record_failed_login(client_ip)
flash('Invalid staff link.')
return redirect(url_for('index'))
# Clear failed login attempts on successful staff code login
clear_failed_logins(client_ip)
# Log in the staff member
session['user_id'] = staff_member['id']
session['user_type'] = 'staff'
session['event_id'] = staff_member['event_id']
flash(f'Welcome back, {staff_member["first_name"]}!')
return redirect(url_for('staff_dashboard'))
except Error as e:
flash(f'Database error: {e}')
return redirect(url_for('index'))
# Routes - Auth # Routes - Auth
@app.route('/login', methods=['GET', 'POST']) @app.route('/login', methods=['GET', 'POST'])
def login(): def login():
@@ -1255,9 +1608,10 @@ def register_organizer():
# Create organizer # Create organizer
password_hash = hash_password(password) password_hash = hash_password(password)
staff_code = generate_staff_code()
cursor.execute( cursor.execute(
"INSERT INTO organizers (email, password_hash, name) VALUES (%s, %s, %s)", "INSERT INTO organizers (email, password_hash, name, staff_code) VALUES (%s, %s, %s, %s)",
(email, password_hash, name) (email, password_hash, name, staff_code)
) )
conn.commit() conn.commit()
cursor.close() cursor.close()
@@ -1670,6 +2024,17 @@ def create_breakout_session(event_id):
flash(f'Database error: {e}') flash(f'Database error: {e}')
return redirect(url_for('organizer_dashboard')) return redirect(url_for('organizer_dashboard'))
# Check if event is single-day
is_single_day = False
prefilled_date = ''
if event.get('start_time') and event.get('end_time'):
start = event['start_time']
end = event['end_time']
if hasattr(start, 'date') and hasattr(end, 'date'):
is_single_day = start.date() == end.date()
if is_single_day:
prefilled_date = start.strftime('%Y-%m-%d')
if request.method == 'POST': if request.method == 'POST':
name = request.form.get('name', '').strip() name = request.form.get('name', '').strip()
description = request.form.get('description', '').strip() description = request.form.get('description', '').strip()
@@ -1722,7 +2087,7 @@ def create_breakout_session(event_id):
except Error as e: except Error as e:
flash(f'Database error: {e}') flash(f'Database error: {e}')
return render_template('organizer/create_breakout_session.html', event=event) return render_template('organizer/create_breakout_session.html', event=event, prefilled_date=prefilled_date)
@app.route('/organizer/breakout-session/<int:session_id>/edit', methods=['GET', 'POST']) @app.route('/organizer/breakout-session/<int:session_id>/edit', methods=['GET', 'POST'])
@@ -2298,6 +2663,72 @@ def manage_attendee_types(event_id):
return redirect(url_for('organizer_dashboard')) return redirect(url_for('organizer_dashboard'))
@app.route('/organizer/event/<int:event_id>/attendee-type/<int:type_id>/edit', methods=['GET', 'POST'])
@login_required
def edit_attendee_type(event_id, type_id):
"""Edit an attendee type."""
if session.get('user_type') != 'organizer':
flash('Access denied.')
return redirect(url_for('index'))
try:
conn = get_db_connection()
cursor = conn.cursor(dictionary=True)
cursor.execute("SELECT * FROM events WHERE id = %s AND organizer_id = %s", (event_id, session['user_id']))
event = cursor.fetchone()
if not event:
flash('Event not found.')
cursor.close()
conn.close()
return redirect(url_for('organizer_dashboard'))
cursor.execute("SELECT * FROM attendee_types WHERE id = %s AND event_id = %s", (type_id, event_id))
attendee_type = cursor.fetchone()
if not attendee_type:
flash('Attendee type not found.')
cursor.close()
conn.close()
return redirect(url_for('manage_attendee_types', event_id=event_id))
# Check how many attendees are assigned to this type
cursor.execute("SELECT COUNT(*) as count FROM attendees WHERE attendee_type_id = %s", (type_id,))
attendee_count = cursor.fetchone()['count']
if request.method == 'POST':
name = request.form.get('name', '').strip()
price = request.form.get('price', '').strip()
if not name:
flash('Type name is required.')
cursor.close()
conn.close()
return render_template('organizer/edit_attendee_type.html', event=event, attendee_type=attendee_type)
try:
price_value = float(price) if price else 0.00
except ValueError:
price_value = 0.00
cursor.execute("""
UPDATE attendee_types SET name = %s, price = %s WHERE id = %s
""", (name, price_value, type_id))
conn.commit()
flash(f'Attendee type "{name}" updated successfully!')
cursor.close()
conn.close()
return redirect(url_for('manage_attendee_types', event_id=event_id))
cursor.close()
conn.close()
return render_template('organizer/edit_attendee_type.html', event=event, attendee_type=attendee_type, attendee_count=attendee_count)
except Error as e:
flash(f'Database error: {e}')
return redirect(url_for('organizer_dashboard'))
@app.route('/organizer/event/<int:event_id>/attendee-type/<int:type_id>/delete', methods=['POST']) @app.route('/organizer/event/<int:event_id>/attendee-type/<int:type_id>/delete', methods=['POST'])
@login_required @login_required
def delete_attendee_type(event_id, type_id): def delete_attendee_type(event_id, type_id):
@@ -2451,10 +2882,11 @@ def manage_event_staff(event_id):
return render_template('organizer/event_staff.html', event=event, staff_members=staff_members) return render_template('organizer/event_staff.html', event=event, staff_members=staff_members)
invite_token = generate_invite_token() invite_token = generate_invite_token()
staff_code = generate_staff_code()
cursor.execute(""" cursor.execute("""
INSERT INTO staff (event_id, email, first_name, last_name, invite_token) INSERT INTO staff (event_id, email, first_name, last_name, invite_token, staff_code)
VALUES (%s, %s, %s, %s, %s) VALUES (%s, %s, %s, %s, %s, %s)
""", (event_id, email, first_name, last_name, invite_token)) """, (event_id, email, first_name, last_name, invite_token, staff_code))
conn.commit() conn.commit()
# Get organizer email for sending # Get organizer email for sending
@@ -2692,10 +3124,11 @@ def batch_add_staff(event_id):
added_count = 0 added_count = 0
for member in source_staff: for member in source_staff:
if member['email'].lower() not in existing_emails: if member['email'].lower() not in existing_emails:
staff_code = generate_staff_code()
cursor.execute(""" cursor.execute("""
INSERT INTO staff (event_id, first_name, last_name, email, invite_token, invite_used, created_at) INSERT INTO staff (event_id, first_name, last_name, email, invite_token, invite_used, created_at, staff_code)
VALUES (%s, %s, %s, %s, UUID(), FALSE, NOW()) VALUES (%s, %s, %s, %s, UUID(), FALSE, NOW(), %s)
""", (event_id, member['first_name'], member['last_name'], member['email'])) """, (event_id, member['first_name'], member['last_name'], member['email'], staff_code))
existing_emails.add(member['email'].lower()) existing_emails.add(member['email'].lower())
added_count += 1 added_count += 1
@@ -2776,6 +3209,10 @@ def edit_attendee(attendee_id):
conn.close() conn.close()
return redirect(url_for('organizer_dashboard')) return redirect(url_for('organizer_dashboard'))
# Get attendee types for this event
cursor.execute("SELECT * FROM attendee_types WHERE event_id = %s ORDER BY name", (attendee['event_id'],))
attendee_types = cursor.fetchall()
if request.method == 'POST': if request.method == 'POST':
first_name = request.form.get('first_name', '').strip() first_name = request.form.get('first_name', '').strip()
last_name = request.form.get('last_name', '').strip() last_name = request.form.get('last_name', '').strip()
@@ -2783,12 +3220,13 @@ def edit_attendee(attendee_id):
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()
attendee_type_id = request.form.get('attendee_type_id', '').strip()
cursor.execute(""" cursor.execute("""
UPDATE attendees UPDATE attendees
SET first_name = %s, last_name = %s, email = %s, organisation = %s, role = %s, introduction = %s SET first_name = %s, last_name = %s, email = %s, organisation = %s, role = %s, introduction = %s, attendee_type_id = %s
WHERE id = %s WHERE id = %s
""", (first_name, last_name, email, organisation, role, introduction, attendee_id)) """, (first_name, last_name, email, organisation, role, introduction, attendee_type_id if attendee_type_id else None, attendee_id))
conn.commit() conn.commit()
cursor.close() cursor.close()
conn.close() conn.close()
@@ -2798,7 +3236,7 @@ def edit_attendee(attendee_id):
cursor.close() cursor.close()
conn.close() conn.close()
return render_template('organizer/edit_attendee.html', attendee=attendee) return render_template('organizer/edit_attendee.html', attendee=attendee, attendee_types=attendee_types)
except Error as e: except Error as e:
flash(f'Database error: {e}') flash(f'Database error: {e}')
return redirect(url_for('organizer_dashboard')) return redirect(url_for('organizer_dashboard'))
@@ -2883,7 +3321,145 @@ def event_badges(event_id):
return redirect(url_for('organizer_dashboard')) return redirect(url_for('organizer_dashboard'))
@app.route('/organizer/event/<int:event_id>/stats') @app.route('/organizer/event/<int:event_id>/badges/rectangular/download')
@login_required
def download_rectangular_badges(event_id):
"""Download rectangular badge PDFs for event."""
if session.get('user_type') != 'organizer':
flash('Access denied.')
return redirect(url_for('index'))
try:
conn = get_db_connection()
cursor = conn.cursor(dictionary=True)
cursor.execute("SELECT * FROM events WHERE id = %s AND organizer_id = %s", (event_id, session['user_id']))
event = cursor.fetchone()
if not event:
flash('Event not found.')
cursor.close()
conn.close()
return redirect(url_for('organizer_dashboard'))
cursor.execute("SELECT * FROM attendees WHERE event_id = %s ORDER BY last_name, first_name", (event_id,))
attendees = cursor.fetchall()
# Get attendee types for this event
cursor.execute("SELECT * FROM attendee_types WHERE event_id = %s", (event_id,))
attendee_types = cursor.fetchall()
attendee_types_map = {at['id']: at for at in attendee_types}
cursor.close()
conn.close()
if not attendees:
flash('No attendees found for this event.')
return redirect(url_for('event_badges', event_id=event_id))
# Generate rectangular badge PDF
pdf_buffer = generate_rectangular_badges_pdf(attendees, event, attendee_types_map)
filename = f"badges_rectangular_{event.get('code', event_id)}.pdf"
return send_file(
pdf_buffer,
mimetype='application/pdf',
as_attachment=True,
download_name=filename
)
except Error as e:
flash(f'Database error: {e}')
return redirect(url_for('organizer_dashboard'))
@app.route('/organizer/event/<int:event_id>/attendees/excel/download')
@login_required
def download_attendees_excel(event_id):
"""Download all attendees as Excel sheet."""
if session.get('user_type') != 'organizer':
flash('Access denied.')
return redirect(url_for('index'))
try:
conn = get_db_connection()
cursor = conn.cursor(dictionary=True)
cursor.execute("SELECT * FROM events WHERE id = %s AND organizer_id = %s", (event_id, session['user_id']))
event = cursor.fetchone()
if not event:
cursor.close()
conn.close()
flash('Event not found.')
return redirect(url_for('organizer_dashboard'))
cursor.execute("SELECT * FROM attendees WHERE event_id = %s ORDER BY last_name, first_name", (event_id,))
attendees = cursor.fetchall()
# Get attendee types
cursor.execute("SELECT * FROM attendee_types WHERE event_id = %s", (event_id,))
attendee_types = cursor.fetchall()
attendee_types_map = {at['id']: at for at in attendee_types}
cursor.close()
conn.close()
if not attendees:
flash('No attendees found.')
return redirect(url_for('event_badges', event_id=event_id))
# Create Excel workbook
wb = openpyxl.Workbook()
ws = wb.active
ws.title = "Attendees"
# Header styling
header_font = Font(bold=True, color="FFFFFF")
header_fill = PatternFill("solid", fgColor="333333")
header_alignment = Alignment(horizontal="center")
headers = ['ID', 'Voornaam', 'Achternaam', 'Email', 'Organisatie', 'Rol', 'Type', 'Ingecheckt', 'Aangemeld op']
for col, header in enumerate(headers, 1):
cell = ws.cell(row=1, column=col, value=header)
cell.font = header_font
cell.fill = header_fill
cell.alignment = header_alignment
# Data rows
for row, attendee in enumerate(attendees, 2):
ws.cell(row=row, column=1, value=attendee.get('id'))
ws.cell(row=row, column=2, value=attendee.get('first_name', ''))
ws.cell(row=row, column=3, value=attendee.get('last_name', ''))
ws.cell(row=row, column=4, value=attendee.get('email', ''))
ws.cell(row=row, column=5, value=attendee.get('organisation', ''))
ws.cell(row=row, column=6, value=attendee.get('role', ''))
ws.cell(row=row, column=7, value=attendee_types_map.get(attendee.get('attendee_type_id'), {}).get('name', ''))
ws.cell(row=row, column=8, value='Ja' if attendee.get('checked_in') else 'Nee')
ws.cell(row=row, column=9, value=str(attendee.get('created_at', ''))[:19] if attendee.get('created_at') else '')
# Auto-fit column widths
for col in ws.columns:
max_length = 0
column_letter = col[0].column_letter
for cell in col:
if cell.value:
max_length = max(max_length, len(str(cell.value)))
ws.column_dimensions[column_letter].width = min(max_length + 2, 30)
filename = f"attendees_{event.get('code', event_id)}.xlsx"
buffer = io.BytesIO()
wb.save(buffer)
buffer.seek(0)
return send_file(
buffer,
mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
as_attachment=True,
download_name=filename
)
except Error as e:
flash(f'Database error: {e}')
return redirect(url_for('organizer_dashboard'))
@login_required @login_required
def event_stats(event_id): def event_stats(event_id):
"""Attendance statistics for event.""" """Attendance statistics for event."""
@@ -3948,6 +4524,13 @@ def payment_page(code):
@app.route('/attendee/personal/<token>') @app.route('/attendee/personal/<token>')
def attendee_personal_page(token): def attendee_personal_page(token):
"""Personal page accessed via confirmation email link.""" """Personal page accessed via confirmation email link."""
client_ip = request.remote_addr
# Check if IP is blocked
if is_ip_blocked_for_personal_page(client_ip):
flash(f'Too many invalid attempts. Please try again later (blocked for 30 minutes).')
return redirect(url_for('index'))
try: try:
conn = get_db_connection() conn = get_db_connection()
cursor = conn.cursor(dictionary=True) cursor = conn.cursor(dictionary=True)
@@ -3956,17 +4539,23 @@ def attendee_personal_page(token):
cursor.execute("SELECT * FROM attendees WHERE confirmation_token = %s", (token,)) cursor.execute("SELECT * FROM attendees WHERE confirmation_token = %s", (token,))
attendee = cursor.fetchone() attendee = cursor.fetchone()
# Fallback: also accept attendee_code for old links where token was cleared
if not attendee: if not attendee:
flash('Invalid or expired link.') cursor.execute("SELECT * FROM attendees WHERE attendee_code = %s", (token,))
attendee = cursor.fetchone()
if not attendee:
record_failed_personal_page_attempt(client_ip)
flash('Invalid link.')
cursor.close() cursor.close()
conn.close() conn.close()
return redirect(url_for('index')) return redirect(url_for('index'))
# Clear confirmation token (one-time use for security) # Clear failed attempts on successful access
cursor.execute("UPDATE attendees SET confirmation_token = NULL WHERE id = %s", (attendee['id'],)) clear_failed_personal_page_attempts(client_ip)
conn.commit()
# Auto-login the attendee # Auto-login the attendee
# Note: confirmation_token is kept so the link remains valid indefinitely
session['user_id'] = attendee['id'] session['user_id'] = attendee['id']
session['user_type'] = 'attendee' session['user_type'] = 'attendee'
session['event_id'] = attendee['event_id'] session['event_id'] = attendee['event_id']
@@ -4030,4 +4619,10 @@ def view_event(event_id):
if __name__ == '__main__': if __name__ == '__main__':
# Ensure staff_code column exists in organizers and staff tables
ensure_staff_code_column()
ensure_all_organizers_have_staff_code()
ensure_staff_staff_code_column()
ensure_all_staff_have_staff_code()
app.run(debug=True, host='0.0.0.0', port=5002) app.run(debug=True, host='0.0.0.0', port=5002)
+2
View File
@@ -50,6 +50,7 @@ def create_tables():
email VARCHAR(255) UNIQUE NOT NULL, email VARCHAR(255) UNIQUE NOT NULL,
password_hash VARCHAR(255) NOT NULL, password_hash VARCHAR(255) NOT NULL,
name VARCHAR(255) NOT NULL, name VARCHAR(255) NOT NULL,
staff_code VARCHAR(10) UNIQUE DEFAULT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) )
""", """,
@@ -163,6 +164,7 @@ def create_tables():
password_hash VARCHAR(255) DEFAULT NULL, password_hash VARCHAR(255) DEFAULT NULL,
first_name VARCHAR(100) NOT NULL, first_name VARCHAR(100) NOT NULL,
last_name VARCHAR(100) NOT NULL, last_name VARCHAR(100) NOT NULL,
staff_code VARCHAR(10) UNIQUE DEFAULT NULL,
invite_token VARCHAR(64) DEFAULT NULL, invite_token VARCHAR(64) DEFAULT NULL,
invite_used BOOLEAN DEFAULT FALSE, invite_used BOOLEAN DEFAULT FALSE,
preferred_language VARCHAR(5) DEFAULT 'en', preferred_language VARCHAR(5) DEFAULT 'en',
+2 -1
View File
@@ -122,11 +122,12 @@ main {
padding: 0.75rem 1.5rem; padding: 0.75rem 1.5rem;
border-radius: 6px; border-radius: 6px;
text-decoration: none; text-decoration: none;
font-weight: 500; font-weight: 400;
text-align: center; text-align: center;
cursor: pointer; cursor: pointer;
border: none; border: none;
transition: all 0.2s; transition: all 0.2s;
vertical-align: middle;
} }
.btn-primary { .btn-primary {
+7
View File
@@ -3,6 +3,13 @@
{% block title %}{{ 'attendee_dashboard'|t }} - NetEvents{% endblock %} {% block title %}{{ 'attendee_dashboard'|t }} - NetEvents{% endblock %}
{% block content %} {% block content %}
<style>
.dashboard {
padding: 30px 20px;
max-width: 1600px;
margin: 0 auto;
}
</style>
<div class="dashboard"> <div class="dashboard">
<h1>{{ 'attendee_dashboard'|t }}</h1> <h1>{{ 'attendee_dashboard'|t }}</h1>
+1 -1
View File
@@ -56,7 +56,7 @@
.personal-page { .personal-page {
max-width: 900px; max-width: 900px;
margin: 0 auto; margin: 0 auto;
padding: 20px; padding: 30px 25px;
} }
.personal-page h1 { .personal-page h1 {
+2 -31
View File
@@ -5,17 +5,10 @@
{% block content %} {% block content %}
<div class="auth-container"> <div class="auth-container">
<div class="auth-box"> <div class="auth-box">
<h2>{{ 'login'|t }}</h2> <h2>{{ 'organizer_login'|t }}</h2>
<div class="user-type-tabs">
<button type="button" class="tab-btn" data-type="breakout_organizer">{{ 'presenter'|t }}</button>
<button type="button" class="tab-btn" data-type="staff">{{ 'staff'|t }}</button>
<button type="button" class="tab-btn" data-type="attendee">{{ 'visitor'|t }}</button>
<button type="button" class="tab-btn" data-type="organizer">{{ 'organizer'|t }}</button>
</div>
<form method="POST" action="{{ url_for('login') }}"> <form method="POST" action="{{ url_for('login') }}">
<input type="hidden" name="user_type" id="user_type" value="attendee"> <input type="hidden" name="user_type" value="organizer">
<div class="form-group"> <div class="form-group">
<label for="email">{{ 'email'|t }}</label> <label for="email">{{ 'email'|t }}</label>
@@ -36,26 +29,4 @@
</p> </p>
</div> </div>
</div> </div>
<script>
document.querySelectorAll('.tab-btn').forEach(btn => {
btn.addEventListener('click', function() {
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
this.classList.add('active');
document.getElementById('user_type').value = this.dataset.type;
});
});
// Pre-select tab based on passed default_type
const params = new URLSearchParams(window.location.search);
const type = params.get('type');
if (type) {
const btn = document.querySelector(`.tab-btn[data-type="${type}"]`);
if (btn) {
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
document.getElementById('user_type').value = type;
}
}
</script>
{% endblock %} {% endblock %}
+1 -1
View File
@@ -31,7 +31,7 @@
</div> </div>
</nav> </nav>
<main class="container"> <main>
{% with messages = get_flashed_messages() %} {% with messages = get_flashed_messages() %}
{% if messages %} {% if messages %}
<div class="flash-messages"> <div class="flash-messages">
+2 -3
View File
@@ -124,9 +124,8 @@
</button> </button>
</td> </td>
<td> <td>
<form method="POST" action="{{ url_for('delete_attendee_type', event_id=event.id, type_id=at.id) }}" style="display: inline;" <form method="GET" action="{{ url_for('edit_attendee_type', event_id=event.id, type_id=at.id) }}" style="display: inline;">
onsubmit="return confirm('{{ 'confirm_delete_type'|t }}');"> <button type="submit" class="btn btn-sm" style="background: #2563eb; color: #fff; padding: 4px 10px; margin-right: 5px;">{{ 'edit'|t }}</button>
<button type="submit" class="btn btn-sm btn-danger">{{ 'delete'|t }}</button>
</form> </form>
</td> </td>
</tr> </tr>
+27 -15
View File
@@ -9,6 +9,8 @@
<p>{{ 'attendee_badges'|t }}</p> <p>{{ 'attendee_badges'|t }}</p>
<div class="header-actions"> <div class="header-actions">
<a href="{{ url_for('event_scan', event_id=event.id) }}" class="btn btn-secondary">📷 {{ 'scan_qr_codes'|t }}</a> <a href="{{ url_for('event_scan', event_id=event.id) }}" class="btn btn-secondary">📷 {{ 'scan_qr_codes'|t }}</a>
<a href="{{ url_for('download_rectangular_badges', event_id=event.id) }}" class="btn btn-secondary">🏷️ Rectangular Labels (80x50mm)</a>
<a href="{{ url_for('download_attendees_excel', event_id=event.id) }}" class="btn btn-secondary">📥 Download Excel</a>
<button onclick="window.print()" class="btn btn-primary">{{ 'print_badges'|t }}</button> <button onclick="window.print()" class="btn btn-primary">{{ 'print_badges'|t }}</button>
</div> </div>
</div> </div>
@@ -18,7 +20,7 @@
<div class="badge-card" data-first="{{ attendee.first_name }}" data-last="{{ attendee.last_name }}"> <div class="badge-card" data-first="{{ attendee.first_name }}" data-last="{{ attendee.last_name }}">
<div class="badge-header"> <div class="badge-header">
<h3 class="badge-name"> <h3 class="badge-name">
<span class="first-name">{{ attendee.first_name }}</span><br> <span class="first-name">{{ attendee.first_name }}</span>
<span class="last-name">{{ attendee.last_name }}</span> <span class="last-name">{{ attendee.last_name }}</span>
</h3> </h3>
</div> </div>
@@ -38,27 +40,32 @@
{% if attendee.checked_in %}✓ {{ 'checked_in'|t }}{% else %}{{ 'pending'|t }}{% endif %} {% if attendee.checked_in %}✓ {{ 'checked_in'|t }}{% else %}{{ 'pending'|t }}{% endif %}
</span> </span>
</div> </div>
<div class="badge-event-bar">
{{ event.name }} — {{ event.start_time|localized_date if event.start_time else 'TBD' }}
</div>
</div> </div>
{% endfor %} {% endfor %}
</div> </div>
<style> <style>
.badge-header h3 { margin: 0; font-size: 1rem; line-height: 1.2; } .badge-header h3 { margin: 0; font-size: 30pt; line-height: 1.1; }
.badge-header h3 .first-name, .badge-header h3 .first-name,
.badge-header h3 .last-name { display: block; line-height: 1.1; } .badge-header h3 .last-name { display: inline; line-height: 1.1; font-size: 30pt; }
.badge-header h3 .last-name { font-size: 0.8rem; } .badge-header h3 .last-name:before { content: ' '; }
@media print { @media print {
.navbar, .footer, .badges-header .header-actions button, .flash-messages { display: none !important; } .navbar, .footer, .badges-header .header-actions button, .flash-messages { display: none !important; }
.badges-page { padding: 0; } .badges-page { padding: 0; }
.badges-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 20px; } .badges-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 20px; }
.badge-card { border: 2px solid #000; padding: 15px; page-break-inside: avoid; } .badge-card { border: 2px solid #000; padding: 15px; page-break-inside: avoid; }
.badge-header h3 { margin: 0; font-size: 1rem; line-height: 1.2; } .badge-header h3 { margin: 0; font-size: 30pt; line-height: 1.1; }
.badge-header h3 .first-name, .badge-header h3 .first-name,
.badge-header h3 .last-name { display: block !important; line-height: 1.1; } .badge-header h3 .last-name { display: inline !important; line-height: 1.1; font-size: 30pt; }
.badge-header h3 .last-name { font-size: 0.8rem !important; } .badge-header h3 .last-name:before { content: ' '; }
.badge-qr img { width: 80px !important; height: 80px !important; } .badge-qr img { width: 80px !important; height: 80px !important; }
.badge-body p { margin: 5px 0; } .badge-body p { margin: 5px 0; }
.badge-footer { display: flex; justify-content: space-between; margin-top: 10px; padding-top: 10px; border-top: 1px solid #ccc; } .badge-footer { display: flex; justify-content: space-between; margin-top: 10px; padding-top: 10px; border-top: 1px solid #ccc; }
.badge-event-bar { background: #000; color: #fff; padding: 8px 15px; margin: 10px -15px -15px; font-size: 12pt; text-align: center; }
.badge-event-bar { background: #000; color: #fff; padding: 8px 15px; margin: 10px -15px -15px; font-size: 12pt; text-align: center; }
} }
</style> </style>
<script> <script>
@@ -66,9 +73,10 @@
// Create offscreen canvas for text measurement // Create offscreen canvas for text measurement
const canvas = document.createElement('canvas'); const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d'); const ctx = canvas.getContext('2d');
ctx.font = '30pt sans-serif';
// Width limit for last name before it needs font reduction // Container width limit (badge width)
const LAST_NAME_WIDTH_LIMIT = 120; const BADGE_WIDTH_LIMIT = 280;
function measureText(text, fontSize, fontFamily) { function measureText(text, fontSize, fontFamily) {
ctx.font = fontSize + 'px ' + (fontFamily || 'sans-serif'); ctx.font = fontSize + 'px ' + (fontFamily || 'sans-serif');
@@ -78,16 +86,20 @@
function formatBadgeNames() { function formatBadgeNames() {
const cards = document.querySelectorAll('.badge-card'); const cards = document.querySelectorAll('.badge-card');
cards.forEach(card => { cards.forEach(card => {
const firstName = card.dataset.first || '';
const lastName = card.dataset.last || ''; const lastName = card.dataset.last || '';
const firstSpan = card.querySelector('.first-name');
const lastSpan = card.querySelector('.last-name'); const lastSpan = card.querySelector('.last-name');
// Measure last name at reduced size (0.8rem = 12.8px) // Measure first + last name at 20pt
const reducedFontSize = 12.8; const combinedWidth = measureText(firstName + ' ' + lastName, 30);
const reducedLastWidth = measureText(lastName, reducedFontSize);
// If still too wide, reduce further // If combined width exceeds limit, insert br before last name
if (reducedLastWidth > LAST_NAME_WIDTH_LIMIT) { if (combinedWidth > BADGE_WIDTH_LIMIT) {
lastSpan.style.fontSize = '0.7rem'; if (!lastSpan.previousElementSibling || lastSpan.previousElementSibling.tagName !== 'BR') {
const br = document.createElement('br');
lastSpan.parentNode.insertBefore(br, lastSpan);
}
} }
}); });
} }
@@ -3,6 +3,7 @@
{% block title %}{{ session.name }} - {{ 'breakout_session'|t }}{% endblock %} {% block title %}{{ session.name }} - {{ 'breakout_session'|t }}{% endblock %}
{% block content %} {% block content %}
<div style="padding: 20px;">
<div class="breakout-session-detail"> <div class="breakout-session-detail">
<div class="session-header"> <div class="session-header">
<h1>{{ session.name }}</h1> <h1>{{ session.name }}</h1>
@@ -55,4 +56,5 @@
{% endif %} {% endif %}
</section> </section>
</div> </div>
</div>
{% endblock %} {% endblock %}
@@ -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"> <div class="form-container" style="padding: 20px;">
<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>
@@ -20,12 +20,12 @@
<div class="form-group"> <div class="form-group">
<label for="start_time">{{ 'start_time'|t }}</label> <label for="start_time">{{ 'start_time'|t }}</label>
<input type="datetime-local" id="start_time" name="start_time" required> <input type="datetime-local" id="start_time" name="start_time" required value="{% if prefilled_date %}{{ prefilled_date }}T09:00{% endif %}">
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="end_time">{{ 'end_time'|t }}</label> <label for="end_time">{{ 'end_time'|t }}</label>
<input type="datetime-local" id="end_time" name="end_time" required> <input type="datetime-local" id="end_time" name="end_time" required value="{% if prefilled_date %}{{ prefilled_date }}T10:00{% endif %}">
</div> </div>
<div class="form-group"> <div class="form-group">
+88 -5
View File
@@ -4,6 +4,36 @@
{% block content %} {% block content %}
<style> <style>
.dashboard {
width: 100%;
margin: 0 auto;
padding: 30px 20px;
box-sizing: border-box;
max-width: 1600px;
}
.events-list {
display: grid !important;
grid-template-columns: repeat(auto-fill, minmax(400px, 1fr)) !important;
gap: 1.5rem !important;
width: 100% !important;
}
.event-item {
display: flex !important;
flex-direction: column !important;
width: 100% !important;
box-sizing: border-box !important;
}
.event-item-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
flex-wrap: wrap;
gap: 10px;
width: 100%;
}
.event-info {
width: 100%;
}
.progress-bar-container { .progress-bar-container {
background: #e9ecef; background: #e9ecef;
border-radius: 4px; border-radius: 4px;
@@ -89,11 +119,11 @@
background: #dee2e6; background: #dee2e6;
} }
.section-toggle::after { .section-toggle::after {
content: ''; content: '';
font-size: 10px; font-size: 10px;
} }
.section-toggle.collapsed::after { .section-toggle.collapsed::after {
content: ''; content: '';
} }
.section-content { .section-content {
padding: 10px 0; padding: 10px 0;
@@ -126,6 +156,18 @@
display: flex; display: flex;
gap: 5px; gap: 5px;
} }
.pagination-controls {
display: flex;
justify-content: center;
gap: 8px;
margin-top: 10px;
padding-top: 10px;
border-top: 1px solid #e9ecef;
}
.pagination-controls button {
padding: 4px 12px;
font-size: 12px;
}
/* Wide screen - spread content */ /* Wide screen - spread content */
@media (min-width: 1400px) { @media (min-width: 1400px) {
@@ -165,8 +207,46 @@
function toggleSection(id) { function toggleSection(id) {
const content = document.getElementById(id); const content = document.getElementById(id);
const toggle = content.previousElementSibling; const toggle = content.previousElementSibling;
const wasCollapsed = content.classList.contains('collapsed');
content.classList.toggle('collapsed'); content.classList.toggle('collapsed');
toggle.classList.toggle('collapsed'); toggle.classList.toggle('collapsed');
if (wasCollapsed && content.dataset.itemCount) {
const perPage = parseInt(content.dataset.perPage) || 5;
setupPagination(id, parseInt(content.dataset.itemCount), perPage);
}
}
function showPage(sectionId, page, perPage) {
const section = document.getElementById(sectionId);
const items = section.querySelectorAll('.list-item');
items.forEach((item, i) => {
const start = (page - 1) * perPage;
const end = start + perPage;
item.style.display = i >= start && i < end ? 'flex' : 'none';
});
const totalPages = parseInt(section.dataset.totalPages);
section.querySelectorAll('.pagination-controls button').forEach(btn => {
btn.disabled = parseInt(btn.dataset.page) == page;
});
}
function setupPagination(sectionId, totalItems, perPage) {
const section = document.getElementById(sectionId);
const totalPages = Math.ceil(totalItems / perPage);
if (totalPages <= 1) return;
section.dataset.totalPages = totalPages;
const controls = section.querySelector('.pagination-controls');
controls.innerHTML = '';
for (let p = 1; p <= totalPages; p++) {
const btn = document.createElement('button');
btn.className = 'btn btn-sm btn-outline';
btn.dataset.page = p;
btn.textContent = p;
btn.onclick = () => showPage(sectionId, p, perPage);
if (p === 1) btn.disabled = true;
controls.appendChild(btn);
}
showPage(sectionId, 1, perPage);
} }
</script> </script>
@@ -215,7 +295,7 @@ function toggleSection(id) {
<div class="section-toggle" onclick="toggleSection('staff-{{ event.id }}')"> <div class="section-toggle" onclick="toggleSection('staff-{{ event.id }}')">
<strong>Staff ({{ event.staff|length }})</strong> <strong>Staff ({{ event.staff|length }})</strong>
</div> </div>
<div id="staff-{{ event.id }}" class="section-content collapsed"> <div id="staff-{{ event.id }}" class="section-content collapsed" data-item-count="{{ event.staff|length }}" data-per-page="5">
<div class="item-list"> <div class="item-list">
{% for s in event.staff %} {% for s in event.staff %}
<div class="list-item"> <div class="list-item">
@@ -232,6 +312,7 @@ function toggleSection(id) {
</div> </div>
{% endfor %} {% endfor %}
</div> </div>
<div class="pagination-controls"></div>
</div> </div>
</div> </div>
{% endif %} {% endif %}
@@ -241,7 +322,7 @@ function toggleSection(id) {
<div class="section-toggle" onclick="toggleSection('sessions-{{ event.id }}')"> <div class="section-toggle" onclick="toggleSection('sessions-{{ event.id }}')">
<strong>{{ 'breakout_sessions'|t }} ({{ event.breakout_sessions|length }})</strong> <strong>{{ 'breakout_sessions'|t }} ({{ event.breakout_sessions|length }})</strong>
</div> </div>
<div id="sessions-{{ event.id }}" class="section-content collapsed"> <div id="sessions-{{ event.id }}" class="section-content collapsed" data-item-count="{{ event.breakout_sessions|length }}" data-per-page="5">
<div class="item-list"> <div class="item-list">
{% for session in event.breakout_sessions %} {% for session in event.breakout_sessions %}
<div class="list-item"> <div class="list-item">
@@ -262,6 +343,7 @@ function toggleSection(id) {
</div> </div>
{% endfor %} {% endfor %}
</div> </div>
<div class="pagination-controls"></div>
</div> </div>
</div> </div>
{% endif %} {% endif %}
@@ -271,7 +353,7 @@ function toggleSection(id) {
<div class="section-toggle" onclick="toggleSection('attendees-{{ event.id }}')"> <div class="section-toggle" onclick="toggleSection('attendees-{{ event.id }}')">
<strong>{{ 'attendees'|t }} ({{ event.attendees|length }})</strong> <strong>{{ 'attendees'|t }} ({{ event.attendees|length }})</strong>
</div> </div>
<div id="attendees-{{ event.id }}" class="section-content collapsed"> <div id="attendees-{{ event.id }}" class="section-content collapsed" data-item-count="{{ event.attendees|length }}" data-per-page="5">
<div class="item-list"> <div class="item-list">
{% for att in event.attendees %} {% for att in event.attendees %}
<div class="list-item"> <div class="list-item">
@@ -291,6 +373,7 @@ function toggleSection(id) {
</div> </div>
{% endfor %} {% endfor %}
</div> </div>
<div class="pagination-controls"></div>
</div> </div>
</div> </div>
{% endif %} {% endif %}
+10
View File
@@ -37,6 +37,16 @@
<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">
<label for="attendee_type_id">{{ 'attendee_type'|t }}</label>
<select id="attendee_type_id" name="attendee_type_id">
<option value="">{{ 'no_type'|t }}</option>
{% for at in attendee_types %}
<option value="{{ at.id }}" {% if attendee.attendee_type_id == at.id %}selected{% endif %}>{{ at.name }} ({{ at.price|format_currency if at.price > 0 else 'free'|t }})</option>
{% endfor %}
</select>
</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>
@@ -0,0 +1,79 @@
{% extends "base.html" %}
{% block title %}{{ 'edit_attendee_type'|t }} - {{ event.name }} - NetEvents{% endblock %}
{% block content %}
<style>
.section-box {
background: #fff;
border: 1px solid #ddd;
border-radius: 8px;
padding: 20px;
margin-bottom: 20px;
}
.section-box h2 {
margin-top: 0;
margin-bottom: 15px;
color: #2c3e50;
}
.form-row {
display: flex;
gap: 15px;
margin-bottom: 15px;
}
.form-group {
flex: 1;
}
.form-group label {
display: block;
margin-bottom: 5px;
font-weight: 500;
}
.form-group input {
width: 100%;
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 4px;
box-sizing: border-box;
}
.back-link {
margin-bottom: 20px;
display: inline-block;
}
</style>
<div class="edit-attendee-type-page">
<a href="{{ url_for('manage_attendee_types', event_id=event.id) }}" class="back-link">&larr; {{ 'back_to_types'|t }}</a>
<h1>{{ 'edit_attendee_type'|t }} - {{ event.name }}</h1>
<div class="section-box">
<h2>{{ 'type_details'|t }}</h2>
<form method="POST" action="{{ url_for('edit_attendee_type', event_id=event.id, type_id=attendee_type.id) }}">
<div class="form-row">
<div class="form-group">
<label for="name">{{ 'type_name'|t }} *</label>
<input type="text" id="name" name="name" value="{{ attendee_type.name }}" placeholder="{{ 'e_g_vip_speaker_student'|t }}" required>
</div>
<div class="form-group">
<label for="price">{{ 'price'|t }} ({{ 'optional'|t }})</label>
<input type="number" id="price" name="price" step="0.01" min="0" value="{{ attendee_type.price if attendee_type.price else '' }}" placeholder="0.00">
<small style="color: #666;">{{ 'leave_empty_for_free'|t }}</small>
</div>
</div>
<button type="submit" class="btn btn-primary">{{ 'save_changes'|t }}</button>
<a href="{{ url_for('manage_attendee_types', event_id=event.id) }}" class="btn btn-secondary" style="margin-left: 10px; padding: 0.75rem 1.5rem; font-size: 1rem; font-weight: 400; line-height: 1; height: auto; box-sizing: border-box; display: inline-block; text-decoration: none; border-radius: 6px; cursor: pointer; transition: all 0.2s; vertical-align: middle; color: white; background-color: #64748b;">{{ 'cancel'|t }}</a>
{% if attendee_count == 0 %}
<form method="POST" action="{{ url_for('delete_attendee_type', event_id=event.id, type_id=attendee_type.id) }}" style="display: inline; margin-left: 10px;"
onsubmit="return confirm('{{ 'confirm_delete_type'|t }}');">
<button type="submit" class="btn btn-danger">{{ 'delete'|t }}</button>
</form>
{% else %}
<button type="button" class="btn btn-danger" disabled
title="{{ 'cannot_delete_type_with_attendees'|t|format(attendee_count) }}"
style="margin-left: 10px; opacity: 0.5; cursor: not-allowed;">{{ 'delete'|t }}</button>
{% endif %}
</form>
</div>
</div>
{% endblock %}
@@ -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"> <div class="form-container" style="padding: 20px;">
<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>
+5
View File
@@ -3,6 +3,7 @@
{% block title %}{{ event.name }} - NetEvents{% endblock %} {% block title %}{{ event.name }} - NetEvents{% endblock %}
{% block content %} {% block content %}
<div style="padding: 20px;">
<style> <style>
.section-box { .section-box {
background: #fff; background: #fff;
@@ -374,6 +375,9 @@ th.sort-desc .sort-icon::before {
<h2>{{ 'registered_attendees'|t }}</h2> <h2>{{ 'registered_attendees'|t }}</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('download_rectangular_badges', event_id=event.id) }}" class="btn btn-secondary">🏷️ Rectangular Labels (80x50mm)</a>
<a href="{{ url_for('download_attendees_excel', event_id=event.id) }}" class="btn btn-secondary">📥 {{ 'download_excel'|t }}</a>
</div> </div>
{% if attendees %} {% if attendees %}
@@ -644,4 +648,5 @@ function fallbackCopy(url) {
// Initial selected count // Initial selected count
updateSelectedCount(); updateSelectedCount();
</script> </script>
</div>
{% endblock %} {% endblock %}