Security hardening: env-based config, CSRF protection, secure code generation, plus presenter management and email communications system
- Move all credentials from hardcoded to .env with python-dotenv (add .env.example template and .gitignore) - Add CSRF token generation and validation on all state-changing requests - Replace random.choices with secrets.choice for all secure code generation - Add session cookie security headers (HttpOnly, Secure, SameSite) - Add presenter management: assign/remove presenters to breakout sessions, presenter QR scanning - Add email communications system: templates per event, custom email sending, sent email tracking - Add staff/organizer profile pages with staff code display - New DB tables: event_email_templates, sent_emails, user_communications - New DB columns: staff.reminder_count/reminder_dates, breakout_session_organizers.presenter_code - Expand English translation strings across all new features Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -3,170 +3,12 @@
|
||||
{% block title %}{{ event.name }} - NetEvents{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div style="padding: 20px;">
|
||||
<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;
|
||||
}
|
||||
.section-actions {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
.staff-table,
|
||||
.sessions-table,
|
||||
.attendees-table {
|
||||
width: 100%;
|
||||
}
|
||||
.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;
|
||||
}
|
||||
th.sortable {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
th.sortable:hover {
|
||||
background: #f0f0f0;
|
||||
}
|
||||
.sort-icon::before {
|
||||
content: '\2195';
|
||||
margin-left: 5px;
|
||||
opacity: 0.4;
|
||||
}
|
||||
th.sort-asc .sort-icon::before {
|
||||
content: '\2191';
|
||||
opacity: 1;
|
||||
}
|
||||
th.sort-desc .sort-icon::before {
|
||||
content: '\2193';
|
||||
opacity: 1;
|
||||
}
|
||||
.modal-overlay {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
backdrop-filter: blur(4px);
|
||||
-webkit-backdrop-filter: blur(4px);
|
||||
z-index: 1000;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
.modal-content {
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
padding: 24px;
|
||||
max-width: 400px;
|
||||
width: 90%;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
.modal-header h2 {
|
||||
margin-top: 0;
|
||||
color: #dc3545;
|
||||
}
|
||||
.modal-actions {
|
||||
margin-top: 20px;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
/* Wide screen - spread content */
|
||||
@media (min-width: 1400px) {
|
||||
.event-detail {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.event-detail .section-box {
|
||||
padding: 30px 40px;
|
||||
}
|
||||
|
||||
.event-meta-box {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.staff-section .section-box,
|
||||
.breakout-sessions-section .section-box,
|
||||
.attendees-section .section-box {
|
||||
padding: 25px 30px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Responsive styles */
|
||||
@media (max-width: 768px) {
|
||||
.event-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.event-meta-box p {
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.staff-table,
|
||||
.sessions-table,
|
||||
.attendees-table {
|
||||
display: block;
|
||||
overflow-x: auto;
|
||||
white-space: nowrap;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.section-box {
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
padding: 16px;
|
||||
margin: 10px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 8px 12px;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<div class="event-detail">
|
||||
<div class="event-header">
|
||||
<h1>{{ event.name }}</h1>
|
||||
<div class="event-actions">
|
||||
<a href="{{ url_for('edit_event', event_id=event.id) }}" class="btn btn-outline">{{ 'edit_event'|t }}</a>
|
||||
<a href="{{ url_for('event_communications', event_id=event.id) }}" class="btn btn-outline"><i data-lucide="mail"></i> {{ 'communications'|t }}</a>
|
||||
<a href="{{ url_for('edit_event', event_id=event.id) }}" class="btn btn-outline"><i data-lucide="pencil"></i> {{ 'edit_event'|t }}</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -185,14 +27,14 @@ th.sort-desc .sort-icon::before {
|
||||
<p><strong>{{ 'location'|t }}:</strong> {{ event.location }}</p>
|
||||
<p><strong>{{ 'max_attendees'|t }}:</strong> {{ event.max_attendees or ('unlimited'|t) }}</p>
|
||||
<p><strong>{{ 'registered_attendees'|t }}:</strong> <span id="attendee-count">{{ attendees|length }}</span></p>
|
||||
<p><strong>{{ 'registration_link'|t }}:</strong> <span id="reg-link">{{ url_for('register_event', code=event.code, _external=True) }}</span> <button type="button" style="padding: 2px 6px; font-size: 11px;" onclick="navigator.clipboard.writeText('{{ url_for('register_event', code=event.code, _external=True) }}').then(() => this.textContent = 'Copied!')">Copy</button></p>
|
||||
<p><strong>{{ 'registration_link'|t }}:</strong> <span id="reg-link">{{ url_for('register_event', code=event.code, _external=True) }}</span> <button type="button" class="btn btn-sm btn-ghost" onclick="copyLink('{{ url_for('register_event', code=event.code, _external=True) }}')"><i data-lucide="copy"></i></button></p>
|
||||
</div>
|
||||
|
||||
<section class="attendee-types-section">
|
||||
<div class="section-box">
|
||||
<h2>{{ 'attendee_types'|t }}</h2>
|
||||
<div class="section-actions">
|
||||
<a href="{{ url_for('manage_attendee_types', event_id=event.id) }}" class="btn btn-primary">{{ 'manage_types'|t }}</a>
|
||||
<a href="{{ url_for('manage_attendee_types', event_id=event.id) }}" class="btn btn-primary"><i data-lucide="tag"></i> {{ 'manage_types'|t }}</a>
|
||||
</div>
|
||||
|
||||
{% if attendee_types %}
|
||||
@@ -210,18 +52,18 @@ th.sort-desc .sort-icon::before {
|
||||
<td><strong>{{ at.name }}</strong></td>
|
||||
<td>
|
||||
{% if at.price and at.price > 0 %}
|
||||
<span style="color: #27ae60; font-weight: 500;">{{ at.price|format_currency }}</span>
|
||||
<span class="text-success font-medium">{{ at.price|format_currency }}</span>
|
||||
{% else %}
|
||||
<span style="color: #7f8c8d;">{{ 'free'|t }}</span>
|
||||
<span class="text-muted">{{ 'free'|t }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<code style="background: #f0f0f0; padding: 4px 8px; border-radius: 4px; font-size: 12px;">
|
||||
<code class="code">
|
||||
{{ url_for('register_event', code=event.code, type_code=at.code, _external=True) }}
|
||||
</code>
|
||||
<button type="button" class="btn btn-sm" style="padding: 2px 8px; margin-left: 8px;"
|
||||
<button type="button" class="btn btn-sm btn-ghost"
|
||||
onclick="copyLink('{{ url_for('register_event', code=event.code, type_code=at.code, _external=True) }}')">
|
||||
{{ 'copy'|t }}
|
||||
<i data-lucide="copy"></i>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -229,7 +71,7 @@ th.sort-desc .sort-icon::before {
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<p class="no-types" style="color: #888;">{{ 'no_attendee_types_defined'|t }} <a href="{{ url_for('manage_attendee_types', event_id=event.id) }}">{{ 'create_first_type'|t }}</a></p>
|
||||
<p class="no-types">{{ 'no_attendee_types_defined'|t }} <a href="{{ url_for('manage_attendee_types', event_id=event.id) }}">{{ 'create_first_type'|t }}</a></p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
@@ -238,10 +80,10 @@ th.sort-desc .sort-icon::before {
|
||||
<div class="section-box">
|
||||
<h2>{{ 'staff'|t }}</h2>
|
||||
<div class="section-actions">
|
||||
<button type="button" class="btn btn-primary" id="add-staff-btn">{{ 'add_staff'|t }}</button>
|
||||
<button type="button" class="btn btn-primary" id="add-staff-btn"><i data-lucide="user-plus"></i> {{ 'add_staff'|t }}</button>
|
||||
</div>
|
||||
|
||||
<div id="add-staff-form" style="display: none; margin-bottom: 20px; padding: 15px; background: #f8f9fa; border-radius: 5px;">
|
||||
<div id="add-staff-form" class="add-staff-form d-none">
|
||||
<form method="POST" action="{{ url_for('manage_event_staff', event_id=event.id) }}" id="staff-inline-form">
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
@@ -261,9 +103,9 @@ th.sort-desc .sort-icon::before {
|
||||
<button type="button" class="btn btn-outline" id="cancel-staff-btn">{{ 'cancel'|t }}</button>
|
||||
</form>
|
||||
{% if other_events %}
|
||||
<form method="POST" action="{{ url_for('batch_add_staff', event_id=event.id) }}" style="margin-top: 15px; padding-top: 15px; border-top: 1px solid #ddd;">
|
||||
<div class="form-row" style="align-items: flex-end;">
|
||||
<div class="form-group" style="flex: 1;">
|
||||
<form method="POST" action="{{ url_for('batch_add_staff', event_id=event.id) }}" class="border-top pt-4 mt-4">
|
||||
<div class="form-row align-items-end">
|
||||
<div class="form-group">
|
||||
<label for="batch_source_event">Or add staff from another event:</label>
|
||||
<select name="source_event_id" id="batch_source_event" required>
|
||||
<option value="">Select event...</option>
|
||||
@@ -282,16 +124,18 @@ th.sort-desc .sort-icon::before {
|
||||
<table class="staff-table" id="event-staff-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-sort="name" class="sortable">{{ 'name'|t }} <span class="sort-icon"></span></th>
|
||||
<th data-sort="first_name" class="sortable">{{ 'first_name'|t }} <span class="sort-icon"></span></th>
|
||||
<th data-sort="last_name" class="sortable">{{ 'last_name'|t }} <span class="sort-icon"></span></th>
|
||||
<th data-sort="email" class="sortable">{{ 'email'|t }} <span class="sort-icon"></span></th>
|
||||
<th data-sort="status" class="sortable">{{ 'status'|t }} <span class="sort-icon"></span></th>
|
||||
<th data-sort="invite_token" class="sortable">{{ 'status'|t }} <span class="sort-icon"></span></th>
|
||||
<th>{{ 'actions'|t }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="event-staff-tbody">
|
||||
{% for member in staff %}
|
||||
<tr data-name="{{ member.first_name }} {{ member.last_name }}" data-email="{{ member.email }}" data-status="{{ member.invite_token }}">
|
||||
<td>{{ member.first_name }} {{ member.last_name }}</td>
|
||||
<tr data-first-name="{{ member.first_name }}" data-last-name="{{ member.last_name }}" data-email="{{ member.email }}" data-status="{{ member.invite_token }}">
|
||||
<td>{{ member.first_name }}</td>
|
||||
<td>{{ member.last_name }}</td>
|
||||
<td>{{ member.email }}</td>
|
||||
<td>
|
||||
{% if member.invite_token %}
|
||||
@@ -301,29 +145,45 @@ th.sort-desc .sort-icon::before {
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<a href="{{ url_for('edit_staff', event_id=event.id, staff_id=member.id) }}" class="btn btn-sm btn-success">{{ 'view'|t }}</a>
|
||||
<button type="button" class="btn btn-sm btn-danger" onclick="showDeleteModal({{ member.id }}, '{{ member.first_name }} {{ member.last_name }}', '{{ member.email }}')">{{ 'remove'|t }}</button>
|
||||
<a href="{{ url_for('edit_staff', event_id=event.id, staff_id=member.id) }}" class="btn btn-sm btn-success"><i data-lucide="eye"></i></a>
|
||||
<button type="button" class="btn btn-sm btn-danger" onclick="showDeleteModal({{ member.id }}, '{{ member.first_name }} {{ member.last_name }}', '{{ member.email }}')"><i data-lucide="trash-2"></i></button>
|
||||
{% if member.invite_token %}
|
||||
<button type="button" class="btn btn-sm btn-outline remind-btn" data-staff-id="{{ member.id }}" data-reminder-dates="{{ member.reminder_dates|tojson }}" title="{{ 'send_reminder'|t }}">
|
||||
<i data-lucide="bell"></i> <span class="reminder-count">{{ member.reminder_count|default(0) }}</span>
|
||||
</button>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% if total_staff_pages > 1 %}
|
||||
<div class="pagination mt-4" id="staff-pagination">
|
||||
<button type="button" class="btn btn-sm btn-outline" onclick="loadStaffPage({{ staff_page - 1 }})" {% if staff_page <= 1 %}disabled{% endif %}>
|
||||
<i data-lucide="chevron-left"></i>
|
||||
</button>
|
||||
<span class="pagination-info">{{ 'page'|t }} <span id="staff-current-page">{{ staff_page }}</span> {{ 'of'|t }} {{ total_staff_pages }}</span>
|
||||
<button type="button" class="btn btn-sm btn-outline" onclick="loadStaffPage({{ staff_page + 1 }})" {% if staff_page >= total_staff_pages %}disabled{% endif %}>
|
||||
<i data-lucide="chevron-right"></i>
|
||||
</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<p class="no-staff">{{ 'no_staff_yet'|t }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div id="staffDeleteModal" class="modal-overlay" style="display: none;">
|
||||
<div id="staffDeleteModal" class="modal-overlay">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h2>{{ 'remove_staff_member'|t }}</h2>
|
||||
<p>{{ 'confirm_remove_staff'|t }} <strong id="staffName"></strong> {{ 'from_event'|t }}?</p>
|
||||
<p style="color: #666; font-size: 14px;">{{ 'email'|t }}: <span id="staffEmail"></span></p>
|
||||
<p class="text-muted text-sm">{{ 'email'|t }}: <span id="staffEmail"></span></p>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="btn btn-outline" onclick="closeStaffDeleteModal()">{{ 'cancel'|t }}</button>
|
||||
<form id="staffDeleteForm" method="POST" style="display: inline;">
|
||||
<form id="staffDeleteForm" method="POST" class="d-inline">
|
||||
<button type="submit" class="btn btn-danger">{{ 'remove'|t }}</button>
|
||||
</form>
|
||||
</div>
|
||||
@@ -334,7 +194,7 @@ th.sort-desc .sort-icon::before {
|
||||
<div class="section-box">
|
||||
<h2>{{ 'breakout_sessions'|t }}</h2>
|
||||
<div class="section-actions">
|
||||
<a href="{{ url_for('create_breakout_session', event_id=event.id) }}" class="btn btn-primary">{{ 'add_breakout_session'|t }}</a>
|
||||
<a href="{{ url_for('create_breakout_session', event_id=event.id) }}" class="btn btn-primary"><i data-lucide="plus"></i> {{ 'add_breakout_session'|t }}</a>
|
||||
</div>
|
||||
|
||||
{% if breakout_sessions %}
|
||||
@@ -358,7 +218,7 @@ th.sort-desc .sort-icon::before {
|
||||
<td>{{ session.max_attendees or ('unlimited'|t) }}</td>
|
||||
<td>{{ session.registered_count|default(0) }}</td>
|
||||
<td>
|
||||
<a href="{{ url_for('view_breakout_session', code=session.code) }}" class="btn btn-sm btn-success">{{ 'view'|t }}</a>
|
||||
<a href="{{ url_for('view_breakout_session', code=session.code) }}" class="btn btn-sm btn-success"><i data-lucide="eye"></i></a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
@@ -374,75 +234,88 @@ th.sort-desc .sort-icon::before {
|
||||
<div class="section-box">
|
||||
<h2>{{ 'registered_attendees'|t }} ({{ attendees|length }})</h2>
|
||||
<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('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>
|
||||
<a href="{{ url_for('register_attendee', code=event.code) }}" class="btn btn-primary"><i data-lucide="user-plus"></i> {{ 'add_attendee'|t }}</a>
|
||||
<a href="{{ url_for('event_badges', event_id=event.id) }}" class="btn btn-secondary"><i data-lucide="printer"></i> {{ 'print_badges'|t }}</a>
|
||||
<a href="{{ url_for('download_rectangular_badges', event_id=event.id) }}" class="btn btn-secondary"><i data-lucide="tag"></i> Rectangular Labels</a>
|
||||
<a href="{{ url_for('download_attendees_excel', event_id=event.id) }}" class="btn btn-secondary"><i data-lucide="download"></i> {{ 'download_excel'|t }}</a>
|
||||
</div>
|
||||
|
||||
{% if attendees %}
|
||||
<form method="POST" action="{{ url_for('batch_assign_attendee_type', event_id=event.id) }}" id="batch-assign-form">
|
||||
<div style="margin-bottom: 15px; padding: 10px; background: #f8f9fa; border-radius: 5px; display: flex; gap: 10px; align-items: center;">
|
||||
<input type="checkbox" id="select-all-attendees" style="width: auto;">
|
||||
<label for="select-all-attendees" style="margin: 0; font-weight: 500;">{{ 'select_all'|t }}</label>
|
||||
<span style="margin-left: 20px;">{{ 'assign_type'|t }}:</span>
|
||||
<select name="attendee_type_id" id="batch-type-select" style="padding: 5px 10px;">
|
||||
<div class="batch-select-bar">
|
||||
<input type="checkbox" id="select-all-attendees">
|
||||
<label for="select-all-attendees" class="m-0 font-semibold">{{ 'select_all'|t }}</label>
|
||||
<span class="ml-4">{{ 'assign_type'|t }}:</span>
|
||||
<select name="attendee_type_id" id="batch-type-select" class="batch-type-select">
|
||||
<option value="">{{ 'no_type'|t }}</option>
|
||||
{% for at in attendee_types %}
|
||||
<option value="{{ at.id }}">{{ at.name }} {% if at.price and at.price > 0 %}({{ at.price|format_currency }}){% endif %}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<button type="submit" class="btn btn-sm btn-primary">{{ 'apply'|t }}</button>
|
||||
<span id="selected-count" style="color: #888; font-size: 12px;"></span>
|
||||
<span id="selected-count" class="text-muted text-sm"></span>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<table class="attendees-table" id="attendees-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 40px;"></th>
|
||||
<th data-sort="name" class="sortable">{{ 'name'|t }} <span class="sort-icon"></span></th>
|
||||
<th class="checkbox-col"></th>
|
||||
<th data-sort="first_name" class="sortable">{{ 'first_name'|t }} <span class="sort-icon"></span></th>
|
||||
<th data-sort="last_name" class="sortable">{{ 'last_name'|t }} <span class="sort-icon"></span></th>
|
||||
<th data-sort="organisation" class="sortable">{{ 'organisation'|t }} <span class="sort-icon"></span></th>
|
||||
<th data-sort="role" class="sortable">{{ 'role'|t }} <span class="sort-icon"></span></th>
|
||||
<th data-sort="type" class="sortable">{{ 'type'|t }} <span class="sort-icon"></span></th>
|
||||
<th data-sort="status" class="sortable">{{ 'status'|t }} <span class="sort-icon"></span></th>
|
||||
<th data-sort="attendee_type_id" class="sortable">{{ 'type'|t }} <span class="sort-icon"></span></th>
|
||||
<th data-sort="checked_in" class="sortable">{{ 'status'|t }} <span class="sort-icon"></span></th>
|
||||
<th>{{ 'actions'|t }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="attendees-tbody">
|
||||
{% for attendee in attendees %}
|
||||
<tr data-name="{{ attendee.first_name }} {{ attendee.last_name }}" data-organisation="{{ attendee.organisation or '' }}" data-role="{{ attendee.role or '' }}" data-status="{{ attendee.checked_in }}" data-type="{{ attendee.attendee_type_name or '' }}">
|
||||
<tr data-first-name="{{ attendee.first_name }}" data-last-name="{{ attendee.last_name }}" data-organisation="{{ attendee.organisation or '' }}" data-role="{{ attendee.role or '' }}" data-status="{{ attendee.checked_in }}" data-type="{{ attendee.attendee_type_name or '' }}">
|
||||
<td>
|
||||
<input type="checkbox" name="attendee_ids" value="{{ attendee.id }}" class="attendee-checkbox" form="batch-assign-form">
|
||||
</td>
|
||||
<td>{{ attendee.first_name }} {{ attendee.last_name }}</td>
|
||||
<td>{{ attendee.first_name }}</td>
|
||||
<td>{{ attendee.last_name }}</td>
|
||||
<td>{{ attendee.organisation if attendee.organisation else '-' }}</td>
|
||||
<td>{{ attendee.role if attendee.role else '-' }}</td>
|
||||
<td>
|
||||
{% if attendee.attendee_type_name %}
|
||||
<span class="badge" style="background: #3498db; color: white;">{{ attendee.attendee_type_name }}</span>
|
||||
<span class="badge badge-info">{{ attendee.attendee_type_name }}</span>
|
||||
{% else %}
|
||||
<span style="color: #aaa;">-</span>
|
||||
<span class="text-light">-</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if attendee.checked_in %}
|
||||
<span class="badge badge-success">{{ 'checked_in'|t }}</span>
|
||||
<span class="badge badge-success"><i data-lucide="check"></i> {{ 'checked_in'|t }}</span>
|
||||
{% else %}
|
||||
<span class="badge badge-pending">{{ 'not_checked_in'|t }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if not attendee.checked_in %}
|
||||
<button class="btn btn-sm btn-success checkin-btn" data-attendee-id="{{ attendee.id }}">{{ 'check_in'|t }}</button>
|
||||
<button class="btn btn-sm btn-success checkin-btn" data-attendee-id="{{ attendee.id }}"><i data-lucide="user-check"></i></button>
|
||||
{% else %}
|
||||
<span class="text-muted">{{ 'checked_in'|t }}</span>
|
||||
<span class="text-muted"><i data-lucide="check"></i></span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% if total_pages > 1 %}
|
||||
<div class="pagination mt-4" id="attendees-pagination">
|
||||
<button type="button" class="btn btn-sm btn-outline" onclick="loadAttendeesPage({{ page - 1 }})" {% if page <= 1 %}disabled{% endif %}>
|
||||
<i data-lucide="chevron-left"></i>
|
||||
</button>
|
||||
<span class="pagination-info">{{ 'page'|t }} <span id="attendees-current-page">{{ page }}</span> {{ 'of'|t }} {{ total_pages }}</span>
|
||||
<button type="button" class="btn btn-sm btn-outline" onclick="loadAttendeesPage({{ page + 1 }})" {% if page >= total_pages %}disabled{% endif %}>
|
||||
<i data-lucide="chevron-right"></i>
|
||||
</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<p class="no-attendees">{{ 'no_attendees_yet'|t }}</p>
|
||||
{% endif %}
|
||||
@@ -451,14 +324,18 @@ th.sort-desc .sort-icon::before {
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
lucide.createIcons();
|
||||
});
|
||||
|
||||
document.getElementById('add-staff-btn').addEventListener('click', function() {
|
||||
document.getElementById('add-staff-form').style.display = 'block';
|
||||
this.style.display = 'none';
|
||||
document.getElementById('add-staff-form').classList.add('shown');
|
||||
this.classList.add('d-none');
|
||||
});
|
||||
|
||||
document.getElementById('cancel-staff-btn').addEventListener('click', function() {
|
||||
document.getElementById('add-staff-form').style.display = 'none';
|
||||
document.getElementById('add-staff-btn').style.display = 'inline-block';
|
||||
document.getElementById('add-staff-form').classList.remove('shown');
|
||||
document.getElementById('add-staff-btn').classList.remove('d-none');
|
||||
});
|
||||
|
||||
document.getElementById('staff-inline-form').addEventListener('submit', function(e) {
|
||||
@@ -476,13 +353,10 @@ document.getElementById('staff-inline-form').addEventListener('submit', function
|
||||
location.reload();
|
||||
}
|
||||
}).catch(error => {
|
||||
alert('{{ "error_adding_staff"|t }}');
|
||||
showToast('{{ "error_adding_staff"|t }}', 'error');
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.querySelectorAll('.checkin-btn').forEach(btn => {
|
||||
btn.addEventListener('click', async function() {
|
||||
const attendeeId = this.dataset.attendeeId;
|
||||
@@ -494,89 +368,51 @@ document.querySelectorAll('.checkin-btn').forEach(btn => {
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
// Update status cell (6th td) and remove checkin button
|
||||
const row = this.closest('tr');
|
||||
row.querySelector('td:nth-child(6)').innerHTML = '<span class="badge badge-success">{{ "checked_in"|t }}</span>';
|
||||
row.querySelector('td:nth-child(6)').innerHTML = '<span class="badge badge-success"><i data-lucide="check"></i> {{ "checked_in"|t }}</span>';
|
||||
this.remove();
|
||||
document.getElementById('attendee-count').textContent = parseInt(document.getElementById('attendee-count').textContent) + 1;
|
||||
lucide.createIcons();
|
||||
}
|
||||
} catch (error) {
|
||||
alert('{{ "error_checking_in"|t }}');
|
||||
showToast('{{ "error_checking_in"|t }}', 'error');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Attendee table sorting
|
||||
const sortableHeaders = document.querySelectorAll('#attendees-table th.sortable');
|
||||
let currentSort = { column: null, direction: 'asc' };
|
||||
// Attendee table sorting - AJAX-based with backend sorting
|
||||
let currentAttendeeSort = { column: 'first_name', direction: 'asc' };
|
||||
|
||||
const sortableHeaders = document.querySelectorAll('#attendees-table th.sortable');
|
||||
sortableHeaders.forEach(th => {
|
||||
th.addEventListener('click', () => {
|
||||
const column = th.dataset.sort;
|
||||
const direction = currentSort.column === column && currentSort.direction === 'asc' ? 'desc' : 'asc';
|
||||
currentSort = { column, direction };
|
||||
if (!column) return;
|
||||
const direction = currentAttendeeSort.column === column && currentAttendeeSort.direction === 'asc' ? 'desc' : 'asc';
|
||||
currentAttendeeSort = { column, direction };
|
||||
|
||||
sortableHeaders.forEach(h => h.classList.remove('sort-asc', 'sort-desc'));
|
||||
th.classList.add(direction === 'asc' ? 'sort-asc' : 'sort-desc');
|
||||
|
||||
const tbody = document.getElementById('attendees-tbody');
|
||||
const rows = Array.from(tbody.querySelectorAll('tr'));
|
||||
|
||||
rows.sort((a, b) => {
|
||||
let valA = a.dataset[column] || '';
|
||||
let valB = b.dataset[column] || '';
|
||||
|
||||
if (column === 'status') {
|
||||
valA = valA === 'True' ? 1 : 0;
|
||||
valB = valB === 'True' ? 1 : 0;
|
||||
} else {
|
||||
valA = valA.toLowerCase();
|
||||
valB = valB.toLowerCase();
|
||||
}
|
||||
|
||||
if (valA < valB) return direction === 'asc' ? -1 : 1;
|
||||
if (valA > valB) return direction === 'asc' ? 1 : -1;
|
||||
return 0;
|
||||
});
|
||||
|
||||
rows.forEach(row => tbody.appendChild(row));
|
||||
loadAttendeesPage(1);
|
||||
});
|
||||
});
|
||||
|
||||
// Staff table sorting
|
||||
const staffSortableHeaders = document.querySelectorAll('#event-staff-table th.sortable');
|
||||
let staffCurrentSort = { column: null, direction: 'asc' };
|
||||
// Staff table sorting - AJAX-based with backend sorting
|
||||
let currentStaffSort = { column: 'first_name', direction: 'asc' };
|
||||
|
||||
const staffSortableHeaders = document.querySelectorAll('#event-staff-table th.sortable');
|
||||
staffSortableHeaders.forEach(th => {
|
||||
th.addEventListener('click', () => {
|
||||
const column = th.dataset.sort;
|
||||
const direction = staffCurrentSort.column === column && staffCurrentSort.direction === 'asc' ? 'desc' : 'asc';
|
||||
staffCurrentSort = { column, direction };
|
||||
if (!column) return;
|
||||
const direction = currentStaffSort.column === column && currentStaffSort.direction === 'asc' ? 'desc' : 'asc';
|
||||
currentStaffSort = { column, direction };
|
||||
|
||||
staffSortableHeaders.forEach(h => h.classList.remove('sort-asc', 'sort-desc'));
|
||||
th.classList.add(direction === 'asc' ? 'sort-asc' : 'sort-desc');
|
||||
|
||||
const tbody = document.getElementById('event-staff-tbody');
|
||||
const rows = Array.from(tbody.querySelectorAll('tr'));
|
||||
|
||||
rows.sort((a, b) => {
|
||||
let valA = a.dataset[column] || '';
|
||||
let valB = b.dataset[column] || '';
|
||||
|
||||
if (column === 'status') {
|
||||
valA = valA === '' ? 1 : 0;
|
||||
valB = valB === '' ? 1 : 0;
|
||||
} else {
|
||||
valA = valA.toLowerCase();
|
||||
valB = valB.toLowerCase();
|
||||
}
|
||||
|
||||
if (valA < valB) return direction === 'asc' ? -1 : 1;
|
||||
if (valA > valB) return direction === 'asc' ? 1 : -1;
|
||||
return 0;
|
||||
});
|
||||
|
||||
rows.forEach(row => tbody.appendChild(row));
|
||||
loadStaffPage(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -584,13 +420,46 @@ function showDeleteModal(staffId, staffName, staffEmail) {
|
||||
document.getElementById('staffName').textContent = staffName;
|
||||
document.getElementById('staffEmail').textContent = staffEmail;
|
||||
document.getElementById('staffDeleteForm').action = '{{ url_for("delete_staff", event_id=event.id, staff_id=0) }}'.replace('0', staffId);
|
||||
document.getElementById('staffDeleteModal').style.display = 'flex';
|
||||
document.getElementById('staffDeleteModal').classList.add('active');
|
||||
}
|
||||
|
||||
function closeStaffDeleteModal() {
|
||||
document.getElementById('staffDeleteModal').style.display = 'none';
|
||||
document.getElementById('staffDeleteModal').classList.remove('active');
|
||||
}
|
||||
|
||||
document.getElementById('staffDeleteModal').addEventListener('click', function(e) {
|
||||
if (e.target === this) {
|
||||
closeStaffDeleteModal();
|
||||
}
|
||||
});
|
||||
|
||||
// Reminder button handler
|
||||
document.querySelectorAll('.remind-btn').forEach(btn => {
|
||||
btn.addEventListener('click', async function() {
|
||||
const staffId = this.dataset.staffId;
|
||||
const countSpan = this.querySelector('.reminder-count');
|
||||
|
||||
try {
|
||||
const response = await fetch(`{{ url_for('send_staff_reminder', event_id=event.id, staff_id=0) }}`.replace('0', staffId), {
|
||||
method: 'POST'
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
countSpan.textContent = data.reminder_count;
|
||||
// Update the data attribute with new dates
|
||||
this.dataset.reminderDates = JSON.stringify(data.reminder_dates);
|
||||
showToast('{{ "reminder_sent"|t }}', 'success');
|
||||
} else {
|
||||
showToast(data.error || '{{ "error_sending_reminder"|t }}', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
showToast('{{ "error_sending_reminder"|t }}', 'error');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Select all attendees checkbox
|
||||
document.getElementById('select-all-attendees').addEventListener('change', function() {
|
||||
const checkboxes = document.querySelectorAll('.attendee-checkbox');
|
||||
@@ -604,49 +473,175 @@ document.querySelectorAll('.attendee-checkbox').forEach(cb => {
|
||||
|
||||
function updateSelectedCount() {
|
||||
const selected = document.querySelectorAll('.attendee-checkbox:checked').length;
|
||||
document.getElementById('selected-count').textContent = selected > 0 ? `(${selected} {{ 'selected'|t }})` : '';
|
||||
document.getElementById('selected-count').textContent = selected > 0 ? `(${selected} selected)` : '';
|
||||
}
|
||||
|
||||
// Copy link function
|
||||
function copyLink(url) {
|
||||
// Try modern clipboard API first
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
navigator.clipboard.writeText(url).then(function() {
|
||||
alert('{{ 'link_copied'|t }}');
|
||||
}, function(err) {
|
||||
// Fallback for HTTP sites
|
||||
fallbackCopy(url);
|
||||
});
|
||||
} else {
|
||||
// Fallback for older browsers or HTTP sites
|
||||
fallbackCopy(url);
|
||||
}
|
||||
}
|
||||
|
||||
function fallbackCopy(url) {
|
||||
var textArea = document.createElement('textarea');
|
||||
textArea.value = url;
|
||||
textArea.style.position = 'fixed';
|
||||
textArea.style.left = '-9999px';
|
||||
textArea.style.top = '-9999px';
|
||||
document.body.appendChild(textArea);
|
||||
textArea.focus();
|
||||
textArea.select();
|
||||
try {
|
||||
var successful = document.execCommand('copy');
|
||||
if (successful) {
|
||||
alert('{{ 'link_copied'|t }}');
|
||||
} else {
|
||||
alert('{{ 'copy_failed'|t }}');
|
||||
}
|
||||
} catch (err) {
|
||||
alert('{{ 'copy_failed'|t }}');
|
||||
}
|
||||
document.body.removeChild(textArea);
|
||||
}
|
||||
|
||||
// Initial selected count
|
||||
updateSelectedCount();
|
||||
|
||||
// Staff pagination via AJAX
|
||||
function loadStaffPage(pageNum) {
|
||||
const currentUrl = new URL(window.location.href);
|
||||
currentUrl.searchParams.set('staff_page', pageNum);
|
||||
currentUrl.searchParams.set('staff_sort_by', currentStaffSort.column);
|
||||
currentUrl.searchParams.set('staff_sort_order', currentStaffSort.direction);
|
||||
|
||||
fetch(currentUrl.pathname + '?' + currentUrl.searchParams.toString(), {
|
||||
headers: { 'Accept': 'text/html' }
|
||||
})
|
||||
.then(response => response.text())
|
||||
.then(html => {
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(html, 'text/html');
|
||||
|
||||
const staffSection = doc.querySelector('.staff-section .section-box');
|
||||
const staffPagination = doc.querySelector('#staff-pagination');
|
||||
|
||||
if (staffSection && staffPagination) {
|
||||
const currentStaffSection = document.querySelector('.staff-section .section-box');
|
||||
currentStaffSection.innerHTML = staffSection.innerHTML;
|
||||
|
||||
const currentPagination = document.querySelector('#staff-pagination');
|
||||
if (currentPagination) {
|
||||
currentPagination.innerHTML = staffPagination.innerHTML;
|
||||
}
|
||||
|
||||
lucide.createIcons();
|
||||
|
||||
// Re-attach staff sort handlers
|
||||
const newStaffSortableHeaders = document.querySelectorAll('#event-staff-table th.sortable');
|
||||
newStaffSortableHeaders.forEach(th => {
|
||||
th.addEventListener('click', () => {
|
||||
const column = th.dataset.sort;
|
||||
if (!column) return;
|
||||
const direction = currentStaffSort.column === column && currentStaffSort.direction === 'asc' ? 'desc' : 'asc';
|
||||
currentStaffSort = { column, direction };
|
||||
|
||||
newStaffSortableHeaders.forEach(h => h.classList.remove('sort-asc', 'sort-desc'));
|
||||
th.classList.add(direction === 'asc' ? 'sort-asc' : 'sort-desc');
|
||||
|
||||
loadStaffPage(1);
|
||||
});
|
||||
});
|
||||
|
||||
// Re-attach reminder handlers
|
||||
document.querySelectorAll('.remind-btn').forEach(btn => {
|
||||
btn.addEventListener('click', async function() {
|
||||
const staffId = this.dataset.staffId;
|
||||
const countSpan = this.querySelector('.reminder-count');
|
||||
|
||||
try {
|
||||
const response = await fetch(`{{ url_for('send_staff_reminder', event_id=event.id, staff_id=0) }}`.replace('0', staffId), {
|
||||
method: 'POST'
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
countSpan.textContent = data.reminder_count;
|
||||
this.dataset.reminderDates = JSON.stringify(data.reminder_dates);
|
||||
showToast('{{ "reminder_sent"|t }}', 'success');
|
||||
} else {
|
||||
showToast(data.error || '{{ "error_sending_reminder"|t }}', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
showToast('{{ "error_sending_reminder"|t }}', 'error');
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
window.location.href = currentUrl.toString();
|
||||
});
|
||||
}
|
||||
|
||||
// Attendees pagination via AJAX
|
||||
function loadAttendeesPage(pageNum) {
|
||||
const currentUrl = new URL(window.location.href);
|
||||
currentUrl.searchParams.set('page', pageNum);
|
||||
currentUrl.searchParams.set('sort_by', currentAttendeeSort.column);
|
||||
currentUrl.searchParams.set('sort_order', currentAttendeeSort.direction);
|
||||
|
||||
fetch(currentUrl.pathname + '?' + currentUrl.searchParams.toString(), {
|
||||
headers: { 'Accept': 'text/html' }
|
||||
})
|
||||
.then(response => response.text())
|
||||
.then(html => {
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(html, 'text/html');
|
||||
|
||||
const attendeesSection = doc.querySelector('.attendees-section .section-box');
|
||||
const attendeesPagination = doc.querySelector('#attendees-pagination');
|
||||
|
||||
if (attendeesSection && attendeesPagination) {
|
||||
const currentAttendeesSection = document.querySelector('.attendees-section .section-box');
|
||||
currentAttendeesSection.innerHTML = attendeesSection.innerHTML;
|
||||
|
||||
const currentPagination = document.querySelector('#attendees-pagination');
|
||||
if (currentPagination) {
|
||||
currentPagination.innerHTML = attendeesPagination.innerHTML;
|
||||
}
|
||||
|
||||
lucide.createIcons();
|
||||
|
||||
// Re-attach attendee sort handlers
|
||||
const newSortableHeaders = document.querySelectorAll('#attendees-table th.sortable');
|
||||
newSortableHeaders.forEach(th => {
|
||||
th.addEventListener('click', () => {
|
||||
const column = th.dataset.sort;
|
||||
if (!column) return;
|
||||
const direction = currentAttendeeSort.column === column && currentAttendeeSort.direction === 'asc' ? 'desc' : 'asc';
|
||||
currentAttendeeSort = { column, direction };
|
||||
|
||||
newSortableHeaders.forEach(h => h.classList.remove('sort-asc', 'sort-desc'));
|
||||
th.classList.add(direction === 'asc' ? 'sort-asc' : 'sort-desc');
|
||||
|
||||
loadAttendeesPage(1);
|
||||
});
|
||||
});
|
||||
|
||||
// Re-attach checkin handlers
|
||||
document.querySelectorAll('.checkin-btn').forEach(btn => {
|
||||
btn.addEventListener('click', async function() {
|
||||
const attendeeId = this.dataset.attendeeId;
|
||||
const eventId = {{ event.id }};
|
||||
|
||||
try {
|
||||
const response = await fetch(`/organizer/event/${eventId}/checkin/${attendeeId}`, {
|
||||
method: 'POST'
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const row = this.closest('tr');
|
||||
row.querySelector('td:nth-child(7)').innerHTML = '<span class="badge badge-success"><i data-lucide="check"></i> {{ "checked_in"|t }}</span>';
|
||||
this.remove();
|
||||
document.getElementById('attendee-count').textContent = parseInt(document.getElementById('attendee-count').textContent) + 1;
|
||||
lucide.createIcons();
|
||||
}
|
||||
} catch (error) {
|
||||
showToast('{{ "error_checking_in"|t }}', 'error');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Re-attach select all handler
|
||||
const selectAllCheckbox = document.getElementById('select-all-attendees');
|
||||
if (selectAllCheckbox) {
|
||||
selectAllCheckbox.addEventListener('change', function() {
|
||||
const checkboxes = document.querySelectorAll('.attendee-checkbox');
|
||||
checkboxes.forEach(cb => cb.checked = this.checked);
|
||||
updateSelectedCount();
|
||||
});
|
||||
}
|
||||
|
||||
document.querySelectorAll('.attendee-checkbox').forEach(cb => {
|
||||
cb.addEventListener('change', updateSelectedCount);
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
window.location.href = currentUrl.toString();
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user