From 76a42f14d077c105a3ffbaadadd2774e6152f5d9 Mon Sep 17 00:00:00 2001 From: Admin Date: Mon, 31 Aug 2026 12:35:02 +0300 Subject: [PATCH] Update: admin panel, SMTP/EWS/Outlook mail modes, QR fix, event folders, security improvements --- app/__init__.py | 88 ++++ app/models.py | 109 ++++ app/routes.py | 551 +++++++++++++++++++- app/services/audit_service.py | 47 ++ app/services/email_service.py | 226 ++++++-- app/services/qr_service.py | 6 +- app/services/ticket_service.py | 22 +- app/static/css/style.css | 15 + app/static/images/inviteme_logo.png | Bin 0 -> 11426 bytes app/templates/admin/dashboard.html | 3 +- app/templates/admin/email_send.html | 46 ++ app/templates/admin/email_settings.html | 127 +++++ app/templates/admin/event_form.html | 3 +- app/templates/admin/events.html | 38 +- app/templates/admin/guest_form.html | 7 + app/templates/admin/guests.html | 20 +- app/templates/admin/logs.html | 88 ++++ app/templates/admin/panel.html | 33 ++ app/templates/admin/ticket_constructor.html | 3 +- app/templates/admin/upload.html | 2 + app/templates/admin/user_form.html | 1 + app/templates/admin/users.html | 3 +- app/templates/base.html | 30 +- app/templates/checkin/index.html | 1 + app/templates/email/test_email.html | 32 ++ app/templates/login.html | 4 + app/utils/__init__.py | 0 app/utils/paths.py | 28 + app/utils/timezone.py | 27 + config.py | 17 + requirements.txt | 4 + run_waitress.py | 9 + 32 files changed, 1474 insertions(+), 116 deletions(-) create mode 100644 app/services/audit_service.py create mode 100644 app/static/images/inviteme_logo.png create mode 100644 app/templates/admin/email_send.html create mode 100644 app/templates/admin/email_settings.html create mode 100644 app/templates/admin/logs.html create mode 100644 app/templates/admin/panel.html create mode 100644 app/templates/email/test_email.html create mode 100644 app/utils/__init__.py create mode 100644 app/utils/paths.py create mode 100644 app/utils/timezone.py create mode 100644 run_waitress.py diff --git a/app/__init__.py b/app/__init__.py index cbc14ae..796f326 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -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() diff --git a/app/models.py b/app/models.py index 116d021..afee8a1 100644 --- a/app/models.py +++ b/app/models.py @@ -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"" @@ -130,3 +163,79 @@ class CheckIn(db.Model): def __repr__(self): return f"" + + +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"" + + +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"" + diff --git a/app/routes.py b/app/routes.py index 5cea87e..6522321 100644 --- a/app/routes.py +++ b/app/routes.py @@ -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//delete-hard", methods=["POST"]) +@require_role("admin") +def admin_event_delete_hard(event_id): + """Полное удаление мероприятия вместе со связанными файлами и данными.""" + event = Event.query.get_or_404(event_id) + reason = request.form.get("reason", "").strip() + + if not reason: + flash("Для удаления необходимо указать причину", "danger") + return redirect(url_for("main.admin_events")) + + if session.get("current_event_id") == event.id: + session.pop("current_event_id", None) + + # Удаляем связанные файлы + import shutil + + def _safe_rmtree(path): + try: + if path.exists(): + shutil.rmtree(str(path)) + except Exception as e: + current_app.logger.warning(f"Не удалось удалить папку {path}: {e}") + + # Удаляем папки мероприятия целиком + _safe_rmtree(event_photo_folder(event.id)) + _safe_rmtree(event_qr_folder(event.id)) + _safe_rmtree(event_logo_folder(event.id)) + _safe_rmtree(event_tickets_folder(event.id)) + + event_name = event.name + log_action( + "event_delete_hard", + target_type="event", + target_id=event.id, + details=f"Удалено мероприятие '{event_name}'. Причина: {reason}", + ) + + db.session.delete(event) + db.session.commit() + + flash(f"Мероприятие «{event_name}» и все связанные данные удалены", "success") + return redirect(url_for("main.admin_events")) + + @main_bp.route("/admin/events//switch", methods=["POST"]) @require_role("admin", "organizer") def admin_event_switch(event_id): @@ -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//ticket") +@require_role("admin", "organizer") +@require_event +def admin_guest_ticket(guest_id): + """Генерирует и отдаёт PNG-билет для конкретного гостя.""" + event = get_current_event() + guest = Guest.query.filter_by(id=guest_id, event_id=event.id).first_or_404() + try: + ticket_path = generate_ticket_image(guest, event) + log_action( + "guest_ticket_view", + target_type="guest", + target_id=guest.id, + details=f"Просмотр билета гостя '{guest.build_full_name()}'", + ) + db.session.commit() + return send_file(str(ticket_path), mimetype="image/png") + except Exception as e: + flash(f"Ошибка генерации билета: {str(e)}", "danger") + return redirect(url_for("main.admin_guests")) + + +@main_bp.route("/admin/guests//qr") +@require_role("admin", "organizer") +@require_event +def admin_guest_qr(guest_id): + """Отдаёт QR-код гостя без отправки письма.""" + event = get_current_event() + guest = Guest.query.filter_by(id=guest_id, event_id=event.id).first_or_404() + if not guest.qr_filename: + generate_qr_for_guest(guest) + db.session.commit() + + qr_path = 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/", 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/") @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 diff --git a/app/services/audit_service.py b/app/services/audit_service.py new file mode 100644 index 0000000..18a9bde --- /dev/null +++ b/app/services/audit_service.py @@ -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() после основных изменений. diff --git a/app/services/email_service.py b/app/services/email_service.py index 9df1f58..2502c2d 100644 --- a/app/services/email_service.py +++ b/app/services/email_service.py @@ -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", "") - 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", "") - 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 diff --git a/app/services/qr_service.py b/app/services/qr_service.py index 5daf8b9..7ffc705 100644 --- a/app/services/qr_service.py +++ b/app/services/qr_service.py @@ -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" diff --git a/app/services/ticket_service.py b/app/services/ticket_service.py index 7b46e68..3c077c4 100644 --- a/app/services/ticket_service.py +++ b/app/services/ticket_service.py @@ -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,7 +305,10 @@ def generate_ticket_image(guest, event, output_path=None): # QR-код if event.ticket_show_qr: y += 15 - qr_path = Path(current_app.config["QR_FOLDER"]) / guest.qr_filename + 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 qr_img = Image.open(str(qr_path)).convert("RGB") @@ -314,7 +320,10 @@ def generate_ticket_image(guest, event, output_path=None): # Фото гостя if event.ticket_show_photo and guest.photo_filename: - photo_path = Path(current_app.config["PHOTO_FOLDER"]) / 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") photo_img.thumbnail((130, 130)) @@ -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="Иванович", diff --git a/app/static/css/style.css b/app/static/css/style.css index d356135..9297c57 100644 --- a/app/static/css/style.css +++ b/app/static/css/style.css @@ -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)); +} diff --git a/app/static/images/inviteme_logo.png b/app/static/images/inviteme_logo.png new file mode 100644 index 0000000000000000000000000000000000000000..82b2b15af29d1c4f31c278583fc56128d92f1189 GIT binary patch literal 11426 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Действия Выгрузить отчёт Excel
+
@@ -77,7 +78,7 @@ {% for check_in in recent_check_ins %} - {{ check_in.checked_in_at.strftime('%d.%m.%Y %H:%M:%S') }} + {{ check_in.checked_in_at | localtime('%d.%m.%Y %H:%M:%S') }} {{ check_in.guest.full_name }} {{ check_in.guest.department }} diff --git a/app/templates/admin/email_send.html b/app/templates/admin/email_send.html new file mode 100644 index 0000000..926007a --- /dev/null +++ b/app/templates/admin/email_send.html @@ -0,0 +1,46 @@ +{% extends "base.html" %} + +{% block title %}Рассылка приглашений — InviteMe{% endblock %} + +{% block content %} +
+
+
+
+
+

Рассылка приглашений

+ Настройки почты +
+ + {% if event_counts %} +
+ + +
+ + +
+ +
+ Перед рассылкой убедитесь, что в настройках почты указаны корректные SMTP-данные. +
+ + +
+ {% else %} +
У вас пока нет мероприятий для рассылки.
+ {% endif %} +
+
+
+
+{% endblock %} diff --git a/app/templates/admin/email_settings.html b/app/templates/admin/email_settings.html new file mode 100644 index 0000000..f66fe1b --- /dev/null +++ b/app/templates/admin/email_settings.html @@ -0,0 +1,127 @@ +{% extends "base.html" %} + +{% block title %}Настройки почты — InviteMe{% endblock %} + +{% block content %} +
+
+
+
+

Настройки почты для рассылки приглашений

+
+ + +
+ +
+ + +
+
+ + +
+
+ + +
+
+ SMTP/EWS — для серверной установки. Outlook — только если InviteMe запущен на ПК с Outlook. +
+
+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+
+ +
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ +
+
+ + +
URL вида https://mail.integrita.ru/EWS/Exchange.asmx
+
+
+ +
+ Outlook должен быть установлен и запущен на сервере, где работает приложение. Браузер не может запустить Outlook на твоём компьютере. +
+ +
+ + Перейти к рассылке +
+
+
+
+ +
+
+
Отправить тестовое письмо
+
+ +
+ + +
+
+
+
+
+
+{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/app/templates/admin/event_form.html b/app/templates/admin/event_form.html index bce1bf3..55f0234 100644 --- a/app/templates/admin/event_form.html +++ b/app/templates/admin/event_form.html @@ -8,6 +8,7 @@
+
@@ -23,7 +24,7 @@
+ value="{{ event.event_date | localtime('%Y-%m-%dT%H:%M') if event and event.event_date else '' }}">
{% if event %}
diff --git a/app/templates/admin/events.html b/app/templates/admin/events.html index 7c1240e..4323963 100644 --- a/app/templates/admin/events.html +++ b/app/templates/admin/events.html @@ -16,7 +16,7 @@
{{ event.name }}

{% if event.event_date %} - {{ event.event_date.strftime('%d.%m.%Y %H:%M') }}
+ {{ event.event_date | localtime('%d.%m.%Y %H:%M') }}
{% endif %} {% if event.location %} {{ event.location }}
@@ -28,20 +28,54 @@ {% endif %}

+ Изменить Билет + {% if current_user and current_user.is_admin %} + + {% endif %} {% if not event.is_active %} Неактивно {% endif %}
+ + {% if current_user and current_user.is_admin %} + + + {% endif %} + {% else %}
Пока нет ни одного мероприятия. Создайте первое.
diff --git a/app/templates/admin/guest_form.html b/app/templates/admin/guest_form.html index 21eac38..ff4702f 100644 --- a/app/templates/admin/guest_form.html +++ b/app/templates/admin/guest_form.html @@ -8,7 +8,14 @@
+ {% if guest %} + + {% endif %}
+
diff --git a/app/templates/admin/guests.html b/app/templates/admin/guests.html index 275339a..c2b8f3d 100644 --- a/app/templates/admin/guests.html +++ b/app/templates/admin/guests.html @@ -10,7 +10,7 @@
- +
@@ -40,17 +40,17 @@ {{ guest.email }} {{ guest.phone or '-' }} - {% if guest.photo_filename %} - Есть + {% if guest.has_photo_file %} + Есть {% else %} - Нет + Нет {% endif %} {% if guest.qr_filename %} - Да + Да {% else %} - Нет + Нет {% endif %} @@ -62,10 +62,14 @@ Изменить - - + Билет + QR + + +
+
diff --git a/app/templates/admin/logs.html b/app/templates/admin/logs.html new file mode 100644 index 0000000..a9b92bb --- /dev/null +++ b/app/templates/admin/logs.html @@ -0,0 +1,88 @@ +{% extends "base.html" %} + +{% block title %}Аудит-лог — InviteMe{% endblock %} + +{% block content %} +
+

Аудит-лог

+
+ +
+
+ +
+
+ +
+
+ + Сброс +
+
+ +
+ + + + + + + + + + + + + + {% for log in logs.items %} + + + + + + + + + + {% else %} + + + + {% endfor %} + +
ВремяПользовательIPДействиеОбъектID объектаДетали
{{ log.created_at | localtime('%d.%m.%Y %H:%M:%S') }}{{ log.username or '-' }}{{ log.ip_address or '-' }}{{ log.action }}{{ log.target_type or '-' }}{{ log.target_id or '-' }}{{ log.details or '-' }}
Записи не найдены
+
+ +{% if logs.pages > 1 %} + +{% endif %} +{% endblock %} diff --git a/app/templates/admin/panel.html b/app/templates/admin/panel.html new file mode 100644 index 0000000..f1e88c7 --- /dev/null +++ b/app/templates/admin/panel.html @@ -0,0 +1,33 @@ +{% extends "base.html" %} + +{% block title %}Админ-панель — InviteMe{% endblock %} + +{% block content %} +
+
+

Админ-панель

+

Все разделы управления в одном месте

+
+
+ +
+ {% for card in cards %} +
+
+
+
+ {{ card.icon }} +
{{ card.title }}
+
+

{{ card.description }}

+ {% if card.disabled %} + Сначала выберите мероприятие + {% else %} + {{ card.btn }} + {% endif %} +
+
+
+ {% endfor %} +
+{% endblock %} diff --git a/app/templates/admin/ticket_constructor.html b/app/templates/admin/ticket_constructor.html index 425f59b..877903e 100644 --- a/app/templates/admin/ticket_constructor.html +++ b/app/templates/admin/ticket_constructor.html @@ -14,12 +14,13 @@
Настройки дизайна
+
{% if event.logo_filename %}
- Логотип + Логотип
diff --git a/app/templates/admin/upload.html b/app/templates/admin/upload.html index b59dde7..6a0796c 100644 --- a/app/templates/admin/upload.html +++ b/app/templates/admin/upload.html @@ -13,6 +13,7 @@
1. Загрузить Excel со списком гостей

Столбцы: Отдел, Фамилия, Имя, Отчество, Должность, Почта, Телефон, Название фото

+
@@ -33,6 +34,7 @@
2. Загрузить фото гостей

Фото должны называться точно так же, как указано в столбце «Название фото»

+
diff --git a/app/templates/admin/user_form.html b/app/templates/admin/user_form.html index 788d0d1..7d6467d 100644 --- a/app/templates/admin/user_form.html +++ b/app/templates/admin/user_form.html @@ -8,6 +8,7 @@
+
diff --git a/app/templates/admin/users.html b/app/templates/admin/users.html index 2e931fb..17cc1de 100644 --- a/app/templates/admin/users.html +++ b/app/templates/admin/users.html @@ -38,11 +38,12 @@ Заблокирован {% endif %} - {{ user.created_at.strftime('%d.%m.%Y %H:%M') }} + {{ user.created_at | localtime('%d.%m.%Y %H:%M') }} Изменить {% if current_user.id != user.id %} + {% endif %} diff --git a/app/templates/base.html b/app/templates/base.html index 959ea83..db50f51 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -6,38 +6,23 @@ {% block title %}InviteMe{% endblock %} +