Files
conference/templates/presenter/scan.html
T
paul a3546b4c01 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>
2026-05-26 20:34:48 +00:00

240 lines
9.3 KiB
HTML

{% extends "base.html" %}
{% block title %}{{ 'presenter_scan'|t }} - {{ session_name }} - NetEvents{% endblock %}
{% block content %}
<div class="presenter-scan-page">
<div class="scan-header">
<div class="session-info-header">
<h1>{{ session_name }}</h1>
<p class="session-meta">
<span><i data-lucide="calendar"></i> {{ start_time|localized_date if start_time else '' }}</span>
<span><i data-lucide="clock"></i> {{ start_time|localized_date('%H:%M') if start_time else '' }} - {{ end_time|localized_date('%H:%M') if end_time else '' }}</span>
<span><i data-lucide="map-pin"></i> {{ location or 'TBD' }}</span>
</p>
</div>
<a href="{{ url_for('presenter_dashboard') }}" class="btn btn-outline">
<i data-lucide="arrow-left"></i> {{ 'back_to_dashboard'|t }}
</a>
</div>
<div class="presenter-scan-container">
<div class="scanner-section">
<div class="scanner-box">
<div id="qr-reader" class="qr-reader"></div>
<div id="scan-result" class="scan-result hidden">
<div class="result-icon" id="result-icon"></div>
<div class="result-content">
<h2 id="result-title"></h2>
<p id="result-message"></p>
</div>
<button id="scan-again" class="btn btn-primary">
<i data-lucide="scan-line"></i> {{ 'scan_again'|t }}
</button>
</div>
</div>
</div>
<div class="stats-section">
<div class="stat-card">
<span class="stat-number" id="total-count">{{ attendees|length }}</span>
<span class="stat-label">{{ 'total_registered'|t }}</span>
</div>
<div class="stat-card stat-card-success">
<span class="stat-number" id="checked-in-count">{{ checked_in_count }}</span>
<span class="stat-label">{{ 'checked_in'|t }}</span>
</div>
<div class="stat-card stat-card-warning">
<span class="stat-number" id="remaining-count">{{ attendees|length - checked_in_count }}</span>
<span class="stat-label">{{ 'remaining'|t }}</span>
</div>
</div>
<div class="attendees-list-section">
<h3>{{ 'attendees'|t }}</h3>
<div class="attendees-scroll-list">
<ul id="attendees-list" class="attendees-checkin-list">
{% for attendee in attendees %}
<li class="attendee-item {% if attendee.checked_in %}checked-in{% endif %}" data-id="{{ attendee.id }}" data-code="{{ attendee.attendee_code or '' }}">
<div class="attendee-info">
<span class="attendee-avatar">{{ attendee.first_name[0] }}{{ attendee.last_name[0] }}</span>
<div class="attendee-details">
<span class="attendee-name">{{ attendee.first_name }} {{ attendee.last_name }}</span>
<span class="attendee-email">{{ attendee.email }}</span>
</div>
</div>
<div class="attendee-status">
{% if attendee.checked_in %}
<span class="status-badge status-checked-in">
<i data-lucide="check-circle"></i> {{ 'checked_in'|t }}
</span>
{% else %}
<span class="status-badge status-pending">
<i data-lucide="clock"></i> {{ 'pending'|t }}
</span>
{% endif %}
</div>
</li>
{% else %}
<li class="empty-state-item">
<p>{{ 'no_attendees_registered'|t }}</p>
</li>
{% endfor %}
</ul>
</div>
</div>
</div>
</div>
<script src="https://unpkg.com/html5-qrcode@2.3.8/html5-qrcode.min.js"></script>
<script>
const breakoutSessionId = {{ breakout_session_id }};
const attendeesData = {{ attendees|tojson }};
let html5QrCode;
let scanning = true;
function updateStats() {
const items = document.querySelectorAll('.attendee-item');
const total = items.length;
const checkedIn = document.querySelectorAll('.attendee-item.checked-in').length;
document.getElementById('total-count').textContent = total;
document.getElementById('checked-in-count').textContent = checkedIn;
document.getElementById('remaining-count').textContent = total - checkedIn;
}
function markAttendeeCheckedIn(attendeeId) {
const item = document.querySelector(`.attendee-item[data-id="${attendeeId}"]`);
if (item) {
item.classList.add('checked-in');
const badge = item.querySelector('.status-badge');
badge.className = 'status-badge status-checked-in';
badge.innerHTML = '<i data-lucide="check-circle"></i> {{ "checked_in"|t }}';
lucide.createIcons();
// Move to top of list
const list = document.getElementById('attendees-list');
list.insertBefore(item, list.firstChild);
}
updateStats();
}
async function checkInAttendee(attendeeId) {
try {
const response = await fetch(`/presenter/session/${breakoutSessionId}/checkin/${attendeeId}`, {
method: 'POST'
});
const data = await response.json();
const resultDiv = document.getElementById('scan-result');
const icon = document.getElementById('result-icon');
const title = document.getElementById('result-title');
const message = document.getElementById('result-message');
icon.className = 'result-icon';
icon.innerHTML = '';
if (data.success) {
document.getElementById('qr-reader').style.display = 'none';
resultDiv.classList.remove('hidden');
if (data.already_checked_in) {
icon.classList.add('warning');
icon.innerHTML = '<i data-lucide="clock"></i>';
title.textContent = '{{ "already_checked_in"|t }}';
message.textContent = `${data.attendee_name} {{ "was_already_checked_in"|t }}`;
} else {
icon.classList.add('success');
icon.innerHTML = '<i data-lucide="check-circle"></i>';
title.textContent = '{{ "checked_in"|t }}!';
message.textContent = `{{ "success_checked_in"|t }} ${data.attendee_name}`;
markAttendeeCheckedIn(attendeeId);
}
} else {
icon.classList.add('error');
icon.innerHTML = '<i data-lucide="x-circle"></i>';
title.textContent = '{{ "error"|t }}';
message.textContent = data.error || '{{ "failed_checkin"|t }}';
}
lucide.createIcons();
scanning = false;
} catch (error) {
console.error('Check-in error:', error);
showToast('{{ "error_checking_in"|t }}', 'error');
}
}
function findAttendeeByCode(code) {
return attendeesData.find(a => a.attendee_code && a.attendee_code.toUpperCase() === code.toUpperCase());
}
function onScanSuccess(decodedText) {
if (!scanning) return;
// Try NETEVENT format: NETEVENT:eventId:attendeeId
const parts = decodedText.split(':');
if (parts.length === 3 && parts[0] === 'NETEVENT') {
const attendeeId = parseInt(parts[2]);
if (!isNaN(attendeeId)) {
const attendee = attendeesData.find(a => a.id === attendeeId);
if (attendee) {
checkInAttendee(attendeeId);
return;
}
}
}
// Try plain 10-char attendee code
const match = decodedText.match(/^[A-Z0-9]{10}$/i);
if (match) {
const code = match[0].toUpperCase();
const attendee = findAttendeeByCode(code);
if (attendee) {
checkInAttendee(attendee.id);
} else {
showToast('{{ "attendee_not_found"|t }}', 'error');
}
return;
}
// Try legacy URL format: /organizer/event/XX/checkin/YY
const urlMatch = decodedText.match(/\/checkin\/(\d+)/);
if (urlMatch) {
const attendeeId = parseInt(urlMatch[1]);
const attendee = attendeesData.find(a => a.id === attendeeId);
if (attendee) {
checkInAttendee(attendeeId);
return;
}
}
// QR format not recognized
}
function startScanner() {
html5QrCode = new Html5Qrcode("qr-reader");
html5QrCode.start(
{ facingMode: "environment" },
{
fps: 10,
qrbox: { width: 250, height: 250 }
},
onScanSuccess,
(errorMessage) => {}
).catch(err => {
console.error('Camera error:', err);
showToast('{{ "camera_permission_error"|t }}', 'error');
});
}
function resetScanner() {
document.getElementById('qr-reader').style.display = 'block';
document.getElementById('scan-result').classList.add('hidden');
scanning = true;
}
document.getElementById('scan-again').addEventListener('click', resetScanner);
window.addEventListener('DOMContentLoaded', startScanner);
</script>
{% endblock %}