Add attendee type management and staff codes
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -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">← {{ '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>
|
||||||
|
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
Reference in New Issue
Block a user