Update: admin panel, SMTP/EWS/Outlook mail modes, QR fix, event folders, security improvements

This commit is contained in:
Admin
2026-08-31 12:35:02 +03:00
parent a69abb948f
commit 76a42f14d0
32 changed files with 1474 additions and 116 deletions
+530 -21
View File
@@ -10,16 +10,33 @@ from flask import (
)
from werkzeug.utils import secure_filename
from app import db
from app.models import User, Event, Guest, CheckIn
from app import db, csrf, limiter
from app.models import User, Event, Guest, CheckIn, AuditLog, SmtpSettings
from app.services.qr_service import generate_qr_for_guest
from app.services.email_service import send_invitation
from app.services.email_service import send_invitation, send_test_email
from app.services.excel_service import 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
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
# ---------------------------
@@ -30,20 +47,27 @@ def allowed_file(filename, extensions):
def current_user():
user_id = session.get("user_id")
session_version = session.get("session_version")
if user_id:
return User.query.get(user_id)
user = User.query.get(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)
@@ -103,11 +127,28 @@ def available_events_for_user(user):
return Event.query.filter_by(created_by_id=user.id).order_by(Event.created_at.desc())
def _guest_matches_search(guest, query):
"""Регистронезависимый поиск по ФИО, отделу, должности, email и телефону."""
q = query.casefold()
fields = [
guest.full_name,
guest.last_name,
guest.first_name,
guest.middle_name,
guest.department,
guest.position,
guest.email,
guest.phone,
]
return any(f and q in f.casefold() for f in fields)
# ---------------------------
# Auth
# ---------------------------
@main_bp.route("/login", methods=["GET", "POST"])
@limiter.limit("10 per minute", methods=["POST"])
def login():
if request.method == "POST":
username = request.form.get("username", "").strip()
@@ -116,9 +157,11 @@ def login():
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_events"))
return redirect(url_for("main.admin_panel"))
else:
flash("Неверный логин или пароль", "danger")
@@ -127,6 +170,8 @@ def login():
@main_bp.route("/logout")
def logout():
log_action("logout")
db.session.commit()
logout_user()
flash("Вы вышли из системы", "info")
return redirect(url_for("main.login"))
@@ -163,7 +208,7 @@ def admin_event_add():
event_date = None
if event_date_str:
try:
event_date = datetime.fromisoformat(event_date_str)
event_date = local_to_utc(datetime.fromisoformat(event_date_str))
except ValueError:
flash("Неверный формат даты", "danger")
return redirect(url_for("main.admin_event_add"))
@@ -176,6 +221,7 @@ def admin_event_add():
created_by_id=current_user().id,
)
db.session.add(event)
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"))
@@ -204,7 +250,7 @@ def admin_event_edit(event_id):
if event_date_str:
try:
event.event_date = datetime.fromisoformat(event_date_str)
event.event_date = local_to_utc(datetime.fromisoformat(event_date_str))
except ValueError:
flash("Неверный формат даты", "danger")
return redirect(url_for("main.admin_event_edit", event_id=event.id))
@@ -212,6 +258,7 @@ def admin_event_edit(event_id):
event.event_date = None
event.is_active = bool(request.form.get("is_active"))
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"))
@@ -232,11 +279,57 @@ def admin_event_delete(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):
@@ -276,7 +369,7 @@ def admin_event_ticket(event_id):
if logo_file and logo_file.filename:
if allowed_file(logo_file.filename, {"png", "jpg", "jpeg", "gif", "webp", "bmp", "svg"}):
filename = secure_filename(f"event_{event.id}_{logo_file.filename}")
logo_folder = Path(current_app.config["LOGO_FOLDER"])
logo_folder = event_logo_folder(event.id)
logo_folder.mkdir(parents=True, exist_ok=True)
logo_path = logo_folder / filename
logo_file.save(str(logo_path))
@@ -286,11 +379,12 @@ def admin_event_ticket(event_id):
# Logo deletion
if request.form.get("delete_logo") and event.logo_filename:
old_logo = Path(current_app.config["LOGO_FOLDER"]) / event.logo_filename
old_logo = event_logo_folder(event.id) / event.logo_filename
if 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))
@@ -320,6 +414,12 @@ def admin_event_ticket_preview(event_id):
# ---------------------------
@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():
@@ -377,6 +477,12 @@ def admin_upload():
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")
@@ -389,7 +495,8 @@ def admin_upload():
photo_files = request.files.getlist("photo_files")
zip_file = request.files.get("zip_file")
photo_folder = Path(current_app.config["PHOTO_FOLDER"])
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:
@@ -399,8 +506,21 @@ def admin_upload():
zip_file.save(str(zip_path))
try:
with zipfile.ZipFile(zip_path, "r") as z:
z.extractall(str(photo_folder))
uploaded_count = len([name for name in z.namelist() if not name.endswith("/")])
extracted = 0
for member in z.infolist():
if member.is_dir():
continue
# Защита от path traversal
target = (photo_folder / member.filename).resolve()
if photo_folder.resolve() not in target.parents and target != photo_folder.resolve():
current_app.logger.warning(f"Пропущен небезопасный путь в ZIP: {member.filename}")
continue
ext = target.suffix.lower().lstrip(".")
if ext not in {"png", "jpg", "jpeg", "gif", "webp", "bmp", "svg"}:
continue
z.extract(member, str(photo_folder))
extracted += 1
uploaded_count = extracted
flash(f"Из ZIP распаковано файлов: {uploaded_count}", "success")
except Exception as e:
flash(f"Ошибка распаковки ZIP: {str(e)}", "danger")
@@ -418,6 +538,13 @@ def admin_upload():
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"))
@@ -434,10 +561,9 @@ def admin_upload():
def admin_guests():
event = get_current_event()
search = request.args.get("q", "").strip()
query = Guest.query.filter_by(event_id=event.id)
guests = Guest.query.filter_by(event_id=event.id).order_by(Guest.full_name).all()
if search:
query = query.filter(Guest.full_name.ilike(f"%{search}%"))
guests = query.order_by(Guest.full_name).all()
guests = [g for g in guests if _guest_matches_search(g, search)]
return render_template("admin/guests.html", event=event, guests=guests, search=search)
@@ -474,6 +600,12 @@ def admin_guest_add():
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"):
@@ -509,6 +641,12 @@ def admin_guest_edit(guest_id):
guest.email = request.form.get("email", "").strip()
guest.phone = request.form.get("phone", "").strip()
guest.photo_filename = request.form.get("photo_filename", "").strip()
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"))
@@ -523,6 +661,12 @@ 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"))
@@ -539,12 +683,74 @@ def admin_guest_resend(guest_id):
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 = event_qr_folder(guest.event_id) / guest.qr_filename
if 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("/admin/send-invitations", methods=["POST"])
@require_role("admin", "organizer")
@require_event
@@ -561,6 +767,12 @@ def admin_send_invitations():
sent += 1
except Exception:
failed += 1
log_action(
"event_send_invitations",
target_type="event",
target_id=event.id,
details=f"Рассылка приглашений: отправлено {sent}, ошибок {failed}",
)
db.session.commit()
flash(f"Отправлено приглашений: {sent}, ошибок: {failed}", "success" if failed == 0 else "warning")
return redirect(url_for("main.admin_dashboard"))
@@ -582,6 +794,13 @@ def admin_report():
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")
@@ -622,6 +841,7 @@ def 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"))
@@ -657,6 +877,7 @@ def admin_user_edit(user_id):
user.is_active = is_active
if password:
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"))
@@ -673,11 +894,275 @@ def admin_user_delete(user_id):
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.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.mail_password = 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")
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")
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 = Event.query.get_or_404(int(event_id))
if not user.can_manage_event(event):
flash("Нет доступа к этому мероприятию", "danger")
return redirect(url_for("main.admin_email_send"))
guests = Guest.query.filter_by(
event_id=event.id, is_active=True, invitation_sent=False
).all()
if not guests:
flash("Нет неотправленных приглашений для выбранного мероприятия", "info")
return redirect(url_for("main.admin_email_send"))
sent = 0
failed = 0
for guest in guests:
if not guest.qr_filename:
generate_qr_for_guest(guest)
try:
send_invitation(guest)
sent += 1
db.session.commit()
except Exception:
failed += 1
db.session.rollback()
log_action(
"event_send_invitations",
target_type="event",
target_id=event.id,
details=f"Рассылка приглашений из панели: отправлено {sent}, ошибок {failed}",
)
db.session.commit()
flash(f"Отправлено приглашений: {sent}, ошибок: {failed}", "success" if failed == 0 else "warning")
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()
event_counts.append({"event": event, "unsent": unsent})
return render_template("admin/email_send.html", event_counts=event_counts)
# ---------------------------
# Check-in
# ---------------------------
@@ -730,6 +1215,7 @@ def check_in():
@main_bp.route("/api/guests/search")
@require_role("admin", "organizer", "staff")
@csrf.exempt
def api_guests_search():
q = request.args.get("q", "").strip()
event_id = request.args.get("event_id") or session.get("checkin_event_id")
@@ -744,13 +1230,28 @@ def api_guests_search():
except ValueError:
pass
guests = (
# SQL-фильтр (регистронезависимый для латиницы) + fallback:
# если SQLite не нашёл по кириллице, берём всех гостей мероприятия
# и фильтруем в Python через casefold().
sql_like = f"%{q}%"
candidates = (
query
.filter(Guest.full_name.ilike(f"%{q}%"))
.filter(
(Guest.full_name.ilike(sql_like)) |
(Guest.department.ilike(sql_like)) |
(Guest.email.ilike(sql_like))
)
.order_by(Guest.full_name)
.limit(20)
.limit(100)
.all()
)
guests = [g for g in candidates if _guest_matches_search(g, q)]
if not guests and event_id:
all_event_guests = Guest.query.filter_by(
is_active=True, event_id=int(event_id)
).order_by(Guest.full_name).all()
guests = [g for g in all_event_guests if _guest_matches_search(g, q)][:20]
return jsonify([
{
@@ -760,7 +1261,7 @@ def api_guests_search():
"position": g.position,
"email": g.email,
"phone": g.phone,
"photo_url": url_for("static", filename=f"uploads/photos/{g.photo_filename}") if g.photo_filename else "",
"photo_url": g.photo_url,
"token": g.token,
"checked_in": g.is_checked_in,
"event_id": g.event_id,
@@ -772,6 +1273,7 @@ def api_guests_search():
@main_bp.route("/api/check-in/<token>", methods=["POST"])
@require_role("admin", "organizer", "staff")
@csrf.exempt
def api_check_in(token):
guest = Guest.query.filter_by(token=token, is_active=True).first()
if not guest:
@@ -791,6 +1293,12 @@ def api_check_in(token):
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 ''}'",
)
db.session.commit()
return jsonify({
@@ -808,6 +1316,7 @@ def api_check_in(token):
@main_bp.route("/api/guest/<token>")
@require_role("admin", "organizer", "staff")
@csrf.exempt
def api_guest_detail(token):
guest = Guest.query.filter_by(token=token, is_active=True).first_or_404()
return jsonify({
@@ -817,7 +1326,7 @@ def api_guest_detail(token):
"position": guest.position,
"email": guest.email,
"phone": guest.phone,
"photo_url": url_for("static", filename=f"uploads/photos/{guest.photo_filename}") if guest.photo_filename else "",
"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,
@@ -837,7 +1346,7 @@ def index():
return redirect(url_for("main.login"))
if user.is_staff:
return redirect(url_for("main.check_in"))
return redirect(url_for("main.admin_events"))
return redirect(url_for("main.admin_panel"))
@main_bp.app_context_processor