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
+88
View File
@@ -3,12 +3,17 @@ import logging
from flask import Flask
from flask_sqlalchemy import SQLAlchemy
from flask_limiter import Limiter
from flask_limiter.util import get_remote_address
from flask_wtf.csrf import CSRFProtect
from sqlalchemy import text
from config import Config
logger = logging.getLogger(__name__)
db = SQLAlchemy()
limiter = Limiter(key_func=get_remote_address)
csrf = CSRFProtect()
def create_app(config_class=Config):
@@ -22,6 +27,8 @@ def create_app(config_class=Config):
os.makedirs(os.path.dirname(app.config["SQLALCHEMY_DATABASE_URI"].replace("sqlite:///", "")), exist_ok=True)
db.init_app(app)
limiter.init_app(app)
csrf.init_app(app)
from app.routes import main_bp
app.register_blueprint(main_bp)
@@ -30,10 +37,20 @@ def create_app(config_class=Config):
db.create_all()
_migrate_existing_data(app)
_create_default_users(app)
_load_smtp_settings(app)
return app
def _load_smtp_settings(app):
"""Загружает SMTP-настройки из базы в конфиг приложения."""
try:
from app.models import SmtpSettings
SmtpSettings.apply_to_app(app)
except Exception as e:
logger.warning(f"SMTP settings load skipped: {e}")
def _column_exists(table_name, column_name):
return db.session.execute(
text(f"SELECT COUNT(*) FROM pragma_table_info('{table_name}') WHERE name='{column_name}'")
@@ -45,6 +62,65 @@ def _add_column_if_missing(table_name, column_name, column_def):
db.session.execute(text(f"ALTER TABLE {table_name} ADD COLUMN {column_name} {column_def}"))
def _safe_move(src, dst):
"""Перемещает файл, игнорируя ошибки."""
try:
import shutil
dst.parent.mkdir(parents=True, exist_ok=True)
shutil.move(str(src), str(dst))
return True
except Exception as e:
logger.warning(f"Не удалось переместить {src} -> {dst}: {e}")
return False
def _migrate_files_to_event_folders():
"""Переносит существующие файлы в папки по мероприятиям."""
from pathlib import Path
from flask import current_app
from app.models import Event, Guest
photo_base = Path(current_app.config["PHOTO_FOLDER"])
qr_base = Path(current_app.config["QR_FOLDER"])
logo_base = Path(current_app.config["LOGO_FOLDER"])
tickets_base = Path(current_app.config["UPLOAD_FOLDER"]) / "tickets"
# Фото гостей
for guest in Guest.query.filter(Guest.photo_filename.isnot(None)).all():
if not guest.photo_filename:
continue
src = photo_base / guest.photo_filename
if src.exists() and src.is_file():
dst = photo_base / f"event_{guest.event_id}" / guest.photo_filename
_safe_move(src, dst)
# QR-коды гостей
for guest in Guest.query.filter(Guest.qr_filename.isnot(None)).all():
if not guest.qr_filename:
continue
src = qr_base / guest.qr_filename
if src.exists() and src.is_file():
dst = qr_base / f"event_{guest.event_id}" / guest.qr_filename
_safe_move(src, dst)
# Логотипы мероприятий
for event in Event.query.filter(Event.logo_filename.isnot(None)).all():
if not event.logo_filename:
continue
src = logo_base / event.logo_filename
if src.exists() and src.is_file():
dst = logo_base / f"event_{event.id}" / event.logo_filename
_safe_move(src, dst)
# Сгенерированные билеты
if tickets_base.exists():
for event in Event.query.all():
event_tickets_dir = tickets_base / f"event_{event.id}"
event_tickets_dir.mkdir(parents=True, exist_ok=True)
for ticket_file in tickets_base.glob(f"ticket_{event.id}_*.png"):
_safe_move(ticket_file, event_tickets_dir / ticket_file.name)
def _migrate_existing_data(app):
"""Миграция существующих гостей в дефолтное мероприятие и добавление новых колонок."""
from app.models import Event, Guest
@@ -68,6 +144,18 @@ def _migrate_existing_data(app):
_add_column_if_missing("guests", "middle_name", "VARCHAR(255)")
_add_column_if_missing("guests", "phone", "VARCHAR(50)")
# Версия сессии пользователя для инвалидации после смены пароля
_add_column_if_missing("users", "session_version", "INTEGER DEFAULT 0")
# Флаг отправки через локальный Outlook (MAPI)
_add_column_if_missing("smtp_settings", "use_local_outlook", "BOOLEAN DEFAULT 0")
# Флаг отправки через Exchange Web Services (EWS)
_add_column_if_missing("smtp_settings", "use_ews", "BOOLEAN DEFAULT 0")
_add_column_if_missing("smtp_settings", "ews_url", "VARCHAR(500)")
# Переносим файлы в папки по мероприятиям
_migrate_files_to_event_folders()
# Проверяем, есть ли колонка event_id в таблице guests
if _column_exists("guests", "event_id"):
db.session.commit()
+109
View File
@@ -14,12 +14,14 @@ class User(db.Model):
password_hash = db.Column(db.String(255), nullable=False)
role = db.Column(db.String(20), nullable=False, default="staff")
is_active = db.Column(db.Boolean, default=True, nullable=False)
session_version = db.Column(db.Integer, default=0, nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
created_events = db.relationship("Event", backref="creator", lazy=True)
def set_password(self, password):
self.password_hash = generate_password_hash(password)
self.session_version = (self.session_version or 0) + 1
def check_password(self, password):
return check_password_hash(self.password_hash, password)
@@ -115,6 +117,37 @@ class Guest(db.Model):
parts = [p for p in [self.last_name, self.first_name, self.middle_name] if p]
return " ".join(parts) if parts else self.full_name
def _photo_paths(self):
"""Возвращает возможные пути к фото: сначала папка мероприятия, потом корень."""
if not self.photo_filename:
return []
from pathlib import Path
from flask import current_app
base = Path(current_app.config["PHOTO_FOLDER"])
paths = [base / self.photo_filename]
if self.event_id:
paths.insert(0, base / f"event_{self.event_id}" / self.photo_filename)
return paths
@property
def has_photo_file(self):
try:
return any(p.exists() for p in self._photo_paths())
except Exception:
return False
@property
def photo_url(self):
if not self.has_photo_file:
return ""
from pathlib import Path
from flask import current_app
for p in self._photo_paths():
if p.exists():
rel = p.relative_to(Path(current_app.config["UPLOAD_FOLDER"]).parent)
return f"/static/{str(rel).replace('\\', '/')}"
return ""
def __repr__(self):
return f"<Guest {self.full_name} ({self.email})>"
@@ -130,3 +163,79 @@ class CheckIn(db.Model):
def __repr__(self):
return f"<CheckIn guest_id={self.guest_id} at={self.checked_in_at}>"
class AuditLog(db.Model):
__tablename__ = "audit_logs"
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=True)
username = db.Column(db.String(100), nullable=True)
action = db.Column(db.String(50), nullable=False)
target_type = db.Column(db.String(50), nullable=True)
target_id = db.Column(db.String(50), nullable=True)
details = db.Column(db.Text, nullable=True)
ip_address = db.Column(db.String(45), nullable=True)
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
user = db.relationship("User", backref="audit_logs", lazy=True)
def __repr__(self):
return f"<AuditLog {self.action} by {self.username} at {self.created_at}>"
class SmtpSettings(db.Model):
"""Глобальные настройки SMTP для рассылки приглашений."""
__tablename__ = "smtp_settings"
id = db.Column(db.Integer, primary_key=True)
mail_server = db.Column(db.String(255), nullable=False, default="smtp.yandex.ru")
mail_port = db.Column(db.Integer, nullable=False, default=465)
mail_use_ssl = db.Column(db.Boolean, nullable=False, default=True)
mail_username = db.Column(db.String(255), nullable=False, default="")
mail_password = db.Column(db.String(255), nullable=False, default="")
mail_default_sender = db.Column(db.String(255), nullable=True)
use_local_outlook = db.Column(db.Boolean, nullable=False, default=False)
use_ews = db.Column(db.Boolean, nullable=False, default=False)
ews_url = db.Column(db.String(500), nullable=True)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
@property
def mail_mode(self):
if self.use_local_outlook:
return "outlook"
if self.use_ews:
return "ews"
return "smtp"
@classmethod
def get_settings(cls):
settings = cls.query.first()
if not settings:
settings = cls()
db.session.add(settings)
db.session.commit()
return settings
@classmethod
def apply_to_app(cls, app):
"""Применяет сохранённые настройки к конфигурации Flask-приложения."""
try:
settings = cls.query.first()
if not settings:
return
app.config["MAIL_SERVER"] = settings.mail_server or app.config.get("MAIL_SERVER", "")
app.config["MAIL_PORT"] = settings.mail_port or app.config.get("MAIL_PORT", 465)
app.config["MAIL_USE_SSL"] = settings.mail_use_ssl
app.config["MAIL_USERNAME"] = settings.mail_username or ""
app.config["MAIL_PASSWORD"] = settings.mail_password or ""
app.config["MAIL_DEFAULT_SENDER"] = settings.mail_default_sender or settings.mail_username or app.config.get("MAIL_DEFAULT_SENDER", "")
app.config["MAIL_USE_LOCAL_OUTLOOK"] = settings.use_local_outlook
app.config["MAIL_USE_EWS"] = settings.use_ews
app.config["EWS_URL"] = settings.ews_url or ""
except Exception:
pass
def __repr__(self):
return f"<SmtpSettings {self.mail_server}:{self.mail_port} {self.mail_username}>"
+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
+47
View File
@@ -0,0 +1,47 @@
from flask import request
from app import db
from app.models import AuditLog
def log_action(action, target_type=None, target_id=None, details=None):
"""Записывает действие пользователя в аудит-лог.
Аргументы:
action: короткий код действия, например "guest_create", "event_delete".
target_type: тип объекта (event, guest, user, ...).
target_id: идентификатор объекта (может быть строкой).
details: произвольное текстовое описание или причина.
"""
try:
from app.routes import current_user
user = current_user()
user_id = user.id if user else None
username = user.username if user else None
except Exception:
user_id = None
username = None
ip_address = None
try:
if request:
# Учитываем прокси
ip_address = request.headers.get("X-Forwarded-For", request.remote_addr)
if ip_address:
ip_address = ip_address.split(",")[0].strip()
except Exception:
pass
record = AuditLog(
user_id=user_id,
username=username,
action=action,
target_type=target_type,
target_id=str(target_id) if target_id is not None else None,
details=details,
ip_address=ip_address,
)
db.session.add(record)
# Не делаем commit здесь, чтобы не ломать транзакции вызывающего кода.
# Вызывающий код сам делает db.session.commit() после основных изменений.
+175 -51
View File
@@ -7,65 +7,44 @@ from pathlib import Path
from flask import current_app, render_template
from app.services.ticket_service import generate_ticket_image
from app.utils.paths import event_qr_folder
def send_invitation(guest, event_name=None, additional_text=""):
"""Отправляет приглашение гостю на email с QR-кодом и билетом во вложении."""
OUTLOOK_CONTENT_ID_PROP = "http://schemas.microsoft.com/mapi/proptag/0x3712001F"
def _send_smtp_email(to_address, subject, html_body, sender_name, attachments):
"""Отправляет письмо через SMTP с inline-вложениями."""
config = current_app.config
if event_name is None:
event_name = guest.event.name if guest.event else "Мероприятие"
msg = MIMEMultipart("related")
msg["Subject"] = f"Приглашение на мероприятие: {event_name}"
msg["From"] = formataddr(("InviteMe", config.get("MAIL_DEFAULT_SENDER", config["MAIL_USERNAME"])))
msg["To"] = guest.email
# Генерируем билет
try:
ticket_path = generate_ticket_image(guest, guest.event)
except Exception as e:
ticket_path = None
current_app.logger.warning(f"Не удалось сгенерировать билет для {guest.email}: {e}")
# HTML-шаблон письма
html_body = render_template(
"email/invitation.html",
guest=guest,
event_name=event_name,
additional_text=additional_text,
qr_cid="qr_code_image",
ticket_cid="ticket_image" if ticket_path else None,
)
msg.attach(MIMEText(html_body, "html", "utf-8"))
# Прикрепляем QR-код
qr_path = Path(config["QR_FOLDER"]) / guest.qr_filename
if qr_path.exists():
with open(qr_path, "rb") as img_file:
img = MIMEImage(img_file.read())
img.add_header("Content-ID", "<qr_code_image>")
img.add_header("Content-Disposition", "inline", filename=guest.qr_filename)
msg.attach(img)
# Прикрепляем билет
if ticket_path and ticket_path.exists():
with open(ticket_path, "rb") as img_file:
img = MIMEImage(img_file.read())
img.add_header("Content-ID", "<ticket_image>")
img.add_header("Content-Disposition", "inline", filename=f"ticket_{guest.token}.png")
msg.attach(img)
# Отправка через SMTP
server = config["MAIL_SERVER"]
port = config["MAIL_PORT"]
username = config["MAIL_USERNAME"]
password = config["MAIL_PASSWORD"]
server = config.get("MAIL_SERVER", "")
port = config.get("MAIL_PORT", 465)
username = config.get("MAIL_USERNAME", "")
password = config.get("MAIL_PASSWORD", "")
use_ssl = config.get("MAIL_USE_SSL", True)
sender = config.get("MAIL_DEFAULT_SENDER") or username
if not username or not password:
raise ValueError("MAIL_USERNAME и MAIL_PASSWORD должны быть указаны в конфигурации")
if not server or not port:
raise ValueError("MAIL_SERVER и MAIL_PORT должны быть указаны в конфигурации")
msg = MIMEMultipart("related")
msg["Subject"] = subject
msg["From"] = formataddr((sender_name, sender))
msg["To"] = to_address
msg.attach(MIMEText(html_body, "html", "utf-8"))
for att in attachments:
path = att["path"]
cid = att.get("cid")
filename = att.get("filename", path.name)
with open(path, "rb") as img_file:
img = MIMEImage(img_file.read())
if cid:
img.add_header("Content-ID", f"<{cid}>")
img.add_header("Content-Disposition", "inline", filename=filename)
msg.attach(img)
if use_ssl:
with smtplib.SMTP_SSL(server, port) as smtp:
@@ -77,5 +56,150 @@ def send_invitation(guest, event_name=None, additional_text=""):
smtp.login(username, password)
smtp.send_message(msg)
def _send_outlook_email(to_address, subject, html_body, sender_name, attachments):
"""Отправляет письмо через локальный Outlook (MAPI/COM)."""
try:
import win32com.client
except ImportError as e:
raise RuntimeError("Для отправки через Outlook необходима библиотека pywin32") from e
try:
outlook = win32com.client.Dispatch("Outlook.Application")
except Exception as e:
raise RuntimeError("Не удалось подключиться к Outlook. Убедитесь, что Outlook установлен и запущен.") from e
mail = outlook.CreateItem(0) # olMailItem
mail.To = to_address
mail.Subject = subject
mail.HTMLBody = html_body
for att in attachments:
path = att["path"]
cid = att.get("cid")
filename = att.get("filename", path.name)
# 1 = olByValue, 0 = позиция (inline)
attachment = mail.Attachments.Add(str(path), 1, 0, filename)
if cid:
attachment.PropertyAccessor.SetProperty(OUTLOOK_CONTENT_ID_PROP, cid)
mail.Send()
def _send_ews_email(to_address, subject, html_body, sender_name, attachments):
"""Отправляет письмо через Exchange Web Services (EWS)."""
try:
from exchangelib import Credentials, Configuration, Account, Message, Mailbox, HTMLBody, FileAttachment
except ImportError as e:
raise RuntimeError("Для отправки через EWS необходима библиотека exchangelib") from e
config = current_app.config
ews_url = config.get("EWS_URL", "")
username = config.get("MAIL_USERNAME", "")
password = config.get("MAIL_PASSWORD", "")
if not ews_url or not username or not password:
raise ValueError("Для EWS должны быть указаны EWS URL, логин и пароль")
try:
credentials = Credentials(username=username, password=password)
ews_config = Configuration(service_endpoint=ews_url, credentials=credentials)
account = Account(
primary_smtp_address=username,
config=ews_config,
autodiscover=False,
access_type="delegate",
)
msg = Message(
account=account,
subject=subject,
body=HTMLBody(html_body),
to_recipients=[Mailbox(email_address=to_address)],
)
for att in attachments:
path = att["path"]
filename = att.get("filename", path.name)
cid = att.get("cid")
with open(path, "rb") as f:
content = f.read()
file_att = FileAttachment(name=filename, content=content, content_id=cid)
msg.attach(file_att)
msg.send()
except Exception as e:
raise RuntimeError(f"Ошибка отправки через EWS: {e}") from e
def _send_email(to_address, subject, html_body, sender_name="InviteMe", attachments=None):
"""Маршрутизирует отправку через SMTP, EWS или локальный Outlook."""
attachments = attachments or []
if current_app.config.get("MAIL_USE_LOCAL_OUTLOOK"):
_send_outlook_email(to_address, subject, html_body, sender_name, attachments)
elif current_app.config.get("MAIL_USE_EWS"):
_send_ews_email(to_address, subject, html_body, sender_name, attachments)
else:
_send_smtp_email(to_address, subject, html_body, sender_name, attachments)
def send_invitation(guest, event_name=None, additional_text=""):
"""Отправляет приглашение гостю на email с QR-кодом и билетом во вложении."""
config = current_app.config
if event_name is None:
event_name = guest.event.name if guest.event else "Мероприятие"
# Генерируем билет
try:
ticket_path = generate_ticket_image(guest, guest.event)
except Exception as e:
ticket_path = None
current_app.logger.warning(f"Не удалось сгенерировать билет для {guest.email}: {e}")
html_body = render_template(
"email/invitation.html",
guest=guest,
event_name=event_name,
additional_text=additional_text,
qr_cid="qr_code_image",
ticket_cid="ticket_image" if ticket_path else None,
)
attachments = []
# QR-код
qr_path = event_qr_folder(guest.event_id) / guest.qr_filename
if not qr_path.exists():
qr_path = Path(config["QR_FOLDER"]) / guest.qr_filename
if qr_path.exists():
attachments.append({"path": qr_path, "cid": "qr_code_image", "filename": guest.qr_filename})
# Билет
if ticket_path and ticket_path.exists():
attachments.append({
"path": ticket_path,
"cid": "ticket_image",
"filename": f"ticket_{guest.token}.png",
})
_send_email(
to_address=guest.email,
subject=f"Приглашение на мероприятие: {event_name}",
html_body=html_body,
attachments=attachments,
)
guest.invitation_sent = True
return True
def send_test_email(to_address):
"""Отправляет тестовое письмо для проверки настроек."""
html_body = render_template("email/test_email.html")
_send_email(
to_address=to_address,
subject="Тестовое письмо InviteMe",
html_body=html_body,
)
return True
+4 -2
View File
@@ -2,11 +2,13 @@ import qrcode
from pathlib import Path
from flask import current_app, url_for
from app.utils.paths import event_qr_folder
def generate_qr_for_guest(guest, base_url=None):
"""Генерирует QR-код для гостя и сохраняет его в uploads/qrcodes/."""
"""Генерирует QR-код для гостя и сохраняет его в папку мероприятия."""
token = guest.token
qr_folder = Path(current_app.config["QR_FOLDER"])
qr_folder = event_qr_folder(guest.event_id)
qr_folder.mkdir(parents=True, exist_ok=True)
qr_filename = f"{token}.png"
+14 -4
View File
@@ -5,6 +5,8 @@ from PIL import Image, ImageDraw, ImageFont
from flask import current_app
from app.services.qr_service import generate_qr_for_guest
from app.utils.timezone import utc_to_local
from app.utils.paths import event_logo_folder, event_tickets_folder, event_photo_folder, event_qr_folder
TICKET_SIZE = (650, 1150)
@@ -49,7 +51,7 @@ def _draw_logo_centered(img, event, y, max_width):
if not event.logo_filename:
return y
logo_path = Path(current_app.config["LOGO_FOLDER"]) / event.logo_filename
logo_path = event_logo_folder(event.id) / event.logo_filename
if not logo_path.exists():
current_app.logger.warning(f"Логотип не найден: {logo_path}")
return y
@@ -292,8 +294,9 @@ def generate_ticket_image(guest, event, output_path=None):
_draw_ornament(draw, y, width, accent_color)
y += 25
if event.event_date:
date_value = event.event_date.strftime("%d.%m.%Y")
time_value = event.event_date.strftime("%H:%M")
local_event_date = utc_to_local(event.event_date)
date_value = local_event_date.strftime("%d.%m.%Y")
time_value = local_event_date.strftime("%H:%M")
y = _draw_info_row(draw, "Дата", date_value, x_content, y, content_width, label_font, body_font, text_color)
y = _draw_info_row(draw, "Время", time_value, x_content, y, content_width, label_font, body_font, text_color)
if event.location:
@@ -302,6 +305,9 @@ def generate_ticket_image(guest, event, output_path=None):
# QR-код
if event.ticket_show_qr:
y += 15
qr_path = event_qr_folder(guest.event_id) / guest.qr_filename
if not qr_path.exists():
# fallback на старую корневую папку
qr_path = Path(current_app.config["QR_FOLDER"]) / guest.qr_filename
if qr_path.exists():
qr_size = 200
@@ -314,6 +320,9 @@ def generate_ticket_image(guest, event, output_path=None):
# Фото гостя
if event.ticket_show_photo and guest.photo_filename:
photo_path = event_photo_folder(guest.event_id) / guest.photo_filename
if not photo_path.exists():
# fallback на старую корневую папку
photo_path = Path(current_app.config["PHOTO_FOLDER"]) / guest.photo_filename
if photo_path.exists():
photo_img = Image.open(str(photo_path)).convert("RGB")
@@ -339,7 +348,7 @@ def generate_ticket_image(guest, event, output_path=None):
# Сохранение
if output_path is None:
tickets_folder = Path(current_app.config["UPLOAD_FOLDER"]) / "tickets"
tickets_folder = event_tickets_folder(event.id)
tickets_folder.mkdir(parents=True, exist_ok=True)
output_path = tickets_folder / f"ticket_{event.id}_{guest.token}.png"
@@ -352,6 +361,7 @@ def generate_preview_ticket(event, sample_guest=None):
if sample_guest is None:
from app.models import Guest
sample_guest = Guest(
event_id=event.id,
last_name="Иванов",
first_name="Иван",
middle_name="Иванович",
+15
View File
@@ -220,3 +220,18 @@ div[style*="max-width: 600px"] {
background: var(--surface-color) !important;
border: 1px solid var(--surface-border) !important;
}
.app-logo {
height: 36px;
width: auto;
max-width: 140px;
object-fit: contain;
}
.login-logo {
height: 80px;
width: auto;
max-width: 220px;
object-fit: contain;
filter: drop-shadow(0 0 12px var(--accent-glow));
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

+2 -1
View File
@@ -58,6 +58,7 @@
<h5 class="card-title">Действия</h5>
<a href="{{ url_for('main.admin_report') }}" class="btn btn-success">Выгрузить отчёт Excel</a>
<form action="{{ url_for('main.admin_send_invitations') }}" method="POST" class="d-inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn btn-outline-primary">Разослать приглашения</button>
</form>
</div>
@@ -77,7 +78,7 @@
<tbody>
{% for check_in in recent_check_ins %}
<tr>
<td>{{ check_in.checked_in_at.strftime('%d.%m.%Y %H:%M:%S') }}</td>
<td>{{ check_in.checked_in_at | localtime('%d.%m.%Y %H:%M:%S') }}</td>
<td>{{ check_in.guest.full_name }}</td>
<td>{{ check_in.guest.department }}</td>
</tr>
+46
View File
@@ -0,0 +1,46 @@
{% extends "base.html" %}
{% block title %}Рассылка приглашений — InviteMe{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-lg-8">
<div class="card">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center mb-4">
<h4 class="card-title mb-0">Рассылка приглашений</h4>
<a href="{{ url_for('main.admin_email_settings') }}" class="btn btn-outline-secondary btn-sm">Настройки почты</a>
</div>
{% if event_counts %}
<form method="POST" action="{{ url_for('main.admin_email_send') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="mb-4">
<label for="event_id" class="form-label">Мероприятие</label>
<select class="form-select" id="event_id" name="event_id" required>
<option value="">Выберите мероприятие</option>
{% for item in event_counts %}
<option value="{{ item.event.id }}">
{{ item.event.name }} — неотправлено: {{ item.unsent }}
</option>
{% endfor %}
</select>
</div>
<div class="alert alert-info">
Перед рассылкой убедитесь, что в <a href="{{ url_for('main.admin_email_settings') }}">настройках почты</a> указаны корректные SMTP-данные.
</div>
<button type="submit" class="btn btn-primary" onclick="return confirm('Начать рассылку приглашений?')">
Разослать приглашения
</button>
</form>
{% else %}
<div class="alert alert-warning">У вас пока нет мероприятий для рассылки.</div>
{% endif %}
</div>
</div>
</div>
</div>
{% endblock %}
+127
View File
@@ -0,0 +1,127 @@
{% extends "base.html" %}
{% block title %}Настройки почты — InviteMe{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-lg-8">
<div class="card mb-4">
<div class="card-body">
<h4 class="card-title mb-4">Настройки почты для рассылки приглашений</h4>
<form method="POST" action="{{ url_for('main.admin_email_settings') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="mb-4">
<label class="form-label d-block">Способ отправки</label>
<div class="form-check form-check-inline">
<input class="form-check-input" type="radio" name="mail_mode" id="mode_smtp" value="smtp" {% if settings.mail_mode == 'smtp' %}checked{% endif %}>
<label class="form-check-label" for="mode_smtp">SMTP</label>
</div>
<div class="form-check form-check-inline">
<input class="form-check-input" type="radio" name="mail_mode" id="mode_ews" value="ews" {% if settings.mail_mode == 'ews' %}checked{% endif %}>
<label class="form-check-label" for="mode_ews">EWS (Exchange)</label>
</div>
<div class="form-check form-check-inline">
<input class="form-check-input" type="radio" name="mail_mode" id="mode_outlook" value="outlook" {% if settings.mail_mode == 'outlook' %}checked{% endif %}>
<label class="form-check-label" for="mode_outlook">Локальный Outlook</label>
</div>
<div class="form-text text-muted">
SMTP/EWS — для серверной установки. Outlook — только если InviteMe запущен на ПК с Outlook.
</div>
</div>
<div id="common-fields">
<div class="mb-3">
<label for="mail_username" class="form-label">Логин / Email</label>
<input type="text" class="form-control" id="mail_username" name="mail_username" value="{{ settings.mail_username }}">
</div>
<div class="mb-3">
<label for="mail_password" class="form-label">Пароль</label>
<input type="password" class="form-control" id="mail_password" name="mail_password" placeholder="Оставьте пустым, чтобы не менять">
</div>
<div class="mb-4">
<label for="mail_default_sender" class="form-label">Имя отправителя (необязательно)</label>
<input type="text" class="form-control" id="mail_default_sender" name="mail_default_sender" value="{{ settings.mail_default_sender or '' }}">
</div>
</div>
<div id="smtp-fields" {% if settings.mail_mode != 'smtp' %}style="display:none;"{% endif %}>
<div class="row">
<div class="col-md-8 mb-3">
<label for="mail_server" class="form-label">SMTP-сервер</label>
<input type="text" class="form-control" id="mail_server" name="mail_server" value="{{ settings.mail_server }}">
</div>
<div class="col-md-4 mb-3">
<label for="mail_port" class="form-label">Порт</label>
<input type="number" class="form-control" id="mail_port" name="mail_port" value="{{ settings.mail_port }}">
</div>
</div>
<div class="mb-4 form-check">
<input type="checkbox" class="form-check-input" id="mail_use_ssl" name="mail_use_ssl" value="1" {% if settings.mail_use_ssl %}checked{% endif %}>
<label class="form-check-label" for="mail_use_ssl">Использовать SSL</label>
</div>
</div>
<div id="ews-fields" {% if settings.mail_mode != 'ews' %}style="display:none;"{% endif %}>
<div class="mb-4">
<label for="ews_url" class="form-label">EWS URL</label>
<input type="text" class="form-control" id="ews_url" name="ews_url" value="{{ settings.ews_url or '' }}" placeholder="https://mail.company.com/EWS/Exchange.asmx">
<div class="form-text text-muted">URL вида <code>https://mail.integrita.ru/EWS/Exchange.asmx</code></div>
</div>
</div>
<div id="outlook-info" {% if settings.mail_mode != 'outlook' %}style="display:none;"{% endif %} class="alert alert-info">
Outlook должен быть установлен и запущен на сервере, где работает приложение. Браузер не может запустить Outlook на твоём компьютере.
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">Сохранить настройки</button>
<a href="{{ url_for('main.admin_email_send') }}" class="btn btn-outline-primary">Перейти к рассылке</a>
</div>
</form>
</div>
</div>
<div class="card">
<div class="card-body">
<h5 class="card-title mb-3">Отправить тестовое письмо</h5>
<form method="POST" action="{{ url_for('main.admin_email_test') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="input-group">
<input type="email" class="form-control" name="test_address" placeholder="address@example.com" required>
<button class="btn btn-success" type="submit">Отправить тест</button>
</div>
</form>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
const radios = document.querySelectorAll('input[name="mail_mode"]');
const smtpFields = document.getElementById('smtp-fields');
const ewsFields = document.getElementById('ews-fields');
const outlookInfo = document.getElementById('outlook-info');
const commonInputs = document.querySelectorAll('#common-fields input, #smtp-fields input, #ews-fields input');
function toggleFields() {
const mode = document.querySelector('input[name="mail_mode"]:checked').value;
smtpFields.style.display = mode === 'smtp' ? 'block' : 'none';
ewsFields.style.display = mode === 'ews' ? 'block' : 'none';
outlookInfo.style.display = mode === 'outlook' ? 'block' : 'none';
// disable hidden inputs to avoid sending stale data
document.querySelectorAll('#smtp-fields input, #ews-fields input').forEach(i => {
i.disabled = (mode === 'outlook') || (i.closest('#smtp-fields') && mode !== 'smtp') || (i.closest('#ews-fields') && mode !== 'ews');
});
document.querySelectorAll('#common-fields input').forEach(i => i.disabled = mode === 'outlook');
}
radios.forEach(r => r.addEventListener('change', toggleFields));
toggleFields();
</script>
{% endblock %}
+2 -1
View File
@@ -8,6 +8,7 @@
<div class="row">
<div class="col-md-6">
<form method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="mb-3">
<label for="name" class="form-label">Название *</label>
<input type="text" class="form-control" id="name" name="name" value="{{ event.name if event else '' }}" required>
@@ -23,7 +24,7 @@
<div class="mb-3">
<label for="event_date" class="form-label">Дата и время</label>
<input type="datetime-local" class="form-control" id="event_date" name="event_date"
value="{{ event.event_date.strftime('%Y-%m-%dT%H:%M') if event and event.event_date else '' }}">
value="{{ event.event_date | localtime('%Y-%m-%dT%H:%M') if event and event.event_date else '' }}">
</div>
{% if event %}
<div class="mb-3 form-check">
+36 -2
View File
@@ -16,7 +16,7 @@
<h5 class="card-title">{{ event.name }}</h5>
<p class="card-text text-muted">
{% if event.event_date %}
{{ event.event_date.strftime('%d.%m.%Y %H:%M') }}<br>
{{ event.event_date | localtime('%d.%m.%Y %H:%M') }}<br>
{% endif %}
{% if event.location %}
{{ event.location }}<br>
@@ -28,20 +28,54 @@
{% endif %}
<div class="d-flex flex-wrap gap-2">
<form action="{{ url_for('main.admin_event_switch', event_id=event.id) }}" method="POST" class="d-inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn btn-success btn-sm">Выбрать</button>
</form>
<a href="{{ url_for('main.admin_event_edit', event_id=event.id) }}" class="btn btn-outline-primary btn-sm">Изменить</a>
<a href="{{ url_for('main.admin_event_ticket', event_id=event.id) }}" class="btn btn-outline-info btn-sm">Билет</a>
{% if current_user and current_user.is_admin %}
<button type="button" class="btn btn-outline-danger btn-sm" data-bs-toggle="modal" data-bs-target="#deleteEventModal{{ event.id }}">Удалить</button>
{% endif %}
{% if not event.is_active %}
<span class="badge bg-secondary">Неактивно</span>
{% endif %}
</div>
</div>
<div class="card-footer text-muted">
Создано: {{ event.created_at.strftime('%d.%m.%Y') }}
Создано: {{ event.created_at | localdate('%d.%m.%Y') }}
</div>
</div>
</div>
{% if current_user and current_user.is_admin %}
<!-- Modal for hard delete -->
<div class="modal fade" id="deleteEventModal{{ event.id }}" tabindex="-1" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<form action="{{ url_for('main.admin_event_delete_hard', event_id=event.id) }}" method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="modal-header">
<h5 class="modal-title">Удаление мероприятия</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<p>Вы собираетесь полностью удалить мероприятие <strong>{{ event.name }}</strong>.</p>
<p class="text-danger">Все гости, QR-коды, билеты и логотипы этого мероприятия будут удалены безвозвратно.</p>
<div class="mb-3">
<label for="deleteReason{{ event.id }}" class="form-label">Причина удаления <span class="text-danger">*</span></label>
<textarea class="form-control" id="deleteReason{{ event.id }}" name="reason" rows="3" required></textarea>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Отмена</button>
<button type="submit" class="btn btn-danger">Удалить</button>
</div>
</form>
</div>
</div>
</div>
{% endif %}
{% else %}
<div class="col-12">
<div class="alert alert-info">Пока нет ни одного мероприятия. <a href="{{ url_for('main.admin_event_add') }}">Создайте первое</a>.</div>
+7
View File
@@ -8,7 +8,14 @@
<div class="row">
<div class="col-md-6">
{% if guest %}
<div class="mb-3">
<a href="{{ url_for('main.admin_guest_ticket', guest_id=guest.id) }}" target="_blank" class="btn btn-outline-info">Посмотреть билет</a>
<a href="{{ url_for('main.admin_guest_qr', guest_id=guest.id) }}" target="_blank" class="btn btn-outline-success">QR-код</a>
</div>
{% endif %}
<form method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="row">
<div class="col-md-6 mb-3">
<label for="last_name" class="form-label">Фамилия</label>
+12 -8
View File
@@ -10,7 +10,7 @@
<form method="GET" class="row g-3 mb-4">
<div class="col-auto">
<input type="text" name="q" class="form-control" placeholder="Поиск по ФИО" value="{{ search }}">
<input type="text" name="q" class="form-control" placeholder="Поиск по ФИО, отделу, email..." value="{{ search }}">
</div>
<div class="col-auto">
<button type="submit" class="btn btn-outline-primary">Найти</button>
@@ -40,17 +40,17 @@
<td>{{ guest.email }}</td>
<td>{{ guest.phone or '-' }}</td>
<td>
{% if guest.photo_filename %}
<span class="badge bg-success">Есть</span>
{% if guest.has_photo_file %}
<a href="{{ guest.photo_url }}" target="_blank" class="badge bg-success text-decoration-none">Есть</a>
{% else %}
<span class="badge bg-warning text-dark">Нет</span>
<span class="badge bg-warning text-dark" title="{% if guest.photo_filename %}Имя файла указано, но файл не загружен{% else %}Не указано{% endif %}">Нет</span>
{% endif %}
</td>
<td>
{% if guest.qr_filename %}
<span class="badge bg-success">Да</span>
<a href="{{ url_for('main.admin_guest_qr', guest_id=guest.id) }}" target="_blank" class="badge bg-success text-decoration-none">Да</a>
{% else %}
<span class="badge bg-secondary">Нет</span>
<a href="{{ url_for('main.admin_guest_qr', guest_id=guest.id) }}" target="_blank" class="badge bg-secondary text-decoration-none">Нет</a>
{% endif %}
</td>
<td>
@@ -62,10 +62,14 @@
</td>
<td>
<a href="{{ url_for('main.admin_guest_edit', guest_id=guest.id) }}" class="btn btn-sm btn-outline-primary">Изменить</a>
<form action="{{ url_for('main.admin_guest_resend', guest_id=guest.id) }}" method="POST" class="d-inline">
<button type="submit" class="btn btn-sm btn-outline-success">QR</button>
<a href="{{ url_for('main.admin_guest_ticket', guest_id=guest.id) }}" target="_blank" class="btn btn-sm btn-outline-info">Билет</a>
<a href="{{ url_for('main.admin_guest_qr', guest_id=guest.id) }}" target="_blank" class="btn btn-sm btn-outline-success">QR</a>
<form action="{{ url_for('main.admin_guest_resend', guest_id=guest.id) }}" method="POST" class="d-inline" onsubmit="return confirm('Отправить приглашение повторно?')">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn btn-sm btn-outline-warning">Пригласить</button>
</form>
<form action="{{ url_for('main.admin_guest_delete', guest_id=guest.id) }}" method="POST" class="d-inline" onsubmit="return confirm('Заблокировать гостя?')">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn btn-sm btn-outline-danger">Блок</button>
</form>
</td>
+88
View File
@@ -0,0 +1,88 @@
{% extends "base.html" %}
{% block title %}Аудит-лог — InviteMe{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h1>Аудит-лог</h1>
</div>
<form method="GET" class="row g-3 mb-4">
<div class="col-md-3">
<select name="action" class="form-select">
<option value="">Все действия</option>
{% for action in actions %}
<option value="{{ action }}" {% if action == action_filter %}selected{% endif %}>{{ action }}</option>
{% endfor %}
</select>
</div>
<div class="col-md-3">
<input type="text" name="user" class="form-control" placeholder="Пользователь" value="{{ user_filter }}">
</div>
<div class="col-md-3">
<button type="submit" class="btn btn-primary">Фильтр</button>
<a href="{{ url_for('main.admin_logs') }}" class="btn btn-secondary">Сброс</a>
</div>
</form>
<div class="table-responsive">
<table class="table table-sm table-striped table-hover">
<thead>
<tr>
<th>Время</th>
<th>Пользователь</th>
<th>IP</th>
<th>Действие</th>
<th>Объект</th>
<th>ID объекта</th>
<th>Детали</th>
</tr>
</thead>
<tbody>
{% for log in logs.items %}
<tr>
<td>{{ log.created_at | localtime('%d.%m.%Y %H:%M:%S') }}</td>
<td>{{ log.username or '-' }}</td>
<td>{{ log.ip_address or '-' }}</td>
<td><span class="badge bg-secondary">{{ log.action }}</span></td>
<td>{{ log.target_type or '-' }}</td>
<td>{{ log.target_id or '-' }}</td>
<td>{{ log.details or '-' }}</td>
</tr>
{% else %}
<tr>
<td colspan="7" class="text-center text-muted">Записи не найдены</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% if logs.pages > 1 %}
<nav aria-label="Page navigation">
<ul class="pagination justify-content-center">
{% if logs.has_prev %}
<li class="page-item">
<a class="page-link" href="{{ url_for('main.admin_logs', page=logs.prev_num, action=action_filter, user=user_filter) }}">Назад</a>
</li>
{% endif %}
{% for page_num in logs.iter_pages(left_edge=1, right_edge=1, left_current=2, right_current=2) %}
{% if page_num %}
{% if page_num == logs.page %}
<li class="page-item active"><span class="page-link">{{ page_num }}</span></li>
{% else %}
<li class="page-item"><a class="page-link" href="{{ url_for('main.admin_logs', page=page_num, action=action_filter, user=user_filter) }}">{{ page_num }}</a></li>
{% endif %}
{% else %}
<li class="page-item disabled"><span class="page-link">...</span></li>
{% endif %}
{% endfor %}
{% if logs.has_next %}
<li class="page-item">
<a class="page-link" href="{{ url_for('main.admin_logs', page=logs.next_num, action=action_filter, user=user_filter) }}">Вперёд</a>
</li>
{% endif %}
</ul>
</nav>
{% endif %}
{% endblock %}
+33
View File
@@ -0,0 +1,33 @@
{% extends "base.html" %}
{% block title %}Админ-панель — InviteMe{% endblock %}
{% block content %}
<div class="row mb-4">
<div class="col">
<h2>Админ-панель</h2>
<p class="text-muted">Все разделы управления в одном месте</p>
</div>
</div>
<div class="row row-cols-1 row-cols-md-2 row-cols-lg-3 g-4">
{% for card in cards %}
<div class="col">
<div class="card h-100 {% if card.disabled %}opacity-75{% endif %}">
<div class="card-body">
<div class="d-flex align-items-center mb-3">
<span style="font-size: 2rem; margin-right: 0.75rem;">{{ card.icon }}</span>
<h5 class="card-title mb-0">{{ card.title }}</h5>
</div>
<p class="card-text text-muted">{{ card.description }}</p>
{% if card.disabled %}
<a href="{{ card.url }}" class="btn btn-outline-secondary disabled">Сначала выберите мероприятие</a>
{% else %}
<a href="{{ card.url }}" class="btn btn-primary">{{ card.btn }}</a>
{% endif %}
</div>
</div>
</div>
{% endfor %}
</div>
{% endblock %}
+2 -1
View File
@@ -14,12 +14,13 @@
<div class="card-body">
<h5 class="card-title">Настройки дизайна</h5>
<form method="POST" id="ticket-form" enctype="multipart/form-data">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="mb-3">
<label for="logo_file" class="form-label">Логотип компании</label>
<input type="file" class="form-control" id="logo_file" name="logo_file" accept="image/*">
{% if event.logo_filename %}
<div class="mt-2 d-flex align-items-center gap-2">
<img src="{{ url_for('static', filename='uploads/logos/' + event.logo_filename) }}" alt="Логотип" style="max-height: 60px; max-width: 200px;">
<img src="{{ url_for('static', filename='uploads/logos/event_' + event.id|string + '/' + event.logo_filename) }}" alt="Логотип" style="max-height: 60px; max-width: 200px;">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="delete_logo" name="delete_logo" value="1">
<label class="form-check-label" for="delete_logo">Удалить</label>
+2
View File
@@ -13,6 +13,7 @@
<h5 class="card-title">1. Загрузить Excel со списком гостей</h5>
<p class="text-muted">Столбцы: Отдел, Фамилия, Имя, Отчество, Должность, Почта, Телефон, Название фото</p>
<form method="POST" enctype="multipart/form-data">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="mb-3">
<label for="excel_file" class="form-label">Excel-файл</label>
<input type="file" class="form-control" id="excel_file" name="excel_file" accept=".xlsx,.xls" required>
@@ -33,6 +34,7 @@
<h5 class="card-title">2. Загрузить фото гостей</h5>
<p class="text-muted">Фото должны называться точно так же, как указано в столбце «Название фото»</p>
<form method="POST" enctype="multipart/form-data">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="mb-3">
<label for="photo_files" class="form-label">Фото (можно несколько)</label>
<input type="file" class="form-control" id="photo_files" name="photo_files" accept="image/*" multiple>
+1
View File
@@ -8,6 +8,7 @@
<div class="row">
<div class="col-md-6">
<form method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="mb-3">
<label for="username" class="form-label">Логин *</label>
<input type="text" class="form-control" id="username" name="username" value="{{ user.username if user else '' }}" required>
+2 -1
View File
@@ -38,11 +38,12 @@
<span class="badge bg-secondary">Заблокирован</span>
{% endif %}
</td>
<td>{{ user.created_at.strftime('%d.%m.%Y %H:%M') }}</td>
<td>{{ user.created_at | localtime('%d.%m.%Y %H:%M') }}</td>
<td>
<a href="{{ url_for('main.admin_user_edit', user_id=user.id) }}" class="btn btn-sm btn-outline-primary">Изменить</a>
{% if current_user.id != user.id %}
<form action="{{ url_for('main.admin_user_delete', user_id=user.id) }}" method="POST" class="d-inline" onsubmit="return confirm('Заблокировать пользователя?')">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn btn-sm btn-outline-danger">Блок</button>
</form>
{% endif %}
+8 -22
View File
@@ -6,38 +6,23 @@
<title>{% block title %}InviteMe{% endblock %}</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
<link rel="icon" type="image/png" href="{{ url_for('static', filename='images/inviteme_logo.png') }}">
</head>
<body>
<nav class="navbar navbar-expand-lg navbar-dark">
<div class="container-fluid">
<a class="navbar-brand" href="{{ url_for('main.index') }}">InviteMe</a>
<a class="navbar-brand d-flex align-items-center" href="{{ url_for('main.index') }}">
<img src="{{ url_for('static', filename='images/inviteme_logo.png') }}" alt="InviteMe" class="app-logo">
</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav me-auto">
{% if current_user and current_user.role in ('admin', 'organizer') %}
<li class="nav-item">
<a class="nav-link" href="{{ url_for('main.admin_events') }}">Мероприятия</a>
</li>
{% if current_event %}
<li class="nav-item">
<a class="nav-link" href="{{ url_for('main.admin_dashboard') }}">Аналитика</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{{ url_for('main.admin_upload') }}">Загрузка</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{{ url_for('main.admin_guests') }}">Гости</a>
</li>
{% endif %}
{% endif %}
{% if current_user and current_user.is_admin %}
<li class="nav-item">
<a class="nav-link" href="{{ url_for('main.admin_users') }}">Пользователи</a>
</li>
{% endif %}
{% if current_user and current_user.role in ('admin', 'organizer', 'staff') %}
<li class="nav-item">
<a class="nav-link" href="{{ url_for('main.admin_panel') }}">Админ-панель</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{{ url_for('main.check_in') }}">Проверка приглашения</a>
</li>
@@ -55,6 +40,7 @@
{% for event in available_events %}
<li>
<form action="{{ url_for('main.admin_event_switch', event_id=event.id) }}" method="POST" class="d-inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="dropdown-item {% if current_event and current_event.id == event.id %}active{% endif %}">
{{ event.name }}
</button>
+1
View File
@@ -13,6 +13,7 @@
<div class="card-body">
<h5 class="card-title">Выберите мероприятие</h5>
<form method="POST" action="{{ url_for('main.check_in', token=token) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="mb-3">
<select id="event-select" name="event_id" class="form-select" onchange="this.form.submit()">
<option value="">-- Мероприятие --</option>
+32
View File
@@ -0,0 +1,32 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<title>Тестовое письмо InviteMe</title>
</head>
<body style="margin:0;padding:0;background-color:#0f172a;font-family:'Segoe UI',Roboto,Arial,sans-serif;">
<table width="100%" cellpadding="0" cellspacing="0" border="0" style="background-color:#0f172a;padding:40px 0;">
<tr>
<td align="center">
<table width="600" cellpadding="0" cellspacing="0" border="0" style="background:rgba(30,41,59,0.95);border:1px solid rgba(148,163,184,0.15);border-radius:16px;overflow:hidden;">
<tr>
<td style="padding:30px;text-align:center;">
<h2 style="color:#22d3ee;margin:0 0 16px 0;">InviteMe</h2>
<p style="color:#e2e8f0;font-size:16px;line-height:1.6;margin:0;">
Это тестовое письмо. Если вы его видите, SMTP-настройки работают корректно.
</p>
</td>
</tr>
<tr>
<td style="padding:20px 30px;background:rgba(15,23,42,0.6);text-align:center;">
<p style="color:#94a3b8;font-size:13px;margin:0;">
Отправлено из панели управления InviteMe
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>
+4
View File
@@ -7,9 +7,13 @@
<div class="col-md-4">
<div class="card mt-5">
<div class="card-body p-4">
<div class="text-center mb-3">
<img src="{{ url_for('static', filename='images/inviteme_logo.png') }}" alt="InviteMe" class="login-logo">
</div>
<h3 class="card-title text-center mb-4" style="color: var(--accent-color); text-shadow: 0 0 12px var(--accent-glow);">InviteMe</h3>
<p class="text-muted text-center mb-4">Система регистрации гостей</p>
<form method="POST" action="{{ url_for('main.login') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="mb-3">
<label for="username" class="form-label">Логин</label>
<input type="text" class="form-control" id="username" name="username" required autofocus>
View File
+28
View File
@@ -0,0 +1,28 @@
from pathlib import Path
from flask import current_app
def _base_upload_folder():
return Path(current_app.config["UPLOAD_FOLDER"])
def event_photo_folder(event_id):
return Path(current_app.config["PHOTO_FOLDER"]) / f"event_{event_id}"
def event_qr_folder(event_id):
return Path(current_app.config["QR_FOLDER"]) / f"event_{event_id}"
def event_logo_folder(event_id):
return Path(current_app.config["LOGO_FOLDER"]) / f"event_{event_id}"
def event_tickets_folder(event_id):
return _base_upload_folder() / "tickets" / f"event_{event_id}"
def ensure_event_folders(event_id):
"""Создаёт все папки для конкретного мероприятия."""
for folder in [event_photo_folder, event_qr_folder, event_logo_folder, event_tickets_folder]:
folder(event_id).mkdir(parents=True, exist_ok=True)
+27
View File
@@ -0,0 +1,27 @@
from datetime import datetime, timezone
from zoneinfo import ZoneInfo
from flask import current_app
def get_timezone():
tz_name = current_app.config.get("TIMEZONE", "UTC")
return ZoneInfo(tz_name)
def utc_to_local(dt):
"""Конвертирует наивное UTC-время в локальное наивное время."""
if dt is None:
return None
# Считаем, что хранимое время — UTC
utc_dt = dt.replace(tzinfo=timezone.utc) if dt.tzinfo is None else dt.astimezone(timezone.utc)
local_dt = utc_dt.astimezone(get_timezone())
return local_dt.replace(tzinfo=None)
def local_to_utc(dt):
"""Конвертирует наивное локальное время в UTC-наивное для хранения."""
if dt is None:
return None
local_dt = dt.replace(tzinfo=get_timezone())
utc_dt = local_dt.astimezone(timezone.utc)
return utc_dt.replace(tzinfo=None)
+17
View File
@@ -39,3 +39,20 @@ class Config:
# Default event name for migrating existing guests
DEFAULT_EVENT_NAME = os.environ.get("DEFAULT_EVENT_NAME") or "Архивное мероприятие"
# Local timezone for display (e.g. Europe/Moscow)
TIMEZONE = os.environ.get("TIMEZONE") or "Europe/Moscow"
# Session security (HTTPS is required for these flags)
SESSION_COOKIE_SECURE = os.environ.get("SESSION_COOKIE_SECURE", "true").lower() in ["true", "1", "yes"]
SESSION_COOKIE_HTTPONLY = True
SESSION_COOKIE_SAMESITE = "Lax"
PERMANENT_SESSION_LIFETIME = int(os.environ.get("PERMANENT_SESSION_LIFETIME", "28800")) # 8 часов
# CSRF protection
WTF_CSRF_ENABLED = True
WTF_CSRF_TIME_LIMIT = 3600
# Rate limiting
RATELIMIT_STORAGE_URI = os.environ.get("RATELIMIT_STORAGE_URI") or "memory://"
RATELIMIT_STRATEGY = "fixed-window"
+4
View File
@@ -1,5 +1,9 @@
Flask==3.0.0
Flask-SQLAlchemy==3.1.1
Flask-Limiter==4.1.1
Flask-WTF==1.3.0
WTForms==3.2.2
waitress==3.2.2
Werkzeug==3.0.1
openpyxl==3.1.2
qrcode==7.4.2
+9
View File
@@ -0,0 +1,9 @@
from waitress import serve
from app import create_app
app = create_app()
if __name__ == "__main__":
# host=0.0.0.0 — слушаем все интерфейсы
# port=8888 — порт, на который Nginx проксирует запросы
serve(app, host="0.0.0.0", port=8888, threads=4)