Files
InviteMe/app/routes.py
T
2026-09-01 10:52:35 +03:00

1715 lines
67 KiB
Python

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/<token>", 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/<token>/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/<token>/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/<int:event_id>/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/<int:event_id>/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/<int:event_id>/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/<int:event_id>/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/<int:event_id>/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/<int:event_id>/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/<int:event_id>/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/<int:guest_id>/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/<int:guest_id>/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/<int:guest_id>/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/<int:guest_id>/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/<int:guest_id>/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/<int:guest_id>/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/<int:user_id>/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/<int:user_id>/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/<token>", 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/<token>/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/<token>")
@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)