import io import zipfile from datetime import datetime, timedelta from pathlib import Path from uuid import uuid4 from flask import ( Blueprint, abort, render_template, request, redirect, url_for, flash, session, jsonify, send_file, current_app, has_request_context, make_response ) from sqlalchemy import distinct, func, or_ from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import selectinload from app import db, limiter from app.models import User, Event, Guest, CheckIn, AuditLog, SmtpSettings, event_staff, utcnow from app.services.qr_service import generate_qr_for_guest from app.services.email_service import send_invitation, send_test_email from app.services.excel_service import build_guest_import_template, import_guests_from_excel, export_report_to_excel from app.services.ticket_service import generate_ticket_image, generate_preview_ticket from app.services.audit_service import log_action from app.utils.timezone import utc_to_local, local_to_utc from app.utils.paths import event_photo_folder, event_qr_folder, event_logo_folder, event_tickets_folder, ensure_event_folders, safe_child_path from app.utils.security import ( ALLOWED_IMAGE_EXTENSIONS, image_extension, safe_upload_filename, save_validated_image, validate_zip_size, validate_password, ) from app.utils.queue import enqueue_event_invitations main_bp = Blueprint("main", __name__) @main_bp.app_template_filter("localtime") def _localtime_filter(dt, fmt="%d.%m.%Y %H:%M"): if not dt: return "" return utc_to_local(dt).strftime(fmt) @main_bp.app_template_filter("localdate") def _localdate_filter(dt, fmt="%d.%m.%Y"): if not dt: return "" return utc_to_local(dt).strftime(fmt) # --------------------------- # Helpers # --------------------------- def allowed_file(filename, extensions): return "." in filename and filename.rsplit(".", 1)[1].lower() in extensions def _save_image(stream, destination): return save_validated_image( stream, destination, max_bytes=current_app.config["MAX_IMAGE_BYTES"], max_pixels=current_app.config["MAX_IMAGE_PIXELS"], ) def current_user(): if not has_request_context(): return None user_id = session.get("user_id") session_version = session.get("session_version") if user_id: user = db.session.get(User, user_id) if user and user.is_active and user.session_version == session_version: return user # Сессия устарела или пользователь заблокирован logout_user() return None def login_user(user): session["user_id"] = user.id session["role"] = user.role session["session_version"] = user.session_version session.permanent = True def logout_user(): session.pop("user_id", None) session.pop("role", None) session.pop("session_version", None) session.pop("current_event_id", None) session.pop("checkin_event_id", None) def require_role(*roles): def decorator(f): def wrapped(*args, **kwargs): user = current_user() if not user or not user.is_active or user.role not in roles: flash("Требуется авторизация", "warning") return redirect(url_for("main.login")) return f(*args, **kwargs) wrapped.__name__ = f.__name__ return wrapped return decorator def require_login(f): def wrapped(*args, **kwargs): user = current_user() if not user or not user.is_active: flash("Требуется авторизация", "warning") return redirect(url_for("main.login")) return f(*args, **kwargs) wrapped.__name__ = f.__name__ return wrapped def get_current_event(): event_id = session.get("current_event_id") if event_id: user = current_user() event = db.session.get(Event, event_id) if event and user and user.can_manage_event(event): return event return None def require_event(f): """Декоратор для маршрутов, требующих выбранного мероприятия (admin/organizer).""" def wrapped(*args, **kwargs): user = current_user() if not user: flash("Требуется авторизация", "warning") return redirect(url_for("main.login")) if not get_current_event(): flash("Сначала выберите мероприятие", "warning") return redirect(url_for("main.admin_events")) return f(*args, **kwargs) wrapped.__name__ = f.__name__ return wrapped def available_events_for_user(user): if user.is_admin: return Event.query.order_by(Event.created_at.desc()) if user.is_staff: return Event.query.join(event_staff).filter(event_staff.c.user_id == user.id).order_by(Event.created_at.desc()) return Event.query.filter_by(created_by_id=user.id).order_by(Event.created_at.desc()) def can_access_event(user, event): if not user or not event: return False if user.is_admin or user.can_manage_event(event): return True return user.is_staff and any(staff.id == user.id for staff in event.staff_members) def _selected_staff_users(): staff_ids = {value for value in request.form.getlist("staff_ids") if value.isdigit()} if not staff_ids: return [] return User.query.filter(User.id.in_(staff_ids), User.role == "staff", User.is_active == True).all() def require_event_access(event): if not can_access_event(current_user(), event): abort(403) def _setup_available(): return bool(current_app.config.get("ALLOW_WEB_SETUP")) def _guest_search_expression(search): pattern = f"%{search}%" return or_( Guest.full_name.ilike(pattern), Guest.last_name.ilike(pattern), Guest.first_name.ilike(pattern), Guest.middle_name.ilike(pattern), Guest.department.ilike(pattern), Guest.position.ilike(pattern), Guest.email.ilike(pattern), Guest.phone.ilike(pattern), ) def _parse_local_datetime(value): if not value: return None return local_to_utc(datetime.fromisoformat(value)) def _single_line(value, max_length=255): return " ".join(str(value or "").splitlines()).strip()[:max_length] or None def _rsvp_is_open(event): return bool( event and event.is_active and event.rsvp_enabled and (not event.rsvp_deadline or utcnow() <= event.rsvp_deadline) ) def _ics_escape(value): return str(value or "").replace("\\", "\\\\").replace(";", "\\;").replace(",", "\\,").replace("\n", "\\n") def _ics_datetime(value): return value.strftime("%Y%m%dT%H%M%SZ") # --------------------------- # Guest invitation / RSVP # --------------------------- @main_bp.route("/invite/", methods=["GET", "POST"]) @limiter.limit("30 per minute") def public_invitation(token): guest = Guest.query.filter_by(token=token, is_active=True).first_or_404() event = guest.event if not event or not event.is_active: abort(404) rsvp_open = _rsvp_is_open(event) if request.method == "POST": if not rsvp_open: flash("Срок подтверждения участия завершён", "warning") return redirect(url_for("main.public_invitation", token=token)) status = request.form.get("rsvp_status", "").strip() if status not in {"confirmed", "declined"}: flash("Выберите вариант ответа", "danger") return redirect(url_for("main.public_invitation", token=token)) previous_status = guest.rsvp_status guest.rsvp_status = status guest.rsvp_responded_at = utcnow() guest.rsvp_comment = request.form.get("rsvp_comment", "").strip()[:1000] or None guest.dietary_requirements = request.form.get("dietary_requirements", "").strip()[:1000] or None log_action( "guest_rsvp_update", target_type="guest", target_id=guest.id, details=f"RSVP изменён: {previous_status} -> {status}", ) db.session.commit() flash("Ваш ответ сохранён", "success") return redirect(url_for("main.public_invitation", token=token)) return render_template( "invitation.html", guest=guest, event=event, rsvp_open=rsvp_open, ) @main_bp.get("/invite//calendar.ics") @limiter.limit("30 per minute") def public_invitation_calendar(token): guest = Guest.query.filter_by(token=token, is_active=True).first_or_404() event = guest.event if not event or not event.is_active or not event.event_date: abort(404) end_date = event.event_end_date or (event.event_date + timedelta(hours=2)) invitation_url = url_for("main.public_invitation", token=guest.token, _external=True) content = "\r\n".join([ "BEGIN:VCALENDAR", "VERSION:2.0", "PRODID:-//InviteMe//Invitation//RU", "CALSCALE:GREGORIAN", "METHOD:PUBLISH", "BEGIN:VEVENT", f"UID:event-{event.id}-{guest.token}@inviteme", f"DTSTAMP:{_ics_datetime(utcnow())}", f"DTSTART:{_ics_datetime(event.event_date)}", f"DTEND:{_ics_datetime(end_date)}", f"SUMMARY:{_ics_escape(event.name)}", f"DESCRIPTION:{_ics_escape(event.description)}", f"LOCATION:{_ics_escape(event.location)}", f"URL:{_ics_escape(invitation_url)}", "END:VEVENT", "END:VCALENDAR", "", ]) response = make_response(content) response.headers["Content-Type"] = "text/calendar; charset=utf-8" response.headers["Content-Disposition"] = 'attachment; filename="invitation.ics"' return response @main_bp.get("/invite//ticket") @limiter.limit("20 per minute") def public_invitation_ticket(token): guest = Guest.query.filter_by(token=token, is_active=True).first_or_404() if not guest.event or not guest.event.is_active: abort(404) ticket_path = generate_ticket_image(guest, guest.event) return send_file( str(ticket_path), as_attachment=True, download_name=f"InviteMe-{guest.token}.png", mimetype="image/png", ) # --------------------------- # Auth # --------------------------- def _is_first_run(): """True, если в базе ещё нет ни одного пользователя.""" return User.query.first() is None @main_bp.route("/setup", methods=["GET", "POST"]) @limiter.limit("5 per hour", methods=["POST"]) def setup(): """Первоначальная настройка: создание первого администратора.""" if not _setup_available(): abort(404) if not _is_first_run(): return redirect(url_for("main.login")) if request.method == "POST": username = request.form.get("username", "").strip() password = request.form.get("password", "").strip() password_confirm = request.form.get("password_confirm", "").strip() if not username or not password: flash("Логин и пароль обязательны", "danger") return redirect(url_for("main.setup")) if password != password_confirm: flash("Пароли не совпадают", "danger") return redirect(url_for("main.setup")) password_error = validate_password(password, current_app.config["MIN_PASSWORD_LENGTH"]) if password_error: flash(password_error, "danger") return redirect(url_for("main.setup")) if User.query.filter_by(username=username).first(): flash("Пользователь с таким логином уже существует", "danger") return redirect(url_for("main.setup")) admin = User(username=username, role="admin", is_active=True) admin.set_password(password) db.session.add(admin) db.session.commit() flash("Первый администратор создан. Войдите в систему.", "success") return redirect(url_for("main.login")) return render_template("setup.html") @main_bp.route("/login", methods=["GET", "POST"]) @limiter.limit("10 per minute", methods=["POST"]) def login(): if _is_first_run(): if _setup_available(): return redirect(url_for("main.setup")) flash("Система не настроена. Создайте администратора через командную строку.", "warning") return render_template("login.html"), 503 if request.method == "POST": username = request.form.get("username", "").strip() password = request.form.get("password", "").strip() user = User.query.filter_by(username=username, is_active=True).first() if user and user.check_password(password): login_user(user) log_action("login", target_type="user", target_id=user.id, details="Успешный вход") db.session.commit() if user.is_staff: return redirect(url_for("main.check_in")) return redirect(url_for("main.admin_panel")) else: flash("Неверный логин или пароль", "danger") return render_template("login.html") @main_bp.route("/logout", methods=["POST"]) @require_login def logout(): log_action("logout") db.session.commit() logout_user() flash("Вы вышли из системы", "info") return redirect(url_for("main.login")) # --------------------------- # Events # --------------------------- @main_bp.route("/admin/events") @require_role("admin", "organizer") def admin_events(): user = current_user() events = available_events_for_user(user).all() for event in events: event.total_guests = Guest.query.filter_by(event_id=event.id, is_active=True).count() event.checked_in = db.session.query(func.count(distinct(CheckIn.guest_id))).filter(CheckIn.event_id == event.id).scalar() return render_template("admin/events.html", events=events) @main_bp.route("/admin/events/add", methods=["GET", "POST"]) @require_role("admin", "organizer") def admin_event_add(): if request.method == "POST": name = request.form.get("name", "").strip() description = request.form.get("description", "").strip() location = request.form.get("location", "").strip() event_date_str = request.form.get("event_date", "").strip() event_end_date_str = request.form.get("event_end_date", "").strip() rsvp_deadline_str = request.form.get("rsvp_deadline", "").strip() if not name: flash("Название мероприятия обязательно", "danger") return redirect(url_for("main.admin_event_add")) try: event_date = _parse_local_datetime(event_date_str) event_end_date = _parse_local_datetime(event_end_date_str) rsvp_deadline = _parse_local_datetime(rsvp_deadline_str) except ValueError: flash("Неверный формат даты", "danger") return redirect(url_for("main.admin_event_add")) if event_date and event_end_date and event_end_date <= event_date: flash("Время окончания должно быть позже начала", "danger") return redirect(url_for("main.admin_event_add")) event = Event( name=name, description=description, location=location, event_date=event_date, event_end_date=event_end_date, rsvp_enabled=bool(request.form.get("rsvp_enabled")), rsvp_deadline=rsvp_deadline, invitation_subject=_single_line(request.form.get("invitation_subject")), invitation_message=request.form.get("invitation_message", "").strip()[:5000] or None, created_by_id=current_user().id, ) db.session.add(event) event.staff_members = _selected_staff_users() log_action("event_create", target_type="event", target_id=event.id, details=f"Создано мероприятие '{event.name}'") db.session.commit() flash(f"Мероприятие «{event.name}» создано", "success") return redirect(url_for("main.admin_events")) staff_users = User.query.filter_by(role="staff", is_active=True).order_by(User.username).all() return render_template("admin/event_form.html", event=None, staff_users=staff_users) @main_bp.route("/admin/events//edit", methods=["GET", "POST"]) @require_role("admin", "organizer") def admin_event_edit(event_id): event = Event.query.get_or_404(event_id) user = current_user() if not user.can_manage_event(event): flash("Нет доступа к этому мероприятию", "danger") return redirect(url_for("main.admin_events")) if request.method == "POST": event.name = request.form.get("name", "").strip() event.description = request.form.get("description", "").strip() event.location = request.form.get("location", "").strip() event_date_str = request.form.get("event_date", "").strip() event_end_date_str = request.form.get("event_end_date", "").strip() rsvp_deadline_str = request.form.get("rsvp_deadline", "").strip() if not event.name: flash("Название мероприятия обязательно", "danger") return redirect(url_for("main.admin_event_edit", event_id=event.id)) try: event.event_date = _parse_local_datetime(event_date_str) event.event_end_date = _parse_local_datetime(event_end_date_str) event.rsvp_deadline = _parse_local_datetime(rsvp_deadline_str) except ValueError: flash("Неверный формат даты", "danger") return redirect(url_for("main.admin_event_edit", event_id=event.id)) if event.event_date and event.event_end_date and event.event_end_date <= event.event_date: flash("Время окончания должно быть позже начала", "danger") return redirect(url_for("main.admin_event_edit", event_id=event.id)) event.is_active = bool(request.form.get("is_active")) event.rsvp_enabled = bool(request.form.get("rsvp_enabled")) event.invitation_subject = _single_line(request.form.get("invitation_subject")) event.invitation_message = request.form.get("invitation_message", "").strip()[:5000] or None event.staff_members = _selected_staff_users() log_action("event_update", target_type="event", target_id=event.id, details=f"Обновлено мероприятие '{event.name}'") db.session.commit() flash("Мероприятие обновлено", "success") return redirect(url_for("main.admin_events")) staff_users = User.query.filter_by(role="staff", is_active=True).order_by(User.username).all() return render_template("admin/event_form.html", event=event, staff_users=staff_users) @main_bp.route("/admin/events//delete", methods=["POST"]) @require_role("admin", "organizer") def admin_event_delete(event_id): event = Event.query.get_or_404(event_id) user = current_user() if not user.can_manage_event(event): flash("Нет доступа к этому мероприятию", "danger") return redirect(url_for("main.admin_events")) if session.get("current_event_id") == event.id: session.pop("current_event_id", None) event.is_active = False log_action("event_deactivate", target_type="event", target_id=event.id, details=f"Деактивировано мероприятие '{event.name}'") db.session.commit() flash("Мероприятие деактивировано", "success") return redirect(url_for("main.admin_events")) @main_bp.route("/admin/events//delete-hard", methods=["POST"]) @require_role("admin") def admin_event_delete_hard(event_id): """Полное удаление мероприятия вместе со связанными файлами и данными.""" event = Event.query.get_or_404(event_id) reason = request.form.get("reason", "").strip() if not reason: flash("Для удаления необходимо указать причину", "danger") return redirect(url_for("main.admin_events")) if session.get("current_event_id") == event.id: session.pop("current_event_id", None) # Удаляем связанные файлы import shutil def _safe_rmtree(path): try: if path.exists(): shutil.rmtree(str(path)) except Exception as e: current_app.logger.warning(f"Не удалось удалить папку {path}: {e}") # Удаляем папки мероприятия целиком _safe_rmtree(event_photo_folder(event.id)) _safe_rmtree(event_qr_folder(event.id)) _safe_rmtree(event_logo_folder(event.id)) _safe_rmtree(event_tickets_folder(event.id)) event_name = event.name log_action( "event_delete_hard", target_type="event", target_id=event.id, details=f"Удалено мероприятие '{event_name}'. Причина: {reason}", ) db.session.delete(event) db.session.commit() flash(f"Мероприятие «{event_name}» и все связанные данные удалены", "success") return redirect(url_for("main.admin_events")) @main_bp.route("/admin/events//switch", methods=["POST"]) @require_role("admin", "organizer") def admin_event_switch(event_id): event = Event.query.get_or_404(event_id) user = current_user() if not user.can_manage_event(event): flash("Нет доступа к этому мероприятию", "danger") return redirect(url_for("main.admin_events")) session["current_event_id"] = event.id flash(f"Выбрано мероприятие: {event.name}", "info") return redirect(url_for("main.admin_dashboard")) @main_bp.route("/admin/events//ticket", methods=["GET", "POST"]) @require_role("admin", "organizer") def admin_event_ticket(event_id): event = Event.query.get_or_404(event_id) user = current_user() if not user.can_manage_event(event): flash("Нет доступа к этому мероприятию", "danger") return redirect(url_for("main.admin_events")) if request.method == "POST": event.ticket_title = request.form.get("ticket_title", "").strip() or event.name event.ticket_subtitle = request.form.get("ticket_subtitle", "").strip() event.ticket_body_text = request.form.get("ticket_body_text", "").strip() event.ticket_background_color = request.form.get("ticket_background_color", "#ffffff").strip() event.ticket_text_color = request.form.get("ticket_text_color", "#000000").strip() event.ticket_accent_color = request.form.get("ticket_accent_color", "#0d6efd").strip() event.ticket_show_qr = bool(request.form.get("ticket_show_qr")) event.ticket_show_photo = bool(request.form.get("ticket_show_photo")) event.ticket_layout = request.form.get("ticket_layout", "portrait").strip() # Logo upload logo_file = request.files.get("logo_file") if logo_file and logo_file.filename: if allowed_file(logo_file.filename, ALLOWED_IMAGE_EXTENSIONS): extension = image_extension(logo_file.filename) filename = f"logo_{uuid4().hex}.{extension}" logo_folder = event_logo_folder(event.id) logo_folder.mkdir(parents=True, exist_ok=True) logo_path = logo_folder / filename try: _save_image(logo_file.stream, logo_path) if event.logo_filename: previous_logo = safe_child_path(logo_folder, event.logo_filename) if previous_logo and previous_logo.exists() and previous_logo != logo_path: previous_logo.unlink() event.logo_filename = filename except ValueError as exc: flash(str(exc), "warning") else: flash("Неверный формат логотипа. Разрешены: JPG, PNG и WebP.", "warning") # Logo deletion if request.form.get("delete_logo") and event.logo_filename: old_logo = safe_child_path(event_logo_folder(event.id), event.logo_filename) if old_logo and old_logo.exists(): old_logo.unlink() event.logo_filename = None log_action("event_ticket_update", target_type="event", target_id=event.id, details=f"Обновлён дизайн билета мероприятия '{event.name}'") db.session.commit() flash("Дизайн билета сохранён", "success") return redirect(url_for("main.admin_event_ticket", event_id=event.id)) return render_template("admin/ticket_constructor.html", event=event) @main_bp.route("/media/events//logo") @require_role("admin", "organizer", "staff") def event_logo(event_id): event = Event.query.get_or_404(event_id) require_event_access(event) if not event.logo_filename: abort(404) logo_path = safe_child_path(event_logo_folder(event.id), event.logo_filename) if not logo_path or not logo_path.is_file(): abort(404) return send_file(logo_path, conditional=True, max_age=300) @main_bp.route("/admin/events//ticket/preview") @require_role("admin", "organizer") def admin_event_ticket_preview(event_id): event = Event.query.get_or_404(event_id) user = current_user() if not user.can_manage_event(event): flash("Нет доступа к этому мероприятию", "danger") return redirect(url_for("main.admin_events")) try: ticket_path = generate_preview_ticket(event) return send_file(str(ticket_path), mimetype="image/png") except Exception as e: flash(f"Ошибка генерации предпросмотра: {str(e)}", "danger") return redirect(url_for("main.admin_event_ticket", event_id=event.id)) # --------------------------- # Admin Dashboard # --------------------------- @main_bp.route("/admin") @require_role("admin", "organizer", "staff") def admin_index(): return redirect(url_for("main.admin_panel")) @main_bp.route("/admin/dashboard") @require_role("admin", "organizer") @require_event def admin_dashboard(): event = get_current_event() total = Guest.query.filter_by(event_id=event.id, is_active=True).count() checked_in = db.session.query(func.count(distinct(CheckIn.guest_id))).filter(CheckIn.event_id == event.id).scalar() not_checked_in = total - checked_in attendance_percent = round((checked_in / total) * 100, 1) if total else 0 rsvp_confirmed = Guest.query.filter_by(event_id=event.id, is_active=True, rsvp_status="confirmed").count() rsvp_declined = Guest.query.filter_by(event_id=event.id, is_active=True, rsvp_status="declined").count() rsvp_pending = max(total - rsvp_confirmed - rsvp_declined, 0) recent_check_ins = ( CheckIn.query .join(Guest) .filter(Guest.event_id == event.id) .order_by(CheckIn.checked_in_at.desc()) .limit(10) .all() ) return render_template( "admin/dashboard.html", event=event, total=total, checked_in=checked_in, not_checked_in=not_checked_in, attendance_percent=attendance_percent, recent_check_ins=recent_check_ins, rsvp_confirmed=rsvp_confirmed, rsvp_declined=rsvp_declined, rsvp_pending=rsvp_pending, ) # --------------------------- # Upload Excel / Photos # --------------------------- @main_bp.route("/admin/upload/template") @require_role("admin", "organizer") def admin_upload_template(): return send_file( build_guest_import_template(), as_attachment=True, download_name="InviteMe_guests_template.xlsx", mimetype="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", ) @main_bp.route("/admin/upload", methods=["GET", "POST"]) @require_role("admin", "organizer") @require_event def admin_upload(): event = get_current_event() if request.method == "POST": # Excel upload excel_file = request.files.get("excel_file") if excel_file and excel_file.filename: if allowed_file(excel_file.filename, {"xlsx"}): try: excel_stream = io.BytesIO(excel_file.read()) validate_zip_size( excel_stream, current_app.config["MAX_ZIP_FILES"], current_app.config["MAX_XLSX_UNCOMPRESSED_BYTES"], ) result = import_guests_from_excel(excel_stream, event_id=event.id) flash( f"Импорт завершён: добавлено {result['imported']}, обновлено {result['updated']} из {result['total']}", "success", ) # Generate QR codes for guests without QR base_url = request.form.get("base_url", "").strip() or None guests = Guest.query.filter_by(event_id=event.id).filter(Guest.qr_filename.is_(None)).all() for guest in guests: generate_qr_for_guest(guest, base_url=base_url) log_action( "guests_import", target_type="event", target_id=event.id, details=f"Импорт гостей из Excel: добавлено {result['imported']}, обновлено {result['updated']}", ) db.session.commit() flash(f"Сгенерировано QR-кодов: {len(guests)}", "success") except Exception as e: flash(f"Ошибка импорта Excel: {str(e)}", "danger") else: flash("Неверный формат Excel-файла", "danger") # Photos upload (ZIP or multiple files) photo_files = request.files.getlist("photo_files") zip_file = request.files.get("zip_file") photo_folder = event_photo_folder(event.id) photo_folder.mkdir(parents=True, exist_ok=True) uploaded_count = 0 if zip_file and zip_file.filename: if allowed_file(zip_file.filename, {"zip"}): try: with zipfile.ZipFile(zip_file.stream, "r") as archive: members = [member for member in archive.infolist() if not member.is_dir()] if len(members) > current_app.config["MAX_ZIP_FILES"]: raise ValueError("В архиве слишком много файлов") total_size = sum(member.file_size for member in members) if total_size > current_app.config["MAX_ZIP_UNCOMPRESSED_BYTES"]: raise ValueError("Архив слишком большой после распаковки") for member in members: try: filename = safe_upload_filename(member.filename) except ValueError: continue if image_extension(filename) not in ALLOWED_IMAGE_EXTENSIONS: continue with archive.open(member, "r") as source: _save_image(source, photo_folder / filename) uploaded_count += 1 flash(f"Из ZIP распаковано файлов: {uploaded_count}", "success") except (ValueError, zipfile.BadZipFile, RuntimeError) as exc: flash(f"Ошибка распаковки ZIP: {exc}", "danger") else: flash("Неверный формат ZIP-архива", "danger") for photo in photo_files: if photo and photo.filename: try: filename = safe_upload_filename(photo.filename) if image_extension(filename) not in ALLOWED_IMAGE_EXTENSIONS: raise ValueError("Разрешены только JPG, PNG и WebP") _save_image(photo.stream, photo_folder / filename) uploaded_count += 1 except ValueError as exc: flash(f"Файл «{photo.filename}» пропущен: {exc}", "warning") if uploaded_count: flash(f"Загружено фото: {uploaded_count}", "success") log_action( "photos_upload", target_type="event", target_id=event.id, details=f"Загружено фото гостей: {uploaded_count}", ) db.session.commit() return redirect(url_for("main.admin_upload")) return render_template("admin/upload.html", event=event) # --------------------------- # Guests management # --------------------------- @main_bp.route("/admin/guests") @require_role("admin", "organizer") @require_event def admin_guests(): event = get_current_event() search = request.args.get("q", "").strip() page = max(request.args.get("page", 1, type=int), 1) query = Guest.query.options(selectinload(Guest.check_ins)).filter_by(event_id=event.id) if search: query = query.filter(_guest_search_expression(search)) pagination = query.order_by(Guest.full_name).paginate(page=page, per_page=50, error_out=False) return render_template("admin/guests.html", event=event, guests=pagination.items, pagination=pagination, search=search) @main_bp.route("/admin/guests/add", methods=["GET", "POST"]) @require_role("admin", "organizer") @require_event def admin_guest_add(): event = get_current_event() if request.method == "POST": last_name = request.form.get("last_name", "").strip() first_name = request.form.get("first_name", "").strip() middle_name = request.form.get("middle_name", "").strip() full_name = " ".join([p for p in [last_name, first_name, middle_name] if p]) if not full_name: flash("Фамилия и имя обязательны", "danger") return redirect(url_for("main.admin_guest_add")) raw_photo_filename = request.form.get("photo_filename", "").strip() try: photo_filename = safe_upload_filename(raw_photo_filename) if raw_photo_filename else "" except ValueError as exc: flash(str(exc), "danger") return redirect(url_for("main.admin_guest_add")) guest = Guest( event_id=event.id, department=request.form.get("department", "").strip(), last_name=last_name, first_name=first_name, middle_name=middle_name, full_name=full_name, position=request.form.get("position", "").strip(), email=request.form.get("email", "").strip(), phone=request.form.get("phone", "").strip(), photo_filename=photo_filename, ) db.session.add(guest) db.session.commit() base_url = request.form.get("base_url", "").strip() or None generate_qr_for_guest(guest, base_url=base_url) log_action( "guest_create", target_type="guest", target_id=guest.id, details=f"Добавлен гость '{guest.build_full_name()}' ({guest.email})", ) db.session.commit() if request.form.get("send_invitation"): try: send_invitation(guest) db.session.commit() flash("Гость добавлен и приглашение отправлено", "success") except Exception as e: db.session.commit() flash(f"Гость добавлен, но не удалось отправить письмо: {str(e)}", "warning") else: flash("Гость добавлен", "success") return redirect(url_for("main.admin_guests")) return render_template("admin/guest_form.html", event=event, guest=None) @main_bp.route("/admin/guests//edit", methods=["GET", "POST"]) @require_role("admin", "organizer") @require_event def admin_guest_edit(guest_id): event = get_current_event() guest = Guest.query.filter_by(id=guest_id, event_id=event.id).first_or_404() if request.method == "POST": guest.department = request.form.get("department", "").strip() guest.last_name = request.form.get("last_name", "").strip() guest.first_name = request.form.get("first_name", "").strip() guest.middle_name = request.form.get("middle_name", "").strip() guest.full_name = " ".join([ p for p in [guest.last_name, guest.first_name, guest.middle_name] if p ]) or guest.full_name guest.position = request.form.get("position", "").strip() guest.email = request.form.get("email", "").strip() guest.phone = request.form.get("phone", "").strip() raw_photo_filename = request.form.get("photo_filename", "").strip() try: guest.photo_filename = safe_upload_filename(raw_photo_filename) if raw_photo_filename else "" except ValueError as exc: flash(str(exc), "danger") return redirect(url_for("main.admin_guest_edit", guest_id=guest.id)) log_action( "guest_update", target_type="guest", target_id=guest.id, details=f"Обновлены данные гостя '{guest.build_full_name()}'", ) db.session.commit() flash("Данные гостя обновлены", "success") return redirect(url_for("main.admin_guests")) return render_template("admin/guest_form.html", event=event, guest=guest) @main_bp.route("/admin/guests//delete", methods=["POST"]) @require_role("admin", "organizer") @require_event def admin_guest_delete(guest_id): event = get_current_event() guest = Guest.query.filter_by(id=guest_id, event_id=event.id).first_or_404() guest.is_active = False log_action( "guest_deactivate", target_type="guest", target_id=guest.id, details=f"Гость '{guest.build_full_name()}' заблокирован", ) db.session.commit() flash("Гость заблокирован (QR-код аннулирован)", "success") return redirect(url_for("main.admin_guests")) @main_bp.route("/admin/guests//resend", methods=["POST"]) @require_role("admin", "organizer") @require_event def admin_guest_resend(guest_id): event = get_current_event() guest = Guest.query.filter_by(id=guest_id, event_id=event.id).first_or_404() if not guest.qr_filename: generate_qr_for_guest(guest) db.session.commit() try: send_invitation(guest) log_action( "guest_resend", target_type="guest", target_id=guest.id, details=f"Приглашение повторно отправлено гостю '{guest.build_full_name()}'", ) db.session.commit() flash("Приглашение повторно отправлено", "success") except Exception as e: log_action( "guest_resend", target_type="guest", target_id=guest.id, details=f"Ошибка повторной отправки приглашения гостю '{guest.build_full_name()}': {e}", ) db.session.commit() flash(f"Ошибка отправки: {str(e)}", "danger") return redirect(url_for("main.admin_guests")) @main_bp.route("/admin/guests//ticket") @require_role("admin", "organizer") @require_event def admin_guest_ticket(guest_id): """Генерирует и отдаёт PNG-билет для конкретного гостя.""" event = get_current_event() guest = Guest.query.filter_by(id=guest_id, event_id=event.id).first_or_404() try: ticket_path = generate_ticket_image(guest, event) log_action( "guest_ticket_view", target_type="guest", target_id=guest.id, details=f"Просмотр билета гостя '{guest.build_full_name()}'", ) db.session.commit() return send_file(str(ticket_path), mimetype="image/png") except Exception as e: flash(f"Ошибка генерации билета: {str(e)}", "danger") return redirect(url_for("main.admin_guests")) @main_bp.route("/admin/guests//qr") @require_role("admin", "organizer") @require_event def admin_guest_qr(guest_id): """Отдаёт QR-код гостя без отправки письма.""" event = get_current_event() guest = Guest.query.filter_by(id=guest_id, event_id=event.id).first_or_404() if not guest.qr_filename: generate_qr_for_guest(guest) db.session.commit() qr_path = safe_child_path(event_qr_folder(guest.event_id), guest.qr_filename) if not qr_path or not qr_path.exists(): flash("QR-код не найден", "danger") return redirect(url_for("main.admin_guests")) log_action( "guest_qr_view", target_type="guest", target_id=guest.id, details=f"Просмотр QR-кода гостя '{guest.build_full_name()}'", ) db.session.commit() return send_file(str(qr_path), mimetype="image/png") @main_bp.route("/media/guests//photo") @require_role("admin", "organizer", "staff") def guest_photo(guest_id): guest = db.get_or_404(Guest, guest_id) require_event_access(guest.event) paths = [path for path in guest._photo_paths() if path.is_file()] if not paths: abort(404) return send_file(paths[0], conditional=True, max_age=300) @main_bp.route("/admin/send-invitations", methods=["POST"]) @require_role("admin", "organizer") @require_event def admin_send_invitations(): event = get_current_event() try: _job, created = enqueue_event_invitations(event.id, current_user().id) flash("Рассылка поставлена в очередь." if created else "Рассылка уже выполняется.", "success" if created else "info") except Exception: current_app.logger.exception("Failed to enqueue event invitations") flash("Очередь рассылки недоступна. Обратитесь к администратору.", "danger") return redirect(url_for("main.admin_dashboard")) # --------------------------- # Reports # --------------------------- @main_bp.route("/admin/report") @require_role("admin", "organizer") @require_event def admin_report(): event = get_current_event() report_path = Path(current_app.config["UPLOAD_FOLDER"]) / "reports" report_path.mkdir(parents=True, exist_ok=True) filename = f"report_{event.id}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx" output_path = report_path / filename try: export_report_to_excel(str(output_path), event_id=event.id, event_name=event.name) log_action( "event_report", target_type="event", target_id=event.id, details=f"Сформирован отчёт по мероприятию '{event.name}'", ) db.session.commit() return send_file(str(output_path), as_attachment=True, download_name=filename) except Exception as e: flash(f"Ошибка формирования отчёта: {str(e)}", "danger") return redirect(url_for("main.admin_dashboard")) # --------------------------- # User management (admin only) # --------------------------- @main_bp.route("/admin/users") @require_role("admin") def admin_users(): users = User.query.order_by(User.created_at.desc()).all() return render_template("admin/users.html", users=users) @main_bp.route("/admin/users/add", methods=["GET", "POST"]) @require_role("admin") def admin_user_add(): if request.method == "POST": username = request.form.get("username", "").strip() password = request.form.get("password", "").strip() role = request.form.get("role", "staff").strip() if not username or not password: flash("Логин и пароль обязательны", "danger") return redirect(url_for("main.admin_user_add")) password_error = validate_password(password, current_app.config["MIN_PASSWORD_LENGTH"]) if password_error: flash(password_error, "danger") return redirect(url_for("main.admin_user_add")) if role not in ("admin", "organizer", "staff"): flash("Неверная роль", "danger") return redirect(url_for("main.admin_user_add")) if User.query.filter_by(username=username).first(): flash("Пользователь с таким логином уже существует", "danger") return redirect(url_for("main.admin_user_add")) user = User(username=username, role=role, is_active=True) user.set_password(password) db.session.add(user) log_action("user_create", target_type="user", target_id=user.id, details=f"Создан пользователь '{username}' ({role})") db.session.commit() flash(f"Пользователь {username} создан", "success") return redirect(url_for("main.admin_users")) return render_template("admin/user_form.html", user=None) @main_bp.route("/admin/users//edit", methods=["GET", "POST"]) @require_role("admin") def admin_user_edit(user_id): user = User.query.get_or_404(user_id) if request.method == "POST": username = request.form.get("username", "").strip() password = request.form.get("password", "").strip() role = request.form.get("role", user.role).strip() is_active = bool(request.form.get("is_active")) if not username: flash("Логин обязателен", "danger") return redirect(url_for("main.admin_user_edit", user_id=user.id)) if role not in ("admin", "organizer", "staff"): flash("Неверная роль", "danger") return redirect(url_for("main.admin_user_edit", user_id=user.id)) existing = User.query.filter(User.username == username, User.id != user.id).first() if existing: flash("Пользователь с таким логином уже существует", "danger") return redirect(url_for("main.admin_user_edit", user_id=user.id)) user.username = username user.role = role user.is_active = is_active if password: password_error = validate_password(password, current_app.config["MIN_PASSWORD_LENGTH"]) if password_error: flash(password_error, "danger") return redirect(url_for("main.admin_user_edit", user_id=user.id)) user.set_password(password) log_action("user_update", target_type="user", target_id=user.id, details=f"Обновлён пользователь '{username}' ({role})") db.session.commit() flash("Пользователь обновлён", "success") return redirect(url_for("main.admin_users")) return render_template("admin/user_form.html", user=user) @main_bp.route("/admin/users//delete", methods=["POST"]) @require_role("admin") def admin_user_delete(user_id): user = User.query.get_or_404(user_id) if user.id == current_user().id: flash("Нельзя удалить самого себя", "danger") return redirect(url_for("main.admin_users")) user.is_active = False log_action("user_deactivate", target_type="user", target_id=user.id, details=f"Пользователь '{user.username}' заблокирован") db.session.commit() flash("Пользователь заблокирован", "success") return redirect(url_for("main.admin_users")) @main_bp.route("/admin/logs") @require_role("admin") def admin_logs(): page = request.args.get("page", 1, type=int) per_page = 50 action_filter = request.args.get("action", "").strip() user_filter = request.args.get("user", "").strip() query = AuditLog.query if action_filter: query = query.filter(AuditLog.action == action_filter) if user_filter: query = query.filter(AuditLog.username.ilike(f"%{user_filter}%")) logs = query.order_by(AuditLog.created_at.desc()).paginate( page=page, per_page=per_page, error_out=False ) actions = db.session.query(AuditLog.action).distinct().order_by(AuditLog.action).all() actions = [a[0] for a in actions] return render_template( "admin/logs.html", logs=logs, actions=actions, action_filter=action_filter, user_filter=user_filter, ) # --------------------------- # Admin panel # --------------------------- @main_bp.route("/admin/panel") @require_role("admin", "organizer", "staff") def admin_panel(): """Единая админ-панель со ссылками на все разделы.""" user = current_user() event = get_current_event() if user and (user.is_admin or user.is_organizer) else None cards = [] if user and user.role in ("admin", "organizer"): cards.extend([ { "title": "Мероприятия", "description": "Создание и управление мероприятиями", "url": url_for("main.admin_events"), "icon": "📅", "btn": "Перейти", }, { "title": "Гости", "description": "Список гостей, поиск, редактирование", "url": url_for("main.admin_guests") if event else url_for("main.admin_events"), "icon": "👥", "btn": "Перейти", "disabled": not event, }, { "title": "Загрузка данных", "description": "Импорт гостей из Excel и загрузка фото", "url": url_for("main.admin_upload") if event else url_for("main.admin_events"), "icon": "📤", "btn": "Перейти", "disabled": not event, }, { "title": "Рассылка приглашений", "description": "Настройка почты и массовая отправка", "url": url_for("main.admin_email_send"), "icon": "✉️", "btn": "Перейти", }, { "title": "Конструктор билета", "description": "Дизайн, логотип и предпросмотр билета", "url": url_for("main.admin_event_ticket", event_id=event.id) if event else url_for("main.admin_events"), "icon": "🎫", "btn": "Перейти", "disabled": not event, }, { "title": "Аналитика", "description": "Посещаемость и недавние входы", "url": url_for("main.admin_dashboard") if event else url_for("main.admin_events"), "icon": "📊", "btn": "Перейти", "disabled": not event, }, ]) if user and user.is_admin: cards.extend([ { "title": "Пользователи", "description": "Управление администраторами и стаффом", "url": url_for("main.admin_users"), "icon": "🔐", "btn": "Перейти", }, { "title": "Логи", "description": "История действий пользователей", "url": url_for("main.admin_logs"), "icon": "📝", "btn": "Перейти", }, ]) cards.append({ "title": "Проверка приглашения", "description": "Сканирование QR-кода и отметка входа", "url": url_for("main.check_in"), "icon": "📷", "btn": "Перейти", }) return render_template("admin/panel.html", cards=cards) # --------------------------- # Email / invitations # --------------------------- @main_bp.route("/admin/email/settings", methods=["GET", "POST"]) @require_role("admin") def admin_email_settings(): settings = SmtpSettings.get_settings() if request.method == "POST": mode = request.form.get("mail_mode", "smtp").strip() settings.use_local_outlook = mode == "outlook" settings.use_ews = mode == "ews" if mode == "smtp": settings.mail_server = request.form.get("mail_server", "smtp.yandex.ru").strip() try: settings.mail_port = int(request.form.get("mail_port", "465").strip()) except ValueError: settings.mail_port = 465 settings.mail_use_ssl = bool(request.form.get("mail_use_ssl")) settings.mail_username = request.form.get("mail_username", "").strip() settings.mail_default_sender = request.form.get("mail_default_sender", "").strip() password = request.form.get("mail_password", "").strip() if password: settings.set_mail_password(password) elif mode == "ews": settings.ews_url = request.form.get("ews_url", "").strip() settings.mail_username = request.form.get("mail_username", "").strip() settings.mail_default_sender = request.form.get("mail_default_sender", "").strip() password = request.form.get("mail_password", "").strip() if password: settings.set_mail_password(password) # IMAP: копия отправленных писем (общая для всех режимов отправки) settings.imap_save_copy = bool(request.form.get("imap_save_copy")) settings.imap_server = request.form.get("imap_server", "").strip() try: settings.imap_port = int(request.form.get("imap_port", "993").strip()) except ValueError: settings.imap_port = 993 settings.imap_use_ssl = bool(request.form.get("imap_use_ssl")) settings.imap_username = request.form.get("imap_username", "").strip() settings.imap_folder = request.form.get("imap_folder", "Sent").strip() or "Sent" imap_password = request.form.get("imap_password", "").strip() if imap_password: settings.set_imap_password(imap_password) db.session.commit() SmtpSettings.apply_to_app(current_app) mode_label = {"smtp": "SMTP", "ews": "EWS", "outlook": "Outlook"}.get(mode, mode) log_action( "email_settings_update", target_type="smtp_settings", target_id=settings.id, details=f"Обновлены почтовые настройки ({mode_label})", ) db.session.commit() flash("Настройки почты сохранены", "success") return redirect(url_for("main.admin_email_settings")) return render_template("admin/email_settings.html", settings=settings) @main_bp.route("/admin/email/test", methods=["POST"]) @require_role("admin") @limiter.limit("5 per minute") def admin_email_test(): test_address = request.form.get("test_address", "").strip() if not test_address: flash("Укажите адрес для тестового письма", "danger") return redirect(url_for("main.admin_email_settings")) try: send_test_email(test_address) log_action( "email_test_send", target_type="smtp_settings", details=f"Тестовое письмо отправлено на {test_address}", ) db.session.commit() flash(f"Тестовое письмо отправлено на {test_address}", "success") except Exception as e: log_action( "email_test_send", target_type="smtp_settings", details=f"Ошибка отправки тестового письма на {test_address}: {e}", ) db.session.commit() flash(f"Ошибка отправки: {str(e)}", "danger") return redirect(url_for("main.admin_email_settings")) @main_bp.route("/admin/email/send", methods=["GET", "POST"]) @require_role("admin", "organizer") @limiter.limit("3 per minute", methods=["POST"]) def admin_email_send(): user = current_user() events = available_events_for_user(user).all() if request.method == "POST": event_id = request.form.get("event_id", "").strip() if not event_id: flash("Выберите мероприятие", "danger") return redirect(url_for("main.admin_email_send")) event = db.get_or_404(Event, int(event_id)) if not user.can_manage_event(event): flash("Нет доступа к этому мероприятию", "danger") return redirect(url_for("main.admin_email_send")) unsent_count = Guest.query.filter_by( event_id=event.id, is_active=True, invitation_sent=False ).count() if not unsent_count: flash("Нет неотправленных приглашений для выбранного мероприятия", "info") return redirect(url_for("main.admin_email_send")) try: _job, created = enqueue_event_invitations(event.id, user.id) flash(f"Рассылка для {unsent_count} гостей поставлена в очередь." if created else "Рассылка уже выполняется.", "success" if created else "info") except Exception: current_app.logger.exception("Failed to enqueue event invitations") flash("Очередь рассылки недоступна. Обратитесь к администратору.", "danger") return redirect(url_for("main.admin_email_send")) event_counts = [] for event in events: unsent = Guest.query.filter_by( event_id=event.id, is_active=True, invitation_sent=False ).count() failed = Guest.query.filter_by(event_id=event.id, is_active=True, invitation_status="failed").count() event_counts.append({"event": event, "unsent": unsent, "failed": failed}) return render_template("admin/email_send.html", event_counts=event_counts) # --------------------------- # Check-in # --------------------------- @main_bp.route("/check-in", methods=["GET", "POST"]) @require_role("admin", "organizer", "staff") def check_in(): user = current_user() token = request.args.get("token", "").strip() guest = None already_checked_in = False active_events = available_events_for_user(user).filter(Event.is_active == True).all() if request.method == "POST": event_id = request.form.get("event_id", "").strip() if event_id: try: selected_event = db.session.get(Event, int(event_id)) if not selected_event or not selected_event.is_active or not can_access_event(user, selected_event): abort(403) session["checkin_event_id"] = selected_event.id except ValueError: pass return redirect(url_for("main.check_in", token=token)) checkin_event_id = session.get("checkin_event_id") checkin_event = None if checkin_event_id: checkin_event = db.session.get(Event, checkin_event_id) if not checkin_event or not can_access_event(user, checkin_event): session.pop("checkin_event_id", None) checkin_event = None if token: guest = Guest.query.filter_by(token=token, is_active=True).first() if guest and can_access_event(user, guest.event): already_checked_in = guest.is_checked_in if checkin_event is None: checkin_event = guest.event session["checkin_event_id"] = guest.event_id else: guest = None return render_template( "checkin/index.html", guest=guest, token=token, already_checked_in=already_checked_in, active_events=active_events, checkin_event=checkin_event, ) # --------------------------- # API # --------------------------- @main_bp.route("/api/guests/search") @require_role("admin", "organizer", "staff") @limiter.limit("60 per minute") def api_guests_search(): q = request.args.get("q", "").strip() event_id = request.args.get("event_id") or session.get("checkin_event_id") if len(q) < 2: return jsonify([]) if not event_id: return jsonify({"error": "Сначала выберите мероприятие"}), 400 try: event = db.session.get(Event, int(event_id)) except (TypeError, ValueError): event = None if not event or not can_access_event(current_user(), event): abort(403) query = Guest.query.filter(Guest.is_active == True, Guest.event_id == event.id) # Ограничиваем выборку на уровне БД, чтобы поиск не загружал в память # весь список гостей крупного мероприятия. guests = ( query .options(selectinload(Guest.check_ins)) .filter(_guest_search_expression(q)) .order_by(Guest.full_name) .limit(20) .all() ) return jsonify([ { "id": g.id, "full_name": g.build_full_name(), "department": g.department, "position": g.position, "email": g.email, "phone": g.phone, "photo_url": g.photo_url, "token": g.token, "checked_in": g.is_checked_in, "event_id": g.event_id, "event_name": g.event.name if g.event else "", } for g in guests ]) @main_bp.route("/api/check-in/", methods=["POST"]) @require_role("admin", "organizer", "staff") @limiter.limit("120 per minute") def api_check_in(token): guest = Guest.query.filter_by(token=token, is_active=True).first() if not guest: return jsonify({"success": False, "message": "Гость не найден или QR-код аннулирован"}), 404 require_event_access(guest.event) if guest.is_checked_in: return jsonify({ "success": False, "already_checked_in": True, "checked_in_at": guest.last_check_in.isoformat(), "message": "Гость уже отмечен как вошедший", }), 409 check_in_record = CheckIn( guest_id=guest.id, event_id=guest.event_id, checked_in_by_id=current_user().id, device_info=request.headers.get("User-Agent", "")[:500], ) db.session.add(check_in_record) log_action( "check_in", target_type="guest", target_id=guest.id, details=f"Отмечен вход гостя '{guest.build_full_name()}' на мероприятие '{guest.event.name if guest.event else ''}'", ) try: db.session.commit() except IntegrityError: db.session.rollback() existing = CheckIn.query.filter_by(guest_id=guest.id).first() return jsonify({ "success": False, "already_checked_in": True, "checked_in_at": existing.checked_in_at.isoformat() if existing else None, "message": "Гость уже отмечен как вошедший", }), 409 return jsonify({ "success": True, "message": "Вход отмечен", "checked_in_at": check_in_record.checked_in_at.isoformat(), "guest": { "full_name": guest.build_full_name(), "department": guest.department, "position": guest.position, "phone": guest.phone, }, }) @main_bp.route("/api/check-in//undo", methods=["POST"]) @require_role("admin", "organizer", "staff") @limiter.limit("30 per minute") def api_check_in_undo(token): guest = Guest.query.filter_by(token=token, is_active=True).first() if not guest: return jsonify({"success": False, "message": "Гость не найден"}), 404 require_event_access(guest.event) payload = request.get_json(silent=True) or request.form reason = str(payload.get("reason", "")).strip() if len(reason) < 3: return jsonify({"success": False, "message": "Укажите причину отмены"}), 400 record = CheckIn.query.filter_by(guest_id=guest.id).first() if not record: return jsonify({"success": False, "message": "Активная отметка входа не найдена"}), 409 checked_in_at = record.checked_in_at db.session.delete(record) log_action( "check_in_undo", target_type="guest", target_id=guest.id, details=f"Отменён вход от {checked_in_at.isoformat()}. Причина: {reason[:500]}", ) db.session.commit() return jsonify({"success": True, "message": "Отметка входа отменена"}) @main_bp.route("/api/guest/") @require_role("admin", "organizer", "staff") @limiter.limit("120 per minute") def api_guest_detail(token): guest = Guest.query.filter_by(token=token, is_active=True).first_or_404() require_event_access(guest.event) return jsonify({ "id": guest.id, "full_name": guest.build_full_name(), "department": guest.department, "position": guest.position, "email": guest.email, "phone": guest.phone, "photo_url": guest.photo_url, "token": guest.token, "checked_in": guest.is_checked_in, "checked_in_at": guest.last_check_in.isoformat() if guest.last_check_in else None, "event_id": guest.event_id, "event_name": guest.event.name if guest.event else "", }) # --------------------------- # Home # --------------------------- @main_bp.route("/") def index(): if _is_first_run(): return redirect(url_for("main.setup" if _setup_available() else "main.login")) user = current_user() if not user: return redirect(url_for("main.login")) if user.is_staff: return redirect(url_for("main.check_in")) return redirect(url_for("main.admin_panel")) @main_bp.app_context_processor def inject_globals(): user = current_user() events = [] current_event = None if user and (user.is_admin or user.is_organizer): events = available_events_for_user(user).filter(Event.is_active == True).all() event_id = session.get("current_event_id") if event_id: candidate = db.session.get(Event, event_id) if candidate and user.can_manage_event(candidate): current_event = candidate else: session.pop("current_event_id", None) return dict(current_user=user, available_events=events, current_event=current_event)