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 0000000..82b2b15 Binary files /dev/null and b/app/static/images/inviteme_logo.png differ diff --git a/app/templates/admin/dashboard.html b/app/templates/admin/dashboard.html index 7d744df..9f96569 100644 --- a/app/templates/admin/dashboard.html +++ b/app/templates/admin/dashboard.html @@ -58,6 +58,7 @@
Действия
Выгрузить отчёт 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 %} +