Update: admin panel, SMTP/EWS/Outlook mail modes, QR fix, event folders, security improvements
This commit is contained in:
+530
-21
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user