From d54a17b0ad4b095bdc65ca24f15155c47f5ffdea Mon Sep 17 00:00:00 2001 From: InviteMe Developer Date: Tue, 1 Sep 2026 10:37:40 +0300 Subject: [PATCH] Initial commit: InviteMe production redesign --- .dockerignore | 15 + .env.example | 24 + .gitignore | 30 + Caddyfile | 7 + Dockerfile | 31 + README.md | 69 + app/__init__.py | 318 +++ app/models.py | 337 ++++ app/routes.py | 1714 +++++++++++++++++ app/services/__init__.py | 0 app/services/audit_service.py | 47 + app/services/email_service.py | 291 +++ app/services/excel_service.py | 208 ++ app/services/qr_service.py | 43 + app/services/ticket_service.py | 348 ++++ app/static/css/style.css | 5 + app/static/images/icons.svg | 14 + app/static/images/inviteme_logo.png | Bin 0 -> 11426 bytes app/static/js/app.js | 19 + app/static/js/checkin.js | 333 ++++ app/static/js/email_settings.js | 25 + app/static/js/ticket_constructor.js | 6 + app/static/uploads/logos/.gitkeep | 0 app/static/uploads/photos/.gitkeep | 0 app/static/uploads/qrcodes/.gitkeep | 0 app/static/uploads/reports/.gitkeep | 0 app/static/uploads/temp/.gitkeep | 0 app/static/uploads/tickets/.gitkeep | 0 app/tasks.py | 50 + app/templates/admin/dashboard.html | 64 + app/templates/admin/email_send.html | 46 + app/templates/admin/email_settings.html | 143 ++ app/templates/admin/event_form.html | 83 + app/templates/admin/events.html | 82 + app/templates/admin/guest_form.html | 68 + app/templates/admin/guests.html | 110 ++ app/templates/admin/logs.html | 88 + app/templates/admin/panel.html | 21 + app/templates/admin/ticket_constructor.html | 94 + app/templates/admin/upload.html | 112 ++ app/templates/admin/user_form.html | 40 + app/templates/admin/users.html | 59 + app/templates/base.html | 49 + app/templates/checkin/index.html | 92 + app/templates/email/invitation.html | 44 + app/templates/email/test_email.html | 32 + app/templates/invitation.html | 78 + app/templates/login.html | 25 + app/templates/setup.html | 32 + app/utils/__init__.py | 0 app/utils/paths.py | 39 + app/utils/queue.py | 35 + app/utils/secrets.py | 34 + app/utils/security.py | 121 ++ app/utils/timezone.py | 27 + config.py | 110 ++ docker-compose.yml | 123 ++ docs/CONTEXT.md | 195 ++ docs/DEPLOY.md | 21 + docs/PRODUCTION.md | 90 + migrations/README | 1 + migrations/alembic.ini | 50 + migrations/env.py | 113 ++ migrations/script.py.mako | 24 + .../5e84226bd650_initial_production_schema.py | 139 ++ ...4d3a0_add_delivery_reliability_and_rsvp.py | 102 + requirements-dev.txt | 4 + run.py | 6 + run_waitress.py | 21 + tests/conftest.py | 47 + tests/test_security.py | 300 +++ 71 files changed, 6898 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Caddyfile create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 app/__init__.py create mode 100644 app/models.py create mode 100644 app/routes.py create mode 100644 app/services/__init__.py create mode 100644 app/services/audit_service.py create mode 100644 app/services/email_service.py create mode 100644 app/services/excel_service.py create mode 100644 app/services/qr_service.py create mode 100644 app/services/ticket_service.py create mode 100644 app/static/css/style.css create mode 100644 app/static/images/icons.svg create mode 100644 app/static/images/inviteme_logo.png create mode 100644 app/static/js/app.js create mode 100644 app/static/js/checkin.js create mode 100644 app/static/js/email_settings.js create mode 100644 app/static/js/ticket_constructor.js create mode 100644 app/static/uploads/logos/.gitkeep create mode 100644 app/static/uploads/photos/.gitkeep create mode 100644 app/static/uploads/qrcodes/.gitkeep create mode 100644 app/static/uploads/reports/.gitkeep create mode 100644 app/static/uploads/temp/.gitkeep create mode 100644 app/static/uploads/tickets/.gitkeep create mode 100644 app/tasks.py create mode 100644 app/templates/admin/dashboard.html create mode 100644 app/templates/admin/email_send.html create mode 100644 app/templates/admin/email_settings.html create mode 100644 app/templates/admin/event_form.html create mode 100644 app/templates/admin/events.html create mode 100644 app/templates/admin/guest_form.html create mode 100644 app/templates/admin/guests.html create mode 100644 app/templates/admin/logs.html create mode 100644 app/templates/admin/panel.html create mode 100644 app/templates/admin/ticket_constructor.html create mode 100644 app/templates/admin/upload.html create mode 100644 app/templates/admin/user_form.html create mode 100644 app/templates/admin/users.html create mode 100644 app/templates/base.html create mode 100644 app/templates/checkin/index.html create mode 100644 app/templates/email/invitation.html create mode 100644 app/templates/email/test_email.html create mode 100644 app/templates/invitation.html create mode 100644 app/templates/login.html create mode 100644 app/templates/setup.html create mode 100644 app/utils/__init__.py create mode 100644 app/utils/paths.py create mode 100644 app/utils/queue.py create mode 100644 app/utils/secrets.py create mode 100644 app/utils/security.py create mode 100644 app/utils/timezone.py create mode 100644 config.py create mode 100644 docker-compose.yml create mode 100644 docs/CONTEXT.md create mode 100644 docs/DEPLOY.md create mode 100644 docs/PRODUCTION.md create mode 100644 migrations/README create mode 100644 migrations/alembic.ini create mode 100644 migrations/env.py create mode 100644 migrations/script.py.mako create mode 100644 migrations/versions/5e84226bd650_initial_production_schema.py create mode 100644 migrations/versions/8c365be4d3a0_add_delivery_reliability_and_rsvp.py create mode 100644 requirements-dev.txt create mode 100644 run.py create mode 100644 run_waitress.py create mode 100644 tests/conftest.py create mode 100644 tests/test_security.py diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..468de38 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,15 @@ +.git +.gitignore +venv/ +.env +.env.* +instance/ +__pycache__/ +*.pyc +*.pyo +*.pyd +.Python +*.db +*.sqlite3 +app/static/uploads/* +!app/static/uploads/.gitkeep diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..857ea4b --- /dev/null +++ b/.env.example @@ -0,0 +1,24 @@ +# Production Docker Compose configuration +DOMAIN=events.example.com +TIMEZONE=Europe/Moscow + +# Generate independently with: openssl rand -hex 32 +SECRET_KEY= +CREDENTIAL_ENCRYPTION_KEY= +POSTGRES_PASSWORD= + +# Optional mail configuration. Passwords entered in the UI are encrypted at rest. +MAIL_SERVER=smtp.example.com +MAIL_PORT=465 +MAIL_USE_SSL=true +MAIL_USERNAME=noreply@example.com +MAIL_PASSWORD= +MAIL_DEFAULT_SENDER=InviteMe + +# Local development overrides (never use these values in production) +# APP_ENV=development +# DATABASE_URL=sqlite:///instance/inviteme.db +# BASE_URL=http://127.0.0.1:5000 +# SESSION_COOKIE_SECURE=false +# ENFORCE_HTTPS=false +# ALLOW_WEB_SETUP=true diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f71e926 --- /dev/null +++ b/.gitignore @@ -0,0 +1,30 @@ +.env +.env.* +!.env.example +venv/ +__pycache__/ +*.pyc +*.pyo +*.pyd +.Python + +instance/ +*.db +*.sqlite3 +*.dump +*.sql +*.bak +inviteme-uploads*.tar.gz + +app/static/uploads/** +!app/static/uploads/**/ +!app/static/uploads/**/.gitkeep + +*.log +.DS_Store +Thumbs.db + +# Локальные данные и скриншоты +скрин/ +*.txt +!requirements-dev.txt diff --git a/Caddyfile b/Caddyfile new file mode 100644 index 0000000..13f1d14 --- /dev/null +++ b/Caddyfile @@ -0,0 +1,7 @@ +{$DOMAIN} { + encode zstd gzip + request_body { + max_size 32MB + } + reverse_proxy inviteme:8888 +} diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..db4b308 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,31 @@ +FROM python:3.12-slim + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PIP_NO_CACHE_DIR=1 + +WORKDIR /app + +RUN apt-get update && apt-get install -y --no-install-recommends \ + fonts-dejavu-core \ + fonts-liberation \ + && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt . +RUN python -m pip install --upgrade pip==26.2.1 \ + && pip install --requirement requirements.txt + +COPY . . +RUN addgroup --system inviteme \ + && adduser --system --ingroup inviteme --home /app inviteme \ + && mkdir -p /app/instance/uploads \ + && chown -R inviteme:inviteme /app + +USER inviteme + +EXPOSE 8888 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \ + CMD ["python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8888/healthz', timeout=3)"] + +CMD ["python", "run_waitress.py"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..6034ebb --- /dev/null +++ b/README.md @@ -0,0 +1,69 @@ +# InviteMe + +Production-система регистрации гостей, рассылки приглашений, выпуска QR-билетов и контроля входа на мероприятия. + +## Возможности + +- Мероприятия и ролевая модель: администратор, организатор и сотрудник на входе. +- Импорт гостей из `.xlsx`, закрытое хранение фотографий и генерация QR-кодов. +- Фоновая рассылка через SMTP, EWS или локальный Outlook с индивидуальными статусами доставки. +- Персональная страница приглашения: RSVP, комментарий, особенности питания, билет и календарь `.ics`. +- Сканирование на входе, отчёты о посещаемости и журнал аудита. +- PostgreSQL, общие лимиты запросов через Redis, автоматический HTTPS и миграции базы. + +## Запуск в production + +Нужны Linux-сервер с Docker Compose, домен, направленный на сервер, и открытые входящие порты 80/443. + +```bash +cp .env.example .env +# Заполните DOMAIN и сгенерируйте три секрета в .env. +docker compose up -d --build +docker compose exec inviteme flask --app run.py create-admin +``` + +Caddy автоматически получает и продлевает TLS-сертификат. Контейнер приложения напрямую наружу не публикуется. + +Полная инструкция по установке, резервному копированию и обновлению: [docs/PRODUCTION.md](docs/PRODUCTION.md). + +## Локальная разработка + +```bash +python -m venv venv +venv/bin/pip install -r requirements-dev.txt +venv/bin/python run.py +``` + +На Windows замените `venv/bin/` на `venv\Scripts\`. Локальный режим использует SQLite и разрешает создание первого администратора через веб-страницу. + +## Безопасность + +- Production не запускается без уникальных секретов и обязательных безопасных настроек. +- Фотографии, QR-коды, билеты и отчёты находятся вне `static` и выдаются только после проверки доступа. +- Организатор видит только собственные мероприятия, а сотрудник на входе — только назначенные ему. +- Двойная отметка входа блокируется базой, ошибочный вход отменяется с обязательной записью в аудит. +- Изображения ограничиваются по размеру, декодируются, проверяются и сохраняются заново. SVG и анимация запрещены. +- Почтовые пароли шифруются перед записью в базу. +- Включены CSRF, защищённые cookies, HTTPS, security headers и лимиты запросов через Redis. +- Контейнер работает без root, с файловой системой только для чтения и без Linux capabilities. + +## Проверка + +```bash +python -m pytest -q +pip-audit -r requirements.txt +``` + +## Структура + +```text +app/ Flask-приложение +instance/uploads/ закрытые файлы в локальном режиме +migrations/ миграции Alembic +tests/ регрессионные тесты безопасности +docs/PRODUCTION.md инструкция по эксплуатации +``` + +## Лицензия + +Открытая лицензия не предоставляется. Исходный код предназначен только для разрешённого правообладателем использования. diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..98c480f --- /dev/null +++ b/app/__init__.py @@ -0,0 +1,318 @@ +import os +import logging +import secrets +from pathlib import Path + +import click +from flask import Flask, jsonify, redirect, request +from flask_sqlalchemy import SQLAlchemy +from flask_limiter import Limiter +from flask_limiter.util import get_remote_address +from flask_migrate import Migrate +from flask_wtf.csrf import CSRFProtect +from sqlalchemy import inspect, text +from werkzeug.middleware.proxy_fix import ProxyFix + +from config import Config + +logger = logging.getLogger(__name__) +db = SQLAlchemy() +limiter = Limiter(key_func=get_remote_address) +csrf = CSRFProtect() +migrate = Migrate() + + +def create_app(config_class=Config): + if hasattr(config_class, "validate"): + config_class.validate() + + app = Flask(__name__) + app.config.from_object(config_class) + + if app.config.get("TRUST_PROXY_HEADERS"): + app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1) + + # Ensure upload directories exist + os.makedirs(app.config["PHOTO_FOLDER"], exist_ok=True) + os.makedirs(app.config["QR_FOLDER"], exist_ok=True) + os.makedirs(app.config["LOGO_FOLDER"], exist_ok=True) + database_uri = app.config["SQLALCHEMY_DATABASE_URI"] + if database_uri.startswith("sqlite:///"): + os.makedirs(os.path.dirname(database_uri.replace("sqlite:///", "", 1)), exist_ok=True) + + db.init_app(app) + limiter.init_app(app) + csrf.init_app(app) + migrate.init_app(app, db) + + from app.routes import main_bp + app.register_blueprint(main_bp) + _register_security_hooks(app) + _register_cli(app) + + with app.app_context(): + if not app.config.get("SKIP_DB_BOOTSTRAP"): + if not app.config.get("IS_PRODUCTION"): + db.create_all() + _migrate_existing_data(app) + _migrate_legacy_public_uploads(app) + _load_smtp_settings(app) + + return app + + +def _load_smtp_settings(app): + """Загружает SMTP-настройки из базы в конфиг приложения.""" + try: + from app.models import SmtpSettings + settings = SmtpSettings.query.first() + if settings and settings.encrypt_legacy_passwords(): + db.session.commit() + SmtpSettings.apply_to_app(app) + except Exception as e: + logger.warning(f"SMTP settings load skipped: {e}") + + +def _column_exists(table_name, column_name): + inspector = inspect(db.engine) + if table_name not in inspector.get_table_names(): + return False + return any(column["name"] == column_name for column in inspector.get_columns(table_name)) + + +def _add_column_if_missing(table_name, column_name, column_def): + if not _column_exists(table_name, column_name): + 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_legacy_public_uploads(app): + """Move files from old public static/uploads into the private data directory.""" + legacy_root = Path(app.root_path) / "static" / "uploads" + private_root = Path(app.config["UPLOAD_FOLDER"]) + if not legacy_root.exists() or legacy_root.resolve() == private_root.resolve(): + return + + for source in legacy_root.rglob("*"): + if not source.is_file() or source.name == ".gitkeep": + continue + destination = private_root / source.relative_to(legacy_root) + if destination.exists(): + destination = private_root / "legacy_conflicts" / source.relative_to(legacy_root) + if destination.exists(): + destination = destination.with_name(f"{destination.stem}-{secrets.token_hex(4)}{destination.suffix}") + logger.warning("Legacy upload collision; preserving old file at: %s", destination) + _safe_move(source, destination) + + +def _register_security_hooks(app): + @app.before_request + def enforce_https(): + if request.path != "/healthz" and app.config.get("ENFORCE_HTTPS") and not request.is_secure: + return redirect(request.url.replace("http://", "https://", 1), code=308) + + @app.after_request + def add_security_headers(response): + response.headers.setdefault("X-Content-Type-Options", "nosniff") + response.headers.setdefault("X-Frame-Options", "DENY") + response.headers.setdefault("Referrer-Policy", "no-referrer") + response.headers.setdefault("Permissions-Policy", "camera=(self), microphone=(), geolocation=()") + response.headers.setdefault( + "Content-Security-Policy", + "default-src 'self'; " + "base-uri 'self'; frame-ancestors 'none'; form-action 'self'; object-src 'none'; " + "img-src 'self' data:; connect-src 'self'; " + "style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; " + "script-src 'self' https://cdn.jsdelivr.net https://unpkg.com", + ) + if request.is_secure: + response.headers.setdefault("Strict-Transport-Security", "max-age=31536000; includeSubDomains") + if request.path.startswith(("/admin", "/api", "/check-in", "/media", "/invite")): + response.headers.setdefault("Cache-Control", "private, no-store") + return response + + @app.errorhandler(413) + def request_too_large(_error): + return jsonify({"error": "Загружаемый файл слишком большой"}), 413 + + @app.get("/healthz") + @limiter.exempt + def healthz(): + db.session.execute(text("SELECT 1")) + return jsonify({"status": "ok"}) + + +def _register_cli(app): + @app.cli.command("create-admin") + @click.option("--username", prompt=True) + @click.password_option(confirmation_prompt=True) + def create_admin(username, password): + """Create the first administrator without exposing a web setup page.""" + from app.models import User + from app.utils.security import validate_password + + username = username.strip() + error = validate_password(password, app.config["MIN_PASSWORD_LENGTH"]) + if not username: + raise click.ClickException("Логин не может быть пустым") + if error: + raise click.ClickException(error) + if User.query.filter_by(username=username).first(): + raise click.ClickException("Пользователь уже существует") + + user = User(username=username, role="admin", is_active=True) + user.set_password(password) + db.session.add(user) + db.session.commit() + click.echo("Администратор создан") + + +def _migrate_files_to_event_folders(): + """Переносит существующие файлы в папки по мероприятиям.""" + from pathlib import Path + from flask import current_app + from app.models import Event, Guest + from app.utils.paths import safe_child_path + + 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 = safe_child_path(photo_base, guest.photo_filename) + if not src: + continue + if src.exists() and src.is_file(): + dst = safe_child_path(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 = safe_child_path(qr_base, guest.qr_filename) + if not src: + continue + if src.exists() and src.is_file(): + dst = safe_child_path(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 = safe_child_path(logo_base, event.logo_filename) + if not src: + continue + if src.exists() and src.is_file(): + dst = safe_child_path(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 + + try: + # Добавляем колонки дизайна билета в таблицу events + _add_column_if_missing("events", "ticket_title", "VARCHAR(255)") + _add_column_if_missing("events", "ticket_subtitle", "VARCHAR(255)") + _add_column_if_missing("events", "ticket_body_text", "TEXT") + _add_column_if_missing("events", "ticket_background_color", "VARCHAR(7) DEFAULT '#ffffff'") + _add_column_if_missing("events", "ticket_text_color", "VARCHAR(7) DEFAULT '#000000'") + _add_column_if_missing("events", "ticket_accent_color", "VARCHAR(7) DEFAULT '#0d6efd'") + _add_column_if_missing("events", "ticket_show_qr", "BOOLEAN DEFAULT 1") + _add_column_if_missing("events", "ticket_show_photo", "BOOLEAN DEFAULT 0") + _add_column_if_missing("events", "ticket_layout", "VARCHAR(20) DEFAULT 'portrait'") + _add_column_if_missing("events", "logo_filename", "VARCHAR(255)") + + # Добавляем колонки ФИО/телефона в таблицу guests + _add_column_if_missing("guests", "last_name", "VARCHAR(255)") + _add_column_if_missing("guests", "first_name", "VARCHAR(255)") + _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)") + + # Настройки IMAP для сохранения копий отправленных писем + _add_column_if_missing("smtp_settings", "imap_server", "VARCHAR(255)") + _add_column_if_missing("smtp_settings", "imap_port", "INTEGER DEFAULT 993") + _add_column_if_missing("smtp_settings", "imap_use_ssl", "BOOLEAN DEFAULT 1") + _add_column_if_missing("smtp_settings", "imap_username", "VARCHAR(255)") + _add_column_if_missing("smtp_settings", "imap_password", "VARCHAR(255)") + _add_column_if_missing("smtp_settings", "imap_folder", "VARCHAR(255)") + _add_column_if_missing("smtp_settings", "imap_save_copy", "BOOLEAN DEFAULT 0") + + # Переносим файлы в папки по мероприятиям + _migrate_files_to_event_folders() + + # Проверяем, есть ли колонка event_id в таблице guests + if _column_exists("guests", "event_id"): + db.session.commit() + return # Миграция гостей уже выполнена + + # Создаём дефолтное мероприятие для существующих гостей + default_event = Event( + name=app.config.get("DEFAULT_EVENT_NAME", "Архивное мероприятие"), + description="Автоматически созданное мероприятие для существующих гостей", + is_active=True, + ) + db.session.add(default_event) + db.session.flush() + + # Добавляем колонку event_id + db.session.execute(text("ALTER TABLE guests ADD COLUMN event_id INTEGER REFERENCES events(id)")) + + # Привязываем всех существующих гостей к дефолтному мероприятию + db.session.execute( + text("UPDATE guests SET event_id = :event_id"), + {"event_id": default_event.id}, + ) + + # Добавляем колонку event_id в check_ins (опционально) + if not _column_exists("check_ins", "event_id"): + db.session.execute(text("ALTER TABLE check_ins ADD COLUMN event_id INTEGER REFERENCES events(id)")) + db.session.execute( + text(""" + UPDATE check_ins + SET event_id = ( + SELECT event_id FROM guests WHERE guests.id = check_ins.guest_id + ) + """) + ) + + db.session.commit() + logger.info(f"Migration completed: existing guests moved to event '{default_event.name}' (id={default_event.id})") + except Exception as e: + db.session.rollback() + logger.warning(f"Migration skipped or failed: {e}") diff --git a/app/models.py b/app/models.py new file mode 100644 index 0000000..5338595 --- /dev/null +++ b/app/models.py @@ -0,0 +1,337 @@ +import uuid +from datetime import datetime, timezone + +from werkzeug.security import generate_password_hash, check_password_hash + +from app import db + + +def utcnow(): + """Return naive UTC for compatibility with existing database values.""" + return datetime.now(timezone.utc).replace(tzinfo=None) + + +event_staff = db.Table( + "event_staff", + db.Column("event_id", db.Integer, db.ForeignKey("events.id", ondelete="CASCADE"), primary_key=True), + db.Column("user_id", db.Integer, db.ForeignKey("users.id", ondelete="CASCADE"), primary_key=True), +) + + +class User(db.Model): + __tablename__ = "users" + + id = db.Column(db.Integer, primary_key=True) + username = db.Column(db.String(100), unique=True, nullable=False) + 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=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) + + @property + def is_admin(self): + return self.role == "admin" + + @property + def is_organizer(self): + return self.role == "organizer" + + @property + def is_staff(self): + return self.role == "staff" + + def can_manage_event(self, event): + if self.is_admin: + return True + if self.is_organizer and event and event.created_by_id == self.id: + return True + return False + + def __repr__(self): + return f"" + + +class Event(db.Model): + __tablename__ = "events" + __table_args__ = ( + db.Index("ix_events_created_by_active", "created_by_id", "is_active"), + db.Index("ix_events_event_date", "event_date"), + ) + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(255), nullable=False) + description = db.Column(db.Text, nullable=True) + event_date = db.Column(db.DateTime, nullable=True) + event_end_date = db.Column(db.DateTime, nullable=True) + location = db.Column(db.String(255), nullable=True) + is_active = db.Column(db.Boolean, default=True, nullable=False) + created_by_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=True) + created_at = db.Column(db.DateTime, default=utcnow, nullable=False) + + # Ticket design fields + ticket_title = db.Column(db.String(255), nullable=True) + ticket_subtitle = db.Column(db.String(255), nullable=True) + ticket_body_text = db.Column(db.Text, nullable=True) + ticket_background_color = db.Column(db.String(7), nullable=True, default="#ffffff") + ticket_text_color = db.Column(db.String(7), nullable=True, default="#000000") + ticket_accent_color = db.Column(db.String(7), nullable=True, default="#0d6efd") + ticket_show_qr = db.Column(db.Boolean, default=True, nullable=False) + ticket_show_photo = db.Column(db.Boolean, default=False, nullable=False) + ticket_layout = db.Column(db.String(20), nullable=True, default="portrait") + logo_filename = db.Column(db.String(255), nullable=True) + rsvp_enabled = db.Column(db.Boolean, default=True, nullable=False) + rsvp_deadline = db.Column(db.DateTime, nullable=True) + invitation_subject = db.Column(db.String(255), nullable=True) + invitation_message = db.Column(db.Text, nullable=True) + + guests = db.relationship("Guest", backref="event", lazy=True, cascade="all, delete-orphan") + check_ins = db.relationship("CheckIn", backref="event", lazy=True, cascade="all, delete-orphan") + staff_members = db.relationship( + "User", + secondary=event_staff, + lazy="select", + backref=db.backref("assigned_events", lazy="dynamic"), + ) + + def __repr__(self): + return f"" + + +class Guest(db.Model): + __tablename__ = "guests" + __table_args__ = ( + db.Index("ix_guests_event_full_name", "event_id", "full_name"), + db.Index("ix_guests_event_email", "event_id", "email"), + db.Index("ix_guests_event_active", "event_id", "is_active"), + db.Index("ix_guests_event_rsvp", "event_id", "rsvp_status"), + ) + + id = db.Column(db.Integer, primary_key=True) + event_id = db.Column(db.Integer, db.ForeignKey("events.id"), nullable=False) + department = db.Column(db.String(255), nullable=False) + last_name = db.Column(db.String(255), nullable=True) + first_name = db.Column(db.String(255), nullable=True) + middle_name = db.Column(db.String(255), nullable=True) + full_name = db.Column(db.String(255), nullable=False) + position = db.Column(db.String(255), nullable=True) + email = db.Column(db.String(255), nullable=False) + phone = db.Column(db.String(50), nullable=True) + photo_filename = db.Column(db.String(255), nullable=True) + token = db.Column(db.String(36), unique=True, nullable=False, default=lambda: str(uuid.uuid4())) + qr_filename = db.Column(db.String(255), nullable=True) + is_active = db.Column(db.Boolean, default=True, nullable=False) + invitation_sent = db.Column(db.Boolean, default=False, nullable=False) + invitation_status = db.Column(db.String(20), default="pending", nullable=False) + invitation_attempts = db.Column(db.Integer, default=0, nullable=False) + invitation_last_error = db.Column(db.String(1000), nullable=True) + invitation_last_attempt_at = db.Column(db.DateTime, nullable=True) + invitation_sent_at = db.Column(db.DateTime, nullable=True) + rsvp_status = db.Column(db.String(20), default="pending", nullable=False) + rsvp_responded_at = db.Column(db.DateTime, nullable=True) + rsvp_comment = db.Column(db.String(1000), nullable=True) + dietary_requirements = db.Column(db.String(1000), nullable=True) + created_at = db.Column(db.DateTime, default=utcnow, nullable=False) + updated_at = db.Column(db.DateTime, default=utcnow, onupdate=utcnow, nullable=False) + + check_ins = db.relationship("CheckIn", backref="guest", lazy=True, cascade="all, delete-orphan") + + @property + def is_checked_in(self): + return len(self.check_ins) > 0 + + @property + def last_check_in(self): + if not self.check_ins: + return None + return max(check_in.checked_in_at for check_in in self.check_ins) + + def build_full_name(self): + 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 + from app.utils.paths import event_photo_folder, safe_child_path + if self.event_id: + path = safe_child_path(event_photo_folder(self.event_id), self.photo_filename) + return [path] if path else [] + # fallback только если мероприятие ещё не назначено + path = safe_child_path(Path(current_app.config["PHOTO_FOLDER"]), self.photo_filename) + return [path] if path else [] + + @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 flask import url_for + return url_for("main.guest_photo", guest_id=self.id) + + def __repr__(self): + return f"" + + +class CheckIn(db.Model): + __tablename__ = "check_ins" + __table_args__ = ( + db.Index("ix_check_ins_event_time", "event_id", "checked_in_at"), + db.UniqueConstraint("guest_id", name="uq_check_ins_guest_id"), + ) + + id = db.Column(db.Integer, primary_key=True) + guest_id = db.Column(db.Integer, db.ForeignKey("guests.id"), nullable=False) + event_id = db.Column(db.Integer, db.ForeignKey("events.id"), nullable=True) + checked_in_by_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=True) + checked_in_at = db.Column(db.DateTime, default=utcnow, nullable=False) + device_info = db.Column(db.String(500), nullable=True) + + checked_in_by = db.relationship("User", foreign_keys=[checked_in_by_id]) + + def __repr__(self): + return f"" + + +class AuditLog(db.Model): + __tablename__ = "audit_logs" + __table_args__ = ( + db.Index("ix_audit_logs_created_at", "created_at"), + db.Index("ix_audit_logs_action", "action"), + db.Index("ix_audit_logs_username", "username"), + ) + + 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=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(1024), 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) + + # IMAP copy of sent messages + imap_server = db.Column(db.String(255), nullable=True) + imap_port = db.Column(db.Integer, nullable=False, default=993) + imap_use_ssl = db.Column(db.Boolean, nullable=False, default=True) + imap_username = db.Column(db.String(255), nullable=True) + imap_password = db.Column(db.String(1024), nullable=True) + imap_folder = db.Column(db.String(255), nullable=True) + imap_save_copy = db.Column(db.Boolean, nullable=False, default=False) + + updated_at = db.Column(db.DateTime, default=utcnow, onupdate=utcnow, nullable=False) + + @property + def mail_mode(self): + if self.use_local_outlook: + return "outlook" + if self.use_ews: + return "ews" + return "smtp" + + def set_mail_password(self, password): + from app.utils.secrets import encrypt_secret + self.mail_password = encrypt_secret(password) + + def get_mail_password(self): + from app.utils.secrets import decrypt_secret + return decrypt_secret(self.mail_password) + + def set_imap_password(self, password): + from app.utils.secrets import encrypt_secret + self.imap_password = encrypt_secret(password) + + def get_imap_password(self): + from app.utils.secrets import decrypt_secret + return decrypt_secret(self.imap_password) + + def encrypt_legacy_passwords(self): + """Encrypt plaintext values created by older InviteMe versions.""" + from app.utils.secrets import PREFIX + changed = False + if self.mail_password and not self.mail_password.startswith(PREFIX): + self.set_mail_password(self.mail_password) + changed = True + if self.imap_password and not self.imap_password.startswith(PREFIX): + self.set_imap_password(self.imap_password) + changed = True + return changed + + @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.get("MAIL_USERNAME", "") + app.config["MAIL_PASSWORD"] = settings.get_mail_password() or app.config.get("MAIL_PASSWORD", "") + 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 "" + + app.config["IMAP_SERVER"] = settings.imap_server or "" + app.config["IMAP_PORT"] = settings.imap_port or 993 + app.config["IMAP_USE_SSL"] = settings.imap_use_ssl + app.config["IMAP_USERNAME"] = settings.imap_username or settings.mail_username or app.config.get("IMAP_USERNAME", "") + app.config["IMAP_PASSWORD"] = settings.get_imap_password() or settings.get_mail_password() or app.config.get("IMAP_PASSWORD", "") + app.config["IMAP_FOLDER"] = settings.imap_folder or "Sent" + app.config["IMAP_SAVE_COPY"] = settings.imap_save_copy + except Exception: + pass + + def __repr__(self): + return f"" diff --git a/app/routes.py b/app/routes.py new file mode 100644 index 0000000..e373548 --- /dev/null +++ b/app/routes.py @@ -0,0 +1,1714 @@ +import io +import zipfile +from datetime import datetime, timedelta +from pathlib import Path +from uuid import uuid4 + +from flask import ( + Blueprint, abort, render_template, request, redirect, url_for, flash, + session, jsonify, send_file, current_app, has_request_context, make_response +) +from sqlalchemy import distinct, func, or_ +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import selectinload + +from app import db, limiter +from app.models import User, Event, Guest, CheckIn, AuditLog, SmtpSettings, event_staff, utcnow +from app.services.qr_service import generate_qr_for_guest +from app.services.email_service import send_invitation, send_test_email +from app.services.excel_service import build_guest_import_template, 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, safe_child_path +from app.utils.security import ( + ALLOWED_IMAGE_EXTENSIONS, + image_extension, + safe_upload_filename, + save_validated_image, + validate_zip_size, + validate_password, +) +from app.utils.queue import enqueue_event_invitations + +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 +# --------------------------- + +def allowed_file(filename, extensions): + return "." in filename and filename.rsplit(".", 1)[1].lower() in extensions + + +def _save_image(stream, destination): + return save_validated_image( + stream, + destination, + max_bytes=current_app.config["MAX_IMAGE_BYTES"], + max_pixels=current_app.config["MAX_IMAGE_PIXELS"], + ) + + +def current_user(): + if not has_request_context(): + return None + user_id = session.get("user_id") + session_version = session.get("session_version") + if user_id: + user = db.session.get(User, 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) + + +def require_role(*roles): + def decorator(f): + def wrapped(*args, **kwargs): + user = current_user() + if not user or not user.is_active or user.role not in roles: + flash("Требуется авторизация", "warning") + return redirect(url_for("main.login")) + return f(*args, **kwargs) + wrapped.__name__ = f.__name__ + return wrapped + return decorator + + +def require_login(f): + def wrapped(*args, **kwargs): + user = current_user() + if not user or not user.is_active: + flash("Требуется авторизация", "warning") + return redirect(url_for("main.login")) + return f(*args, **kwargs) + wrapped.__name__ = f.__name__ + return wrapped + + +def get_current_event(): + event_id = session.get("current_event_id") + if event_id: + user = current_user() + event = db.session.get(Event, event_id) + if event and user and user.can_manage_event(event): + return event + return None + + +def require_event(f): + """Декоратор для маршрутов, требующих выбранного мероприятия (admin/organizer).""" + def wrapped(*args, **kwargs): + user = current_user() + if not user: + flash("Требуется авторизация", "warning") + return redirect(url_for("main.login")) + if not get_current_event(): + flash("Сначала выберите мероприятие", "warning") + return redirect(url_for("main.admin_events")) + return f(*args, **kwargs) + wrapped.__name__ = f.__name__ + return wrapped + + +def available_events_for_user(user): + if user.is_admin: + return Event.query.order_by(Event.created_at.desc()) + if user.is_staff: + return Event.query.join(event_staff).filter(event_staff.c.user_id == user.id).order_by(Event.created_at.desc()) + return Event.query.filter_by(created_by_id=user.id).order_by(Event.created_at.desc()) + + +def can_access_event(user, event): + if not user or not event: + return False + if user.is_admin or user.can_manage_event(event): + return True + return user.is_staff and any(staff.id == user.id for staff in event.staff_members) + + +def _selected_staff_users(): + staff_ids = {value for value in request.form.getlist("staff_ids") if value.isdigit()} + if not staff_ids: + return [] + return User.query.filter(User.id.in_(staff_ids), User.role == "staff", User.is_active == True).all() + + +def require_event_access(event): + if not can_access_event(current_user(), event): + abort(403) + + +def _setup_available(): + return bool(current_app.config.get("ALLOW_WEB_SETUP")) + + +def _guest_search_expression(search): + pattern = f"%{search}%" + return or_( + Guest.full_name.ilike(pattern), Guest.last_name.ilike(pattern), + Guest.first_name.ilike(pattern), Guest.middle_name.ilike(pattern), + Guest.department.ilike(pattern), Guest.position.ilike(pattern), + Guest.email.ilike(pattern), Guest.phone.ilike(pattern), + ) + + +def _parse_local_datetime(value): + if not value: + return None + return local_to_utc(datetime.fromisoformat(value)) + + +def _single_line(value, max_length=255): + return " ".join(str(value or "").splitlines()).strip()[:max_length] or None + + +def _rsvp_is_open(event): + return bool( + event and event.is_active and event.rsvp_enabled + and (not event.rsvp_deadline or utcnow() <= event.rsvp_deadline) + ) + + +def _ics_escape(value): + return str(value or "").replace("\\", "\\\\").replace(";", "\\;").replace(",", "\\,").replace("\n", "\\n") + + +def _ics_datetime(value): + return value.strftime("%Y%m%dT%H%M%SZ") + + +# --------------------------- +# Guest invitation / RSVP +# --------------------------- + +@main_bp.route("/invite/", methods=["GET", "POST"]) +@limiter.limit("30 per minute") +def public_invitation(token): + guest = Guest.query.filter_by(token=token, is_active=True).first_or_404() + event = guest.event + if not event or not event.is_active: + abort(404) + + rsvp_open = _rsvp_is_open(event) + if request.method == "POST": + if not rsvp_open: + flash("Срок подтверждения участия завершён", "warning") + return redirect(url_for("main.public_invitation", token=token)) + + status = request.form.get("rsvp_status", "").strip() + if status not in {"confirmed", "declined"}: + flash("Выберите вариант ответа", "danger") + return redirect(url_for("main.public_invitation", token=token)) + + previous_status = guest.rsvp_status + guest.rsvp_status = status + guest.rsvp_responded_at = utcnow() + guest.rsvp_comment = request.form.get("rsvp_comment", "").strip()[:1000] or None + guest.dietary_requirements = request.form.get("dietary_requirements", "").strip()[:1000] or None + log_action( + "guest_rsvp_update", + target_type="guest", + target_id=guest.id, + details=f"RSVP изменён: {previous_status} -> {status}", + ) + db.session.commit() + flash("Ваш ответ сохранён", "success") + return redirect(url_for("main.public_invitation", token=token)) + + return render_template( + "invitation.html", + guest=guest, + event=event, + rsvp_open=rsvp_open, + ) + + +@main_bp.get("/invite//calendar.ics") +@limiter.limit("30 per minute") +def public_invitation_calendar(token): + guest = Guest.query.filter_by(token=token, is_active=True).first_or_404() + event = guest.event + if not event or not event.is_active or not event.event_date: + abort(404) + + end_date = event.event_end_date or (event.event_date + timedelta(hours=2)) + invitation_url = url_for("main.public_invitation", token=guest.token, _external=True) + content = "\r\n".join([ + "BEGIN:VCALENDAR", + "VERSION:2.0", + "PRODID:-//InviteMe//Invitation//RU", + "CALSCALE:GREGORIAN", + "METHOD:PUBLISH", + "BEGIN:VEVENT", + f"UID:event-{event.id}-{guest.token}@inviteme", + f"DTSTAMP:{_ics_datetime(utcnow())}", + f"DTSTART:{_ics_datetime(event.event_date)}", + f"DTEND:{_ics_datetime(end_date)}", + f"SUMMARY:{_ics_escape(event.name)}", + f"DESCRIPTION:{_ics_escape(event.description)}", + f"LOCATION:{_ics_escape(event.location)}", + f"URL:{_ics_escape(invitation_url)}", + "END:VEVENT", + "END:VCALENDAR", + "", + ]) + response = make_response(content) + response.headers["Content-Type"] = "text/calendar; charset=utf-8" + response.headers["Content-Disposition"] = 'attachment; filename="invitation.ics"' + return response + + +@main_bp.get("/invite//ticket") +@limiter.limit("20 per minute") +def public_invitation_ticket(token): + guest = Guest.query.filter_by(token=token, is_active=True).first_or_404() + if not guest.event or not guest.event.is_active: + abort(404) + ticket_path = generate_ticket_image(guest, guest.event) + return send_file( + str(ticket_path), + as_attachment=True, + download_name=f"InviteMe-{guest.token}.png", + mimetype="image/png", + ) + + +# --------------------------- +# Auth +# --------------------------- + +def _is_first_run(): + """True, если в базе ещё нет ни одного пользователя.""" + return User.query.first() is None + + +@main_bp.route("/setup", methods=["GET", "POST"]) +@limiter.limit("5 per hour", methods=["POST"]) +def setup(): + """Первоначальная настройка: создание первого администратора.""" + if not _setup_available(): + abort(404) + if not _is_first_run(): + return redirect(url_for("main.login")) + + if request.method == "POST": + username = request.form.get("username", "").strip() + password = request.form.get("password", "").strip() + password_confirm = request.form.get("password_confirm", "").strip() + + if not username or not password: + flash("Логин и пароль обязательны", "danger") + return redirect(url_for("main.setup")) + + if password != password_confirm: + flash("Пароли не совпадают", "danger") + return redirect(url_for("main.setup")) + + password_error = validate_password(password, current_app.config["MIN_PASSWORD_LENGTH"]) + if password_error: + flash(password_error, "danger") + return redirect(url_for("main.setup")) + + if User.query.filter_by(username=username).first(): + flash("Пользователь с таким логином уже существует", "danger") + return redirect(url_for("main.setup")) + + admin = User(username=username, role="admin", is_active=True) + admin.set_password(password) + db.session.add(admin) + db.session.commit() + + flash("Первый администратор создан. Войдите в систему.", "success") + return redirect(url_for("main.login")) + + return render_template("setup.html") + + +@main_bp.route("/login", methods=["GET", "POST"]) +@limiter.limit("10 per minute", methods=["POST"]) +def login(): + if _is_first_run(): + if _setup_available(): + return redirect(url_for("main.setup")) + flash("Система не настроена. Создайте администратора через командную строку.", "warning") + return render_template("login.html"), 503 + + if request.method == "POST": + username = request.form.get("username", "").strip() + password = request.form.get("password", "").strip() + + 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_panel")) + else: + flash("Неверный логин или пароль", "danger") + + return render_template("login.html") + + +@main_bp.route("/logout", methods=["POST"]) +@require_login +def logout(): + log_action("logout") + db.session.commit() + logout_user() + flash("Вы вышли из системы", "info") + return redirect(url_for("main.login")) + + +# --------------------------- +# Events +# --------------------------- + +@main_bp.route("/admin/events") +@require_role("admin", "organizer") +def admin_events(): + user = current_user() + events = available_events_for_user(user).all() + for event in events: + event.total_guests = Guest.query.filter_by(event_id=event.id, is_active=True).count() + event.checked_in = db.session.query(func.count(distinct(CheckIn.guest_id))).filter(CheckIn.event_id == event.id).scalar() + return render_template("admin/events.html", events=events) + + +@main_bp.route("/admin/events/add", methods=["GET", "POST"]) +@require_role("admin", "organizer") +def admin_event_add(): + if request.method == "POST": + name = request.form.get("name", "").strip() + description = request.form.get("description", "").strip() + location = request.form.get("location", "").strip() + event_date_str = request.form.get("event_date", "").strip() + event_end_date_str = request.form.get("event_end_date", "").strip() + rsvp_deadline_str = request.form.get("rsvp_deadline", "").strip() + + if not name: + flash("Название мероприятия обязательно", "danger") + return redirect(url_for("main.admin_event_add")) + + try: + event_date = _parse_local_datetime(event_date_str) + event_end_date = _parse_local_datetime(event_end_date_str) + rsvp_deadline = _parse_local_datetime(rsvp_deadline_str) + except ValueError: + flash("Неверный формат даты", "danger") + return redirect(url_for("main.admin_event_add")) + if event_date and event_end_date and event_end_date <= event_date: + flash("Время окончания должно быть позже начала", "danger") + return redirect(url_for("main.admin_event_add")) + + event = Event( + name=name, + description=description, + location=location, + event_date=event_date, + event_end_date=event_end_date, + rsvp_enabled=bool(request.form.get("rsvp_enabled")), + rsvp_deadline=rsvp_deadline, + invitation_subject=_single_line(request.form.get("invitation_subject")), + invitation_message=request.form.get("invitation_message", "").strip()[:5000] or None, + created_by_id=current_user().id, + ) + db.session.add(event) + event.staff_members = _selected_staff_users() + 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")) + + staff_users = User.query.filter_by(role="staff", is_active=True).order_by(User.username).all() + return render_template("admin/event_form.html", event=None, staff_users=staff_users) + + +@main_bp.route("/admin/events//edit", methods=["GET", "POST"]) +@require_role("admin", "organizer") +def admin_event_edit(event_id): + event = Event.query.get_or_404(event_id) + user = current_user() + if not user.can_manage_event(event): + flash("Нет доступа к этому мероприятию", "danger") + return redirect(url_for("main.admin_events")) + + if request.method == "POST": + event.name = request.form.get("name", "").strip() + event.description = request.form.get("description", "").strip() + event.location = request.form.get("location", "").strip() + event_date_str = request.form.get("event_date", "").strip() + event_end_date_str = request.form.get("event_end_date", "").strip() + rsvp_deadline_str = request.form.get("rsvp_deadline", "").strip() + + if not event.name: + flash("Название мероприятия обязательно", "danger") + return redirect(url_for("main.admin_event_edit", event_id=event.id)) + + try: + event.event_date = _parse_local_datetime(event_date_str) + event.event_end_date = _parse_local_datetime(event_end_date_str) + event.rsvp_deadline = _parse_local_datetime(rsvp_deadline_str) + except ValueError: + flash("Неверный формат даты", "danger") + return redirect(url_for("main.admin_event_edit", event_id=event.id)) + if event.event_date and event.event_end_date and event.event_end_date <= event.event_date: + flash("Время окончания должно быть позже начала", "danger") + return redirect(url_for("main.admin_event_edit", event_id=event.id)) + + event.is_active = bool(request.form.get("is_active")) + event.rsvp_enabled = bool(request.form.get("rsvp_enabled")) + event.invitation_subject = _single_line(request.form.get("invitation_subject")) + event.invitation_message = request.form.get("invitation_message", "").strip()[:5000] or None + event.staff_members = _selected_staff_users() + 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")) + + staff_users = User.query.filter_by(role="staff", is_active=True).order_by(User.username).all() + return render_template("admin/event_form.html", event=event, staff_users=staff_users) + + +@main_bp.route("/admin/events//delete", methods=["POST"]) +@require_role("admin", "organizer") +def admin_event_delete(event_id): + event = Event.query.get_or_404(event_id) + user = current_user() + if not user.can_manage_event(event): + flash("Нет доступа к этому мероприятию", "danger") + return redirect(url_for("main.admin_events")) + + if session.get("current_event_id") == 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): + event = Event.query.get_or_404(event_id) + user = current_user() + if not user.can_manage_event(event): + flash("Нет доступа к этому мероприятию", "danger") + return redirect(url_for("main.admin_events")) + + session["current_event_id"] = event.id + flash(f"Выбрано мероприятие: {event.name}", "info") + return redirect(url_for("main.admin_dashboard")) + + +@main_bp.route("/admin/events//ticket", methods=["GET", "POST"]) +@require_role("admin", "organizer") +def admin_event_ticket(event_id): + event = Event.query.get_or_404(event_id) + user = current_user() + if not user.can_manage_event(event): + flash("Нет доступа к этому мероприятию", "danger") + return redirect(url_for("main.admin_events")) + + if request.method == "POST": + event.ticket_title = request.form.get("ticket_title", "").strip() or event.name + event.ticket_subtitle = request.form.get("ticket_subtitle", "").strip() + event.ticket_body_text = request.form.get("ticket_body_text", "").strip() + event.ticket_background_color = request.form.get("ticket_background_color", "#ffffff").strip() + event.ticket_text_color = request.form.get("ticket_text_color", "#000000").strip() + event.ticket_accent_color = request.form.get("ticket_accent_color", "#0d6efd").strip() + event.ticket_show_qr = bool(request.form.get("ticket_show_qr")) + event.ticket_show_photo = bool(request.form.get("ticket_show_photo")) + event.ticket_layout = request.form.get("ticket_layout", "portrait").strip() + + # Logo upload + logo_file = request.files.get("logo_file") + if logo_file and logo_file.filename: + if allowed_file(logo_file.filename, ALLOWED_IMAGE_EXTENSIONS): + extension = image_extension(logo_file.filename) + filename = f"logo_{uuid4().hex}.{extension}" + logo_folder = event_logo_folder(event.id) + logo_folder.mkdir(parents=True, exist_ok=True) + logo_path = logo_folder / filename + try: + _save_image(logo_file.stream, logo_path) + if event.logo_filename: + previous_logo = safe_child_path(logo_folder, event.logo_filename) + if previous_logo and previous_logo.exists() and previous_logo != logo_path: + previous_logo.unlink() + event.logo_filename = filename + except ValueError as exc: + flash(str(exc), "warning") + else: + flash("Неверный формат логотипа. Разрешены: JPG, PNG и WebP.", "warning") + + # Logo deletion + if request.form.get("delete_logo") and event.logo_filename: + old_logo = safe_child_path(event_logo_folder(event.id), event.logo_filename) + if old_logo and 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)) + + return render_template("admin/ticket_constructor.html", event=event) + + +@main_bp.route("/media/events//logo") +@require_role("admin", "organizer", "staff") +def event_logo(event_id): + event = Event.query.get_or_404(event_id) + require_event_access(event) + if not event.logo_filename: + abort(404) + logo_path = safe_child_path(event_logo_folder(event.id), event.logo_filename) + if not logo_path or not logo_path.is_file(): + abort(404) + return send_file(logo_path, conditional=True, max_age=300) + + +@main_bp.route("/admin/events//ticket/preview") +@require_role("admin", "organizer") +def admin_event_ticket_preview(event_id): + event = Event.query.get_or_404(event_id) + user = current_user() + if not user.can_manage_event(event): + flash("Нет доступа к этому мероприятию", "danger") + return redirect(url_for("main.admin_events")) + + try: + ticket_path = generate_preview_ticket(event) + return send_file(str(ticket_path), mimetype="image/png") + except Exception as e: + flash(f"Ошибка генерации предпросмотра: {str(e)}", "danger") + return redirect(url_for("main.admin_event_ticket", event_id=event.id)) + + +# --------------------------- +# Admin Dashboard +# --------------------------- + +@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(): + event = get_current_event() + total = Guest.query.filter_by(event_id=event.id, is_active=True).count() + checked_in = db.session.query(func.count(distinct(CheckIn.guest_id))).filter(CheckIn.event_id == event.id).scalar() + not_checked_in = total - checked_in + attendance_percent = round((checked_in / total) * 100, 1) if total else 0 + rsvp_confirmed = Guest.query.filter_by(event_id=event.id, is_active=True, rsvp_status="confirmed").count() + rsvp_declined = Guest.query.filter_by(event_id=event.id, is_active=True, rsvp_status="declined").count() + rsvp_pending = max(total - rsvp_confirmed - rsvp_declined, 0) + + recent_check_ins = ( + CheckIn.query + .join(Guest) + .filter(Guest.event_id == event.id) + .order_by(CheckIn.checked_in_at.desc()) + .limit(10) + .all() + ) + + return render_template( + "admin/dashboard.html", + event=event, + total=total, + checked_in=checked_in, + not_checked_in=not_checked_in, + attendance_percent=attendance_percent, + recent_check_ins=recent_check_ins, + rsvp_confirmed=rsvp_confirmed, + rsvp_declined=rsvp_declined, + rsvp_pending=rsvp_pending, + ) + + +# --------------------------- +# Upload Excel / Photos +# --------------------------- + +@main_bp.route("/admin/upload/template") +@require_role("admin", "organizer") +def admin_upload_template(): + return send_file( + build_guest_import_template(), + as_attachment=True, + download_name="InviteMe_guests_template.xlsx", + mimetype="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ) + +@main_bp.route("/admin/upload", methods=["GET", "POST"]) +@require_role("admin", "organizer") +@require_event +def admin_upload(): + event = get_current_event() + + if request.method == "POST": + # Excel upload + excel_file = request.files.get("excel_file") + if excel_file and excel_file.filename: + if allowed_file(excel_file.filename, {"xlsx"}): + try: + excel_stream = io.BytesIO(excel_file.read()) + validate_zip_size( + excel_stream, + current_app.config["MAX_ZIP_FILES"], + current_app.config["MAX_XLSX_UNCOMPRESSED_BYTES"], + ) + result = import_guests_from_excel(excel_stream, event_id=event.id) + flash( + f"Импорт завершён: добавлено {result['imported']}, обновлено {result['updated']} из {result['total']}", + "success", + ) + + # Generate QR codes for guests without QR + base_url = request.form.get("base_url", "").strip() or None + 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") + except Exception as e: + flash(f"Ошибка импорта Excel: {str(e)}", "danger") + else: + flash("Неверный формат Excel-файла", "danger") + + # Photos upload (ZIP or multiple files) + photo_files = request.files.getlist("photo_files") + zip_file = request.files.get("zip_file") + + 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: + if allowed_file(zip_file.filename, {"zip"}): + try: + with zipfile.ZipFile(zip_file.stream, "r") as archive: + members = [member for member in archive.infolist() if not member.is_dir()] + if len(members) > current_app.config["MAX_ZIP_FILES"]: + raise ValueError("В архиве слишком много файлов") + total_size = sum(member.file_size for member in members) + if total_size > current_app.config["MAX_ZIP_UNCOMPRESSED_BYTES"]: + raise ValueError("Архив слишком большой после распаковки") + + for member in members: + try: + filename = safe_upload_filename(member.filename) + except ValueError: + continue + if image_extension(filename) not in ALLOWED_IMAGE_EXTENSIONS: + continue + with archive.open(member, "r") as source: + _save_image(source, photo_folder / filename) + uploaded_count += 1 + flash(f"Из ZIP распаковано файлов: {uploaded_count}", "success") + except (ValueError, zipfile.BadZipFile, RuntimeError) as exc: + flash(f"Ошибка распаковки ZIP: {exc}", "danger") + else: + flash("Неверный формат ZIP-архива", "danger") + + for photo in photo_files: + if photo and photo.filename: + try: + filename = safe_upload_filename(photo.filename) + if image_extension(filename) not in ALLOWED_IMAGE_EXTENSIONS: + raise ValueError("Разрешены только JPG, PNG и WebP") + _save_image(photo.stream, photo_folder / filename) + uploaded_count += 1 + except ValueError as exc: + flash(f"Файл «{photo.filename}» пропущен: {exc}", "warning") + + 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")) + + return render_template("admin/upload.html", event=event) + + +# --------------------------- +# Guests management +# --------------------------- + +@main_bp.route("/admin/guests") +@require_role("admin", "organizer") +@require_event +def admin_guests(): + event = get_current_event() + search = request.args.get("q", "").strip() + page = max(request.args.get("page", 1, type=int), 1) + query = Guest.query.options(selectinload(Guest.check_ins)).filter_by(event_id=event.id) + if search: + query = query.filter(_guest_search_expression(search)) + pagination = query.order_by(Guest.full_name).paginate(page=page, per_page=50, error_out=False) + return render_template("admin/guests.html", event=event, guests=pagination.items, pagination=pagination, search=search) + + +@main_bp.route("/admin/guests/add", methods=["GET", "POST"]) +@require_role("admin", "organizer") +@require_event +def admin_guest_add(): + event = get_current_event() + + if request.method == "POST": + last_name = request.form.get("last_name", "").strip() + first_name = request.form.get("first_name", "").strip() + middle_name = request.form.get("middle_name", "").strip() + full_name = " ".join([p for p in [last_name, first_name, middle_name] if p]) + + if not full_name: + flash("Фамилия и имя обязательны", "danger") + return redirect(url_for("main.admin_guest_add")) + + raw_photo_filename = request.form.get("photo_filename", "").strip() + try: + photo_filename = safe_upload_filename(raw_photo_filename) if raw_photo_filename else "" + except ValueError as exc: + flash(str(exc), "danger") + return redirect(url_for("main.admin_guest_add")) + + guest = Guest( + event_id=event.id, + department=request.form.get("department", "").strip(), + last_name=last_name, + first_name=first_name, + middle_name=middle_name, + full_name=full_name, + position=request.form.get("position", "").strip(), + email=request.form.get("email", "").strip(), + phone=request.form.get("phone", "").strip(), + photo_filename=photo_filename, + ) + db.session.add(guest) + db.session.commit() + + 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"): + try: + send_invitation(guest) + db.session.commit() + flash("Гость добавлен и приглашение отправлено", "success") + except Exception as e: + db.session.commit() + flash(f"Гость добавлен, но не удалось отправить письмо: {str(e)}", "warning") + else: + flash("Гость добавлен", "success") + + return redirect(url_for("main.admin_guests")) + + return render_template("admin/guest_form.html", event=event, guest=None) + + +@main_bp.route("/admin/guests//edit", methods=["GET", "POST"]) +@require_role("admin", "organizer") +@require_event +def admin_guest_edit(guest_id): + event = get_current_event() + guest = Guest.query.filter_by(id=guest_id, event_id=event.id).first_or_404() + + if request.method == "POST": + guest.department = request.form.get("department", "").strip() + guest.last_name = request.form.get("last_name", "").strip() + guest.first_name = request.form.get("first_name", "").strip() + guest.middle_name = request.form.get("middle_name", "").strip() + guest.full_name = " ".join([ + p for p in [guest.last_name, guest.first_name, guest.middle_name] if p + ]) or guest.full_name + guest.position = request.form.get("position", "").strip() + guest.email = request.form.get("email", "").strip() + guest.phone = request.form.get("phone", "").strip() + raw_photo_filename = request.form.get("photo_filename", "").strip() + try: + guest.photo_filename = safe_upload_filename(raw_photo_filename) if raw_photo_filename else "" + except ValueError as exc: + flash(str(exc), "danger") + return redirect(url_for("main.admin_guest_edit", guest_id=guest.id)) + 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")) + + return render_template("admin/guest_form.html", event=event, guest=guest) + + +@main_bp.route("/admin/guests//delete", methods=["POST"]) +@require_role("admin", "organizer") +@require_event +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")) + + +@main_bp.route("/admin/guests//resend", methods=["POST"]) +@require_role("admin", "organizer") +@require_event +def admin_guest_resend(guest_id): + 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() + 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 = safe_child_path(event_qr_folder(guest.event_id), guest.qr_filename) + if not qr_path or 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("/media/guests//photo") +@require_role("admin", "organizer", "staff") +def guest_photo(guest_id): + guest = db.get_or_404(Guest, guest_id) + require_event_access(guest.event) + paths = [path for path in guest._photo_paths() if path.is_file()] + if not paths: + abort(404) + return send_file(paths[0], conditional=True, max_age=300) + + +@main_bp.route("/admin/send-invitations", methods=["POST"]) +@require_role("admin", "organizer") +@require_event +def admin_send_invitations(): + event = get_current_event() + try: + _job, created = enqueue_event_invitations(event.id, current_user().id) + flash("Рассылка поставлена в очередь." if created else "Рассылка уже выполняется.", "success" if created else "info") + except Exception: + current_app.logger.exception("Failed to enqueue event invitations") + flash("Очередь рассылки недоступна. Обратитесь к администратору.", "danger") + return redirect(url_for("main.admin_dashboard")) + + +# --------------------------- +# Reports +# --------------------------- + +@main_bp.route("/admin/report") +@require_role("admin", "organizer") +@require_event +def admin_report(): + event = get_current_event() + report_path = Path(current_app.config["UPLOAD_FOLDER"]) / "reports" + report_path.mkdir(parents=True, exist_ok=True) + filename = f"report_{event.id}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx" + output_path = report_path / filename + + 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") + return redirect(url_for("main.admin_dashboard")) + + +# --------------------------- +# User management (admin only) +# --------------------------- + +@main_bp.route("/admin/users") +@require_role("admin") +def admin_users(): + users = User.query.order_by(User.created_at.desc()).all() + return render_template("admin/users.html", users=users) + + +@main_bp.route("/admin/users/add", methods=["GET", "POST"]) +@require_role("admin") +def admin_user_add(): + if request.method == "POST": + username = request.form.get("username", "").strip() + password = request.form.get("password", "").strip() + role = request.form.get("role", "staff").strip() + + if not username or not password: + flash("Логин и пароль обязательны", "danger") + return redirect(url_for("main.admin_user_add")) + + password_error = validate_password(password, current_app.config["MIN_PASSWORD_LENGTH"]) + if password_error: + flash(password_error, "danger") + return redirect(url_for("main.admin_user_add")) + + if role not in ("admin", "organizer", "staff"): + flash("Неверная роль", "danger") + return redirect(url_for("main.admin_user_add")) + + if User.query.filter_by(username=username).first(): + flash("Пользователь с таким логином уже существует", "danger") + return redirect(url_for("main.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")) + + return render_template("admin/user_form.html", user=None) + + +@main_bp.route("/admin/users//edit", methods=["GET", "POST"]) +@require_role("admin") +def admin_user_edit(user_id): + user = User.query.get_or_404(user_id) + if request.method == "POST": + username = request.form.get("username", "").strip() + password = request.form.get("password", "").strip() + role = request.form.get("role", user.role).strip() + is_active = bool(request.form.get("is_active")) + + if not username: + flash("Логин обязателен", "danger") + return redirect(url_for("main.admin_user_edit", user_id=user.id)) + + if role not in ("admin", "organizer", "staff"): + flash("Неверная роль", "danger") + return redirect(url_for("main.admin_user_edit", user_id=user.id)) + + existing = User.query.filter(User.username == username, User.id != user.id).first() + if existing: + flash("Пользователь с таким логином уже существует", "danger") + return redirect(url_for("main.admin_user_edit", user_id=user.id)) + + user.username = username + user.role = role + user.is_active = is_active + if password: + password_error = validate_password(password, current_app.config["MIN_PASSWORD_LENGTH"]) + if password_error: + flash(password_error, "danger") + return redirect(url_for("main.admin_user_edit", user_id=user.id)) + 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")) + + return render_template("admin/user_form.html", user=user) + + +@main_bp.route("/admin/users//delete", methods=["POST"]) +@require_role("admin") +def admin_user_delete(user_id): + user = User.query.get_or_404(user_id) + if user.id == current_user().id: + flash("Нельзя удалить самого себя", "danger") + 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.set_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.set_mail_password(password) + + # IMAP: копия отправленных писем (общая для всех режимов отправки) + settings.imap_save_copy = bool(request.form.get("imap_save_copy")) + settings.imap_server = request.form.get("imap_server", "").strip() + try: + settings.imap_port = int(request.form.get("imap_port", "993").strip()) + except ValueError: + settings.imap_port = 993 + settings.imap_use_ssl = bool(request.form.get("imap_use_ssl")) + settings.imap_username = request.form.get("imap_username", "").strip() + settings.imap_folder = request.form.get("imap_folder", "Sent").strip() or "Sent" + + imap_password = request.form.get("imap_password", "").strip() + if imap_password: + settings.set_imap_password(imap_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") +@limiter.limit("5 per minute") +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") +@limiter.limit("3 per minute", methods=["POST"]) +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 = db.get_or_404(Event, int(event_id)) + if not user.can_manage_event(event): + flash("Нет доступа к этому мероприятию", "danger") + return redirect(url_for("main.admin_email_send")) + + unsent_count = Guest.query.filter_by( + event_id=event.id, is_active=True, invitation_sent=False + ).count() + + if not unsent_count: + flash("Нет неотправленных приглашений для выбранного мероприятия", "info") + return redirect(url_for("main.admin_email_send")) + + try: + _job, created = enqueue_event_invitations(event.id, user.id) + flash(f"Рассылка для {unsent_count} гостей поставлена в очередь." if created else "Рассылка уже выполняется.", "success" if created else "info") + except Exception: + current_app.logger.exception("Failed to enqueue event invitations") + flash("Очередь рассылки недоступна. Обратитесь к администратору.", "danger") + 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() + failed = Guest.query.filter_by(event_id=event.id, is_active=True, invitation_status="failed").count() + event_counts.append({"event": event, "unsent": unsent, "failed": failed}) + + return render_template("admin/email_send.html", event_counts=event_counts) + + +# --------------------------- +# Check-in +# --------------------------- + +@main_bp.route("/check-in", methods=["GET", "POST"]) +@require_role("admin", "organizer", "staff") +def check_in(): + user = current_user() + token = request.args.get("token", "").strip() + guest = None + already_checked_in = False + + active_events = available_events_for_user(user).filter(Event.is_active == True).all() + + if request.method == "POST": + event_id = request.form.get("event_id", "").strip() + if event_id: + try: + selected_event = db.session.get(Event, int(event_id)) + if not selected_event or not selected_event.is_active or not can_access_event(user, selected_event): + abort(403) + session["checkin_event_id"] = selected_event.id + except ValueError: + pass + return redirect(url_for("main.check_in", token=token)) + + checkin_event_id = session.get("checkin_event_id") + checkin_event = None + if checkin_event_id: + checkin_event = db.session.get(Event, checkin_event_id) + if not checkin_event or not can_access_event(user, checkin_event): + session.pop("checkin_event_id", None) + checkin_event = None + + if token: + guest = Guest.query.filter_by(token=token, is_active=True).first() + if guest and can_access_event(user, guest.event): + already_checked_in = guest.is_checked_in + if checkin_event is None: + checkin_event = guest.event + session["checkin_event_id"] = guest.event_id + else: + guest = None + + return render_template( + "checkin/index.html", + guest=guest, + token=token, + already_checked_in=already_checked_in, + active_events=active_events, + checkin_event=checkin_event, + ) + + +# --------------------------- +# API +# --------------------------- + +@main_bp.route("/api/guests/search") +@require_role("admin", "organizer", "staff") +@limiter.limit("60 per minute") +def api_guests_search(): + q = request.args.get("q", "").strip() + event_id = request.args.get("event_id") or session.get("checkin_event_id") + + if len(q) < 2: + return jsonify([]) + + if not event_id: + return jsonify({"error": "Сначала выберите мероприятие"}), 400 + + try: + event = db.session.get(Event, int(event_id)) + except (TypeError, ValueError): + event = None + if not event or not can_access_event(current_user(), event): + abort(403) + + query = Guest.query.filter(Guest.is_active == True, Guest.event_id == event.id) + + # Ограничиваем выборку на уровне БД, чтобы поиск не загружал в память + # весь список гостей крупного мероприятия. + guests = ( + query + .options(selectinload(Guest.check_ins)) + .filter(_guest_search_expression(q)) + .order_by(Guest.full_name) + .limit(20) + .all() + ) + + return jsonify([ + { + "id": g.id, + "full_name": g.build_full_name(), + "department": g.department, + "position": g.position, + "email": g.email, + "phone": g.phone, + "photo_url": g.photo_url, + "token": g.token, + "checked_in": g.is_checked_in, + "event_id": g.event_id, + "event_name": g.event.name if g.event else "", + } + for g in guests + ]) + + +@main_bp.route("/api/check-in/", methods=["POST"]) +@require_role("admin", "organizer", "staff") +@limiter.limit("120 per minute") +def api_check_in(token): + guest = Guest.query.filter_by(token=token, is_active=True).first() + if not guest: + return jsonify({"success": False, "message": "Гость не найден или QR-код аннулирован"}), 404 + require_event_access(guest.event) + + if guest.is_checked_in: + return jsonify({ + "success": False, + "already_checked_in": True, + "checked_in_at": guest.last_check_in.isoformat(), + "message": "Гость уже отмечен как вошедший", + }), 409 + + check_in_record = CheckIn( + guest_id=guest.id, + event_id=guest.event_id, + checked_in_by_id=current_user().id, + 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 ''}'", + ) + try: + db.session.commit() + except IntegrityError: + db.session.rollback() + existing = CheckIn.query.filter_by(guest_id=guest.id).first() + return jsonify({ + "success": False, + "already_checked_in": True, + "checked_in_at": existing.checked_in_at.isoformat() if existing else None, + "message": "Гость уже отмечен как вошедший", + }), 409 + + return jsonify({ + "success": True, + "message": "Вход отмечен", + "checked_in_at": check_in_record.checked_in_at.isoformat(), + "guest": { + "full_name": guest.build_full_name(), + "department": guest.department, + "position": guest.position, + "phone": guest.phone, + }, + }) + + +@main_bp.route("/api/check-in//undo", methods=["POST"]) +@require_role("admin", "organizer", "staff") +@limiter.limit("30 per minute") +def api_check_in_undo(token): + guest = Guest.query.filter_by(token=token, is_active=True).first() + if not guest: + return jsonify({"success": False, "message": "Гость не найден"}), 404 + require_event_access(guest.event) + payload = request.get_json(silent=True) or request.form + reason = str(payload.get("reason", "")).strip() + if len(reason) < 3: + return jsonify({"success": False, "message": "Укажите причину отмены"}), 400 + record = CheckIn.query.filter_by(guest_id=guest.id).first() + if not record: + return jsonify({"success": False, "message": "Активная отметка входа не найдена"}), 409 + checked_in_at = record.checked_in_at + db.session.delete(record) + log_action( + "check_in_undo", target_type="guest", target_id=guest.id, + details=f"Отменён вход от {checked_in_at.isoformat()}. Причина: {reason[:500]}", + ) + db.session.commit() + return jsonify({"success": True, "message": "Отметка входа отменена"}) + + +@main_bp.route("/api/guest/") +@require_role("admin", "organizer", "staff") +@limiter.limit("120 per minute") +def api_guest_detail(token): + guest = Guest.query.filter_by(token=token, is_active=True).first_or_404() + require_event_access(guest.event) + return jsonify({ + "id": guest.id, + "full_name": guest.build_full_name(), + "department": guest.department, + "position": guest.position, + "email": guest.email, + "phone": guest.phone, + "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, + "event_id": guest.event_id, + "event_name": guest.event.name if guest.event else "", + }) + + +# --------------------------- +# Home +# --------------------------- + +@main_bp.route("/") +def index(): + if _is_first_run(): + return redirect(url_for("main.setup" if _setup_available() else "main.login")) + + user = current_user() + if not user: + return redirect(url_for("main.login")) + if user.is_staff: + return redirect(url_for("main.check_in")) + return redirect(url_for("main.admin_panel")) + + +@main_bp.app_context_processor +def inject_globals(): + user = current_user() + events = [] + current_event = None + if user and (user.is_admin or user.is_organizer): + events = available_events_for_user(user).filter(Event.is_active == True).all() + event_id = session.get("current_event_id") + if event_id: + candidate = db.session.get(Event, event_id) + if candidate and user.can_manage_event(candidate): + current_event = candidate + else: + session.pop("current_event_id", None) + return dict(current_user=user, available_events=events, current_event=current_event) diff --git a/app/services/__init__.py b/app/services/__init__.py new file mode 100644 index 0000000..e69de29 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 new file mode 100644 index 0000000..c19c135 --- /dev/null +++ b/app/services/email_service.py @@ -0,0 +1,291 @@ +import smtplib +import time +from email.mime.multipart import MIMEMultipart +from email.mime.text import MIMEText +from email.mime.image import MIMEImage +from email.utils import formataddr, formatdate +from pathlib import Path +from flask import current_app, has_request_context, render_template, url_for + +from app.services.ticket_service import generate_ticket_image +from app.utils.paths import event_qr_folder, safe_child_path + + +OUTLOOK_CONTENT_ID_PROP = "http://schemas.microsoft.com/mapi/proptag/0x3712001F" + + +def _build_mime_message(to_address, subject, html_body, sender_name, attachments): + """Собирает MIME-сообщение с inline-вложениями.""" + config = current_app.config + sender_address = config.get("MAIL_USERNAME", "") + if not sender_address: + raise ValueError("MAIL_USERNAME должен быть указан в конфигурации") + # Имя отправителя — отдельно от email-адреса + display_name = sender_name or config.get("MAIL_DEFAULT_SENDER") or "" + + msg = MIMEMultipart("related") + msg["Subject"] = subject + msg["From"] = formataddr((display_name, sender_address)) + msg["To"] = to_address + msg["Date"] = formatdate(localtime=True) + 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) + + return msg + + +def _send_smtp_email(to_address, subject, html_body, sender_name, attachments): + """Отправляет письмо через SMTP с inline-вложениями.""" + config = current_app.config + + 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) + + 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 = _build_mime_message(to_address, subject, html_body, sender_name, attachments) + + if use_ssl: + with smtplib.SMTP_SSL(server, port) as smtp: + smtp.login(username, password) + smtp.send_message(msg) + else: + with smtplib.SMTP(server, port) as smtp: + smtp.starttls() + smtp.login(username, password) + smtp.send_message(msg) + + return 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 _save_imap_copy(to_address, subject, html_body, sender_name, attachments): + """Сохраняет копию отправленного письма в IMAP-папку (обычно Sent).""" + import imaplib + + config = current_app.config + if not config.get("IMAP_SAVE_COPY"): + return + + server = config.get("IMAP_SERVER", "") + port = config.get("IMAP_PORT", 993) + username = config.get("IMAP_USERNAME", "") + password = config.get("IMAP_PASSWORD", "") + folder = config.get("IMAP_FOLDER", "Sent") + use_ssl = config.get("IMAP_USE_SSL", True) + + if not server or not username or not password: + raise ValueError("Для сохранения копии в IMAP должны быть указаны сервер, логин и пароль") + + msg = _build_mime_message(to_address, subject, html_body, sender_name, attachments) + msg_bytes = msg.as_bytes() + date_str = imaplib.Time2Internaldate(time.time()) + + try: + if use_ssl: + mail = imaplib.IMAP4_SSL(server, port) + else: + mail = imaplib.IMAP4(server, port) + try: + mail.starttls() + except Exception: + pass + + mail.login(username, password) + mail.select(folder) + result, data = mail.append(folder, "", date_str, msg_bytes) + if result != "OK": + raise RuntimeError(f"IMAP append failed: {data}") + mail.close() + mail.logout() + except Exception as e: + raise RuntimeError(f"Ошибка сохранения копии в IMAP: {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) + + # Сохраняем копию в IMAP, если включено + if current_app.config.get("IMAP_SAVE_COPY"): + _save_imap_copy(to_address, subject, html_body, sender_name, attachments) + + +def send_invitation(guest, event_name=None, additional_text=""): + """Отправляет приглашение гостю на email с QR-кодом и билетом во вложении.""" + from app.models import utcnow + + event = guest.event + guest.invitation_status = "sending" + guest.invitation_attempts = (guest.invitation_attempts or 0) + 1 + guest.invitation_last_attempt_at = utcnow() + guest.invitation_last_error = None + + if event_name is None: + event_name = event.name if 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}") + + base_url = (current_app.config.get("BASE_URL") or "").rstrip("/") + if base_url: + invitation_url = f"{base_url}/invite/{guest.token}" + elif has_request_context(): + invitation_url = url_for("main.public_invitation", token=guest.token, _external=True) + else: + invitation_url = f"http://localhost:5000/invite/{guest.token}" + + html_body = render_template( + "email/invitation.html", + guest=guest, + event_name=event_name, + additional_text=additional_text, + invitation_message=event.invitation_message if event else None, + invitation_url=invitation_url, + qr_cid="qr_code_image", + ticket_cid="ticket_image" if ticket_path else None, + ) + + attachments = [] + + # QR-код + qr_path = safe_child_path(event_qr_folder(guest.event_id), guest.qr_filename) + if qr_path and 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", + }) + + try: + _send_email( + to_address=guest.email, + subject=(event.invitation_subject if event and event.invitation_subject else f"Приглашение на мероприятие: {event_name}"), + html_body=html_body, + attachments=attachments, + ) + except Exception as exc: + guest.invitation_status = "failed" + guest.invitation_last_error = str(exc)[:1000] + raise + + guest.invitation_sent = True + guest.invitation_status = "sent" + guest.invitation_sent_at = utcnow() + guest.invitation_last_error = None + 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/excel_service.py b/app/services/excel_service.py new file mode 100644 index 0000000..c5eca87 --- /dev/null +++ b/app/services/excel_service.py @@ -0,0 +1,208 @@ +import io +from pathlib import Path +from flask import current_app +from openpyxl import load_workbook, Workbook +from openpyxl.styles import Font, PatternFill, Alignment +from app import db +from app.models import Guest +from app.utils.security import safe_upload_filename + + +REQUIRED_COLUMNS = ["Отдел", "Фамилия", "Имя", "Отчество", "Должность", "Почта", "Телефон", "Название фото"] + + +def build_guest_import_template(): + wb = Workbook() + ws = wb.active + ws.title = "Гости" + ws.append(REQUIRED_COLUMNS) + ws.append(["IT", "Иванов", "Иван", "Иванович", "Разработчик", "ivanov@example.com", "+7 999 111-22-33", "ivanov.jpg"]) + for cell in ws[1]: + cell.font = Font(bold=True, color="FFFFFF") + cell.fill = PatternFill(start_color="0891B2", end_color="0891B2", fill_type="solid") + cell.alignment = Alignment(horizontal="center") + for index, width in enumerate([22, 20, 18, 22, 24, 30, 22, 24], start=1): + ws.column_dimensions[ws.cell(row=1, column=index).column_letter].width = width + ws.freeze_panes = "A2" + ws.auto_filter.ref = "A1:H2" + output = io.BytesIO() + wb.save(output) + wb.close() + output.seek(0) + return output + + +def _build_full_name(last_name, first_name, middle_name): + parts = [p for p in [last_name, first_name, middle_name] if p] + return " ".join(parts) + + +def _safe_excel_value(value): + """Prevent spreadsheet formula injection in exported user-controlled cells.""" + if isinstance(value, str) and value.startswith(("=", "+", "-", "@", "\t", "\r")): + return f"'{value}" + return value + + +def read_guests_from_excel(file_path): + """Читает список гостей из Excel-файла (путь или файловый объект).""" + if not hasattr(file_path, "read"): + file_path = Path(file_path) + wb = load_workbook(file_path, data_only=True) + try: + ws = wb.active + + if ws.max_row - 1 > current_app.config["MAX_EXCEL_ROWS"]: + raise ValueError(f"В Excel допускается не более {current_app.config['MAX_EXCEL_ROWS']} строк") + + # Определяем заголовки и сразу считываем все строки + headers = [str(cell.value).strip() if cell.value else "" for cell in ws[1]] + rows = list(ws.iter_rows(min_row=2, values_only=True)) + finally: + wb.close() + + missing = [col for col in REQUIRED_COLUMNS if col not in headers] + if missing: + raise ValueError(f"В файле отсутствуют обязательные столбцы: {', '.join(missing)}") + + col_indices = {col: headers.index(col) for col in REQUIRED_COLUMNS} + guests_data = [] + + for row in rows: + last_name = str(row[col_indices["Фамилия"]]).strip() if row[col_indices["Фамилия"]] else "" + first_name = str(row[col_indices["Имя"]]).strip() if row[col_indices["Имя"]] else "" + middle_name = str(row[col_indices["Отчество"]]).strip() if row[col_indices["Отчество"]] else "" + email = str(row[col_indices["Почта"]]).strip() if row[col_indices["Почта"]] else "" + + if not email: + continue + + full_name = _build_full_name(last_name, first_name, middle_name) + if not full_name: + # Если ФИО не заполнено, но есть email — пропускаем строку + continue + + raw_photo_filename = str(row[col_indices["Название фото"]]).strip() if row[col_indices["Название фото"]] else "" + photo_filename = safe_upload_filename(raw_photo_filename) if raw_photo_filename else "" + + guests_data.append({ + "department": str(row[col_indices["Отдел"]]).strip() if row[col_indices["Отдел"]] else "", + "last_name": last_name, + "first_name": first_name, + "middle_name": middle_name, + "full_name": full_name, + "position": str(row[col_indices["Должность"]]).strip() if row[col_indices["Должность"]] else "", + "email": email, + "phone": str(row[col_indices["Телефон"]]).strip() if row[col_indices["Телефон"]] else "", + "photo_filename": photo_filename, + }) + + return guests_data + + +def import_guests_from_excel(file_path, event_id): + """Импортирует гостей из Excel в базу данных в рамках указанного мероприятия.""" + if not event_id: + raise ValueError("Не выбрано мероприятие для импорта") + + guests_data = read_guests_from_excel(file_path) + imported = 0 + updated = 0 + + for data in guests_data: + guest = Guest.query.filter_by(event_id=event_id, email=data["email"]).first() + if guest: + guest.department = data["department"] + guest.last_name = data["last_name"] + guest.first_name = data["first_name"] + guest.middle_name = data["middle_name"] + guest.full_name = data["full_name"] + guest.position = data["position"] + guest.phone = data["phone"] + guest.photo_filename = data["photo_filename"] + guest.is_active = True + updated += 1 + else: + guest = Guest( + event_id=event_id, + department=data["department"], + last_name=data["last_name"], + first_name=data["first_name"], + middle_name=data["middle_name"], + full_name=data["full_name"], + position=data["position"], + email=data["email"], + phone=data["phone"], + photo_filename=data["photo_filename"], + ) + db.session.add(guest) + imported += 1 + + db.session.commit() + return {"imported": imported, "updated": updated, "total": len(guests_data)} + + +def export_report_to_excel(output_path, event_id, event_name=None): + """Выгружает отчёт о посещаемости для мероприятия в Excel.""" + if not event_id: + raise ValueError("Не выбрано мероприятие для отчёта") + + guests = Guest.query.filter_by(event_id=event_id).order_by(Guest.department, Guest.full_name).all() + + wb = Workbook() + ws = wb.active + ws.title = "Отчёт" + + headers = [ + "Мероприятие", "Отдел", "Фамилия", "Имя", "Отчество", + "Должность", "Email", "Телефон", "Название фото", + "Статус приглашения", "Попыток отправки", "Ошибка отправки", + "RSVP", "Ответ получен", "Особенности питания", "Комментарий", + "Статус входа", "Время входа" + ] + ws.append(headers) + + # Стили заголовков + for cell in ws[1]: + cell.font = Font(bold=True) + cell.fill = PatternFill(start_color="DDDDDD", end_color="DDDDDD", fill_type="solid") + cell.alignment = Alignment(horizontal="center") + + for guest in guests: + status = "Пришёл" if guest.is_checked_in else "Не пришёл" + check_in_time = guest.last_check_in.strftime("%d.%m.%Y %H:%M:%S") if guest.last_check_in else "" + ws.append([ + _safe_excel_value(event_name or ""), + _safe_excel_value(guest.department), + _safe_excel_value(guest.last_name or ""), + _safe_excel_value(guest.first_name or ""), + _safe_excel_value(guest.middle_name or ""), + _safe_excel_value(guest.position or ""), + _safe_excel_value(guest.email), + _safe_excel_value(guest.phone or ""), + _safe_excel_value(guest.photo_filename or ""), + guest.invitation_status or "pending", + guest.invitation_attempts or 0, + _safe_excel_value(guest.invitation_last_error or ""), + guest.rsvp_status or "pending", + guest.rsvp_responded_at.strftime("%d.%m.%Y %H:%M:%S") if guest.rsvp_responded_at else "", + _safe_excel_value(guest.dietary_requirements or ""), + _safe_excel_value(guest.rsvp_comment or ""), + status, + check_in_time, + ]) + + # Автоширина колонок + for column in ws.columns: + max_length = 0 + column_letter = column[0].column_letter + for cell in column: + try: + if cell.value: + max_length = max(max_length, len(str(cell.value))) + except Exception: + pass + ws.column_dimensions[column_letter].width = min(max_length + 2, 50) + + wb.save(output_path) + return output_path diff --git a/app/services/qr_service.py b/app/services/qr_service.py new file mode 100644 index 0000000..a7754f7 --- /dev/null +++ b/app/services/qr_service.py @@ -0,0 +1,43 @@ +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-код для гостя и сохраняет его в папку мероприятия.""" + token = guest.token + qr_folder = event_qr_folder(guest.event_id) + qr_folder.mkdir(parents=True, exist_ok=True) + + qr_filename = f"{token}.png" + qr_path = qr_folder / qr_filename + + # URL, который будет закодирован в QR + if base_url is None: + configured_base_url = (current_app.config.get("BASE_URL") or "").rstrip("/") + if configured_base_url: + check_in_url = f"{configured_base_url}/check-in?token={token}" + else: + try: + check_in_url = url_for("main.check_in", token=token, _external=True) + except Exception: + check_in_url = f"http://localhost:5000/check-in?token={token}" + else: + check_in_url = f"{base_url.rstrip('/')}/check-in?token={token}" + + qr = qrcode.QRCode( + version=1, + error_correction=qrcode.constants.ERROR_CORRECT_H, + box_size=10, + border=4, + ) + qr.add_data(check_in_url) + qr.make(fit=True) + + img = qr.make_image(fill_color="black", back_color="white") + img.save(str(qr_path)) + + guest.qr_filename = qr_filename + return qr_path, check_in_url diff --git a/app/services/ticket_service.py b/app/services/ticket_service.py new file mode 100644 index 0000000..2be3025 --- /dev/null +++ b/app/services/ticket_service.py @@ -0,0 +1,348 @@ +from pathlib import Path + +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, safe_child_path + + +TICKET_SIZE = (650, 1150) + +NON_TRANSFERABLE_TEXT = ( + "Билет действителен только для указанного гостя. " + "Передача третьим лицам запрещена." +) + + +def _load_logo_image(logo_path): + """Load a previously validated raster logo.""" + return Image.open(str(logo_path)).convert("RGBA") + + +def _draw_logo_centered(img, event, y, max_width): + """Рисует логотип по центру, возвращает новую y-координату.""" + if not event.logo_filename: + return y + + logo_path = safe_child_path(event_logo_folder(event.id), event.logo_filename) + if not logo_path or not logo_path.exists(): + current_app.logger.warning(f"Логотип не найден: {logo_path}") + return y + + try: + logo = _load_logo_image(logo_path) + if logo is None: + return y + + # Масштабируем по ширине + if logo.width <= 0 or logo.height <= 0: + current_app.logger.warning(f"Логотип имеет некорректные размеры: {logo.width}x{logo.height}") + return y + + ratio = logo.width / logo.height + new_width = min(max_width, logo.width) + new_height = int(new_width / ratio) + logo = logo.resize((new_width, new_height), Image.Resampling.LANCZOS) + + x = (TICKET_SIZE[0] - new_width) // 2 + img.paste(logo, (x, y), logo) + return y + new_height + 25 + except Exception as e: + current_app.logger.warning(f"Не удалось отрисовать логотип {event.logo_filename}: {e}") + return y + + +def hex_to_rgb(hex_color): + """Конвертирует HEX-цвет в кортеж RGB.""" + hex_color = hex_color.lstrip("#") + if len(hex_color) != 6: + return (255, 255, 255) + return tuple(int(hex_color[i:i + 2], 16) for i in (0, 2, 4)) + + +def _adjust_color(color, factor=0.85): + """Затемняет или осветляет RGB-цвет.""" + rgb = color if isinstance(color, tuple) else hex_to_rgb(color) + return tuple(min(255, max(0, int(c * factor))) for c in rgb) + + +def _get_font(size, style="regular"): + """Пытается загрузить системный шрифт.""" + candidates = { + "regular": ["calibri.ttf", "segoeui.ttf", "arial.ttf"], + "bold": ["calibrib.ttf", "segoeuib.ttf", "arialbd.ttf"], + "serif": ["times.ttf", "georgia.ttf", "cambria.ttf", "calibri.ttf"], + "serif_bold": ["timesbd.ttf", "georgiab.ttf", "cambriab.ttf", "calibrib.ttf"], + } + + for font_name in candidates.get(style, candidates["regular"]): + font_path = Path("C:/Windows/Fonts") / font_name + if font_path.exists(): + try: + return ImageFont.truetype(str(font_path), size) + except Exception: + pass + return ImageFont.load_default() + + +def _draw_text_centered(draw, text, y, width, font, fill, max_width=None): + """Рисует текст по центру с переносом по словам.""" + if not text: + return y + + max_width = max_width or width - 80 + words = text.split() + lines = [] + current_line = "" + + for word in words: + test_line = f"{current_line} {word}".strip() + bbox = draw.textbbox((0, 0), test_line, font=font) + if bbox[2] - bbox[0] <= max_width: + current_line = test_line + else: + if current_line: + lines.append(current_line) + current_line = word + if current_line: + lines.append(current_line) + + line_height = font.size + 10 + for line in lines: + bbox = draw.textbbox((0, 0), line, font=font) + text_width = bbox[2] - bbox[0] + x = (width - text_width) // 2 + draw.text((x, y), line, font=font, fill=fill) + y += line_height + + return y + + +def _draw_text_left(draw, text, x, y, font, fill, max_width): + """Рисует текст слева с переносом по словам.""" + if not text: + return y + + words = text.split() + lines = [] + current_line = "" + + for word in words: + test_line = f"{current_line} {word}".strip() + bbox = draw.textbbox((0, 0), test_line, font=font) + if bbox[2] - bbox[0] <= max_width: + current_line = test_line + else: + if current_line: + lines.append(current_line) + current_line = word + if current_line: + lines.append(current_line) + + line_height = font.size + 6 + for line in lines: + draw.text((x, y), line, font=font, fill=fill) + y += line_height + + return y + + +def _draw_info_row(draw, label, value, x, y, width, label_font, value_font, fill): + """Рисует строку с подписью и значением по центру.""" + if not value: + return y + + # Подпись по центру + bbox = draw.textbbox((0, 0), label, font=label_font) + label_width = bbox[2] - bbox[0] + draw.text((x + (width - label_width) // 2, y), label, font=label_font, fill=fill) + y += label_font.size + 4 + + # Значение по центру + words = value.split() + lines = [] + current_line = "" + for word in words: + test_line = f"{current_line} {word}".strip() + bbox = draw.textbbox((0, 0), test_line, font=value_font) + if bbox[2] - bbox[0] <= width: + current_line = test_line + else: + if current_line: + lines.append(current_line) + current_line = word + if current_line: + lines.append(current_line) + + line_height = value_font.size + 6 + for line in lines: + bbox = draw.textbbox((0, 0), line, font=value_font) + text_width = bbox[2] - bbox[0] + draw.text((x + (width - text_width) // 2, y), line, font=value_font, fill=fill) + y += line_height + + return y + 16 + + +def _draw_ornament(draw, y, width, color): + """Рисует декоративную горизонтальную линию с точками по краям.""" + margin = 100 + draw.line([(margin, y), (width - margin, y)], fill=color, width=1) + r = 4 + draw.ellipse([margin - r, y - r, margin + r, y + r], fill=color) + draw.ellipse([width - margin - r, y - r, width - margin + r, y + r], fill=color) + + +def generate_ticket_image(guest, event, output_path=None): + """Генерирует элегантный PNG-пригласительный билет.""" + if not guest.qr_filename: + generate_qr_for_guest(guest) + + width, height = TICKET_SIZE + bg_color = hex_to_rgb(event.ticket_background_color or "#ffffff") + text_color = hex_to_rgb(event.ticket_text_color or "#1a1a1a") + accent_color = hex_to_rgb(event.ticket_accent_color or "#0d6efd") + + img = Image.new("RGB", (width, height), bg_color) + draw = ImageDraw.Draw(img) + + # Мягкий фоновый градиент/рамка + for i in range(20): + alpha = int(255 * (1 - i / 40)) + c = tuple(min(255, int(bg_color[j] + (accent_color[j] - bg_color[j]) * (i / 60))) for j in range(3)) + draw.rectangle([i, i, width - 1 - i, height - 1 - i], outline=c, width=1) + + # Декоративная внешняя рамка + draw.rectangle([22, 22, width - 22, height - 22], outline=accent_color, width=2) + draw.rectangle([28, 28, width - 28, height - 28], outline=_adjust_color(accent_color, 1.3), width=1) + + # Шрифты + title_font = _get_font(32, "serif_bold") + subtitle_font = _get_font(20, "serif") + name_font = _get_font(28, "bold") + body_font = _get_font(17, "regular") + label_font = _get_font(13, "regular") + small_font = _get_font(12, "regular") + + y = 50 + + # Логотип по центру сверху + y = _draw_logo_centered(img, event, y, max_width=300) + + # Название мероприятия + title = event.ticket_title or event.name + y = _draw_text_centered(draw, title, y, width, title_font, text_color, max_width=width - 80) + + # Подзаголовок + if event.ticket_subtitle: + y += 8 + y = _draw_text_centered(draw, event.ticket_subtitle, y, width, subtitle_font, text_color, max_width=width - 80) + + y += 25 + _draw_ornament(draw, y, width, accent_color) + y += 30 + + # Приглашается + y = _draw_text_centered(draw, "ПРИГЛАШАЕТСЯ", y, width, label_font, text_color) + y += 12 + y = _draw_text_centered(draw, guest.build_full_name(), y, width, name_font, text_color, max_width=width - 80) + y += 30 + + # Данные гостя + content_width = width - 120 + x_content = 60 + + if guest.department: + y = _draw_info_row(draw, "Отдел", guest.department, x_content, y, content_width, label_font, body_font, text_color) + if guest.position: + y = _draw_info_row(draw, "Должность", guest.position, x_content, y, content_width, label_font, body_font, text_color) + if guest.email: + y = _draw_info_row(draw, "Email", guest.email, x_content, y, content_width, label_font, body_font, text_color) + if guest.phone: + y = _draw_info_row(draw, "Телефон", guest.phone, x_content, y, content_width, label_font, body_font, text_color) + + # Инфо о мероприятии + if event.event_date or event.location: + y += 10 + _draw_ornament(draw, y, width, accent_color) + y += 25 + if event.event_date: + 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: + y = _draw_info_row(draw, "Место проведения", event.location, x_content, y, content_width, label_font, body_font, text_color) + + # QR-код + if event.ticket_show_qr: + y += 15 + qr_path = safe_child_path(event_qr_folder(guest.event_id), guest.qr_filename) + if qr_path and qr_path.exists(): + qr_size = 200 + qr_img = Image.open(str(qr_path)).convert("RGB") + qr_img = qr_img.resize((qr_size, qr_size)) + x_qr = (width - qr_size) // 2 + img.paste(qr_img, (x_qr, y)) + y += qr_size + 12 + y = _draw_text_centered(draw, "QR-код для входа", y, width, small_font, text_color) + + # Фото гостя + if event.ticket_show_photo and guest.photo_filename: + photo_path = safe_child_path(event_photo_folder(guest.event_id), guest.photo_filename) + if photo_path and photo_path.exists(): + photo_img = Image.open(str(photo_path)).convert("RGB") + photo_img.thumbnail((130, 130)) + x_photo = (width - photo_img.width) // 2 + img.paste(photo_img, (x_photo, y)) + y += photo_img.height + 20 + + # Дополнительный текст + if event.ticket_body_text: + y += 10 + y = _draw_text_centered(draw, event.ticket_body_text, y, width, small_font, text_color, max_width=width - 80) + y += small_font.size + 10 + + # Номер билета + ticket_id = f"№ {guest.token[:8].upper()}" if not guest.token.startswith("preview") else "№ PREVIEW" + y = max(y, height - 105) + _draw_text_centered(draw, ticket_id, y, width, small_font, text_color) + + # Нижнее уведомление + draw.rectangle([22, height - 65, width - 22, height - 22], fill=accent_color) + _draw_text_centered(draw, NON_TRANSFERABLE_TEXT, height - 50, width, small_font, (255, 255, 255), max_width=width - 60) + + # Сохранение + if output_path is None: + 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" + + img.save(str(output_path), "PNG") + return Path(output_path) + + +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="Иванович", + full_name="Иванов Иван Иванович", + department="Отдел продаж", + position="Менеджер", + email="example@company.com", + phone="+7 (999) 123-45-67", + token="preview-token-12345", + qr_filename=None, + ) + from app.services.qr_service import generate_qr_for_guest + generate_qr_for_guest(sample_guest, base_url="http://example.com") + + return generate_ticket_image(sample_guest, event) diff --git a/app/static/css/style.css b/app/static/css/style.css new file mode 100644 index 0000000..c3bebe4 --- /dev/null +++ b/app/static/css/style.css @@ -0,0 +1,5 @@ +:root{--bg:#07101d;--sidebar:#091321;--surface:#101d2e;--surface-2:#14243a;--border:#23364c;--text:#f4f7fb;--muted:#91a3b8;--accent:#22c7f0;--accent-2:#7c69ff;--success:#32d6a0;--danger:#ff6174;--warning:#f9bd52;--sidebar-w:264px;--topbar-h:76px} +*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--text);font-family:Inter,ui-sans-serif,system-ui,-apple-system,"Segoe UI",sans-serif;min-height:100vh}a{color:var(--accent);text-decoration:none}h1,h2,h3,h4,h5,h6{color:var(--text);letter-spacing:-.02em}.text-muted{color:var(--muted)!important}.app-shell{min-height:100vh}.app-sidebar{position:fixed;inset:0 auto 0 0;width:var(--sidebar-w);z-index:1040;background:linear-gradient(180deg,#0b1727,#08111d);border-right:1px solid var(--border);display:flex;flex-direction:column;padding:24px 16px}.sidebar-brand{display:flex;align-items:center;gap:12px;color:var(--text);padding:0 8px 24px}.sidebar-brand:hover{color:var(--text)}.sidebar-brand strong{display:block;font-size:1.08rem;letter-spacing:.01em}.sidebar-brand small{display:block;color:var(--muted);font-size:.68rem;text-transform:uppercase;letter-spacing:.12em}.brand-mark{width:38px;height:38px;border-radius:12px;display:grid;place-items:center;background:linear-gradient(145deg,var(--accent),var(--accent-2));color:#05101a;font-weight:900;box-shadow:0 8px 24px rgba(34,199,240,.22)}.sidebar-nav{overflow-y:auto;flex:1}.nav-caption{margin:16px 12px 8px;color:#62758a;font-size:.67rem;font-weight:700;letter-spacing:.13em;text-transform:uppercase}.nav-caption-spaced{margin-top:28px}.sidebar-link{display:flex;align-items:center;gap:12px;padding:11px 12px;margin:3px 0;color:#a9b8c8;border:1px solid transparent;border-radius:11px;font-size:.9rem;font-weight:550;transition:.18s}.sidebar-link:hover{color:var(--text);background:#122237}.sidebar-link.active{color:#eafbff;background:linear-gradient(90deg,rgba(34,199,240,.17),rgba(124,105,255,.08));border-color:rgba(34,199,240,.25);box-shadow:inset 3px 0 var(--accent)}.nav-icon{width:20px;height:20px;fill:none;stroke:currentColor;stroke-width:1.7;stroke-linecap:round;stroke-linejoin:round}.sidebar-link[href] .nav-icon use[href$="#grid"]{fill:currentColor;stroke:none}.sidebar-profile{display:flex;align-items:center;gap:10px;border-top:1px solid var(--border);padding:18px 6px 0}.avatar{width:36px;height:36px;display:grid;place-items:center;border-radius:50%;background:#1d3450;color:var(--accent);font-weight:800}.profile-copy{min-width:0;flex:1}.profile-copy strong,.profile-copy small{display:block;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.profile-copy strong{font-size:.84rem}.profile-copy small{font-size:.72rem;color:var(--muted);text-transform:capitalize}.icon-button{border:0;background:transparent;color:var(--muted);padding:8px;border-radius:9px;display:grid;place-items:center}.icon-button:hover{background:#17283c;color:var(--text)}.app-main{margin-left:var(--sidebar-w);min-height:100vh}.app-topbar{height:var(--topbar-h);position:sticky;top:0;z-index:1020;display:flex;align-items:center;gap:16px;padding:0 clamp(20px,4vw,50px);background:rgba(7,16,29,.84);border-bottom:1px solid rgba(35,54,76,.8);backdrop-filter:blur(16px)}.topbar-context small,.topbar-context strong{display:block}.topbar-context small{font-size:.68rem;color:var(--muted);letter-spacing:.08em;text-transform:uppercase}.topbar-context strong{font-size:.93rem;max-width:36vw;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.event-switcher{border:1px solid var(--border);background:var(--surface);color:var(--text);border-radius:10px;padding:9px 14px;font-size:.82rem;max-width:310px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.status-dot{display:inline-block;width:7px;height:7px;margin-right:9px;border-radius:50%;background:var(--success);box-shadow:0 0 9px var(--success)}.content-wrap{max-width:1520px;margin:auto;padding:clamp(24px,4vw,48px)}.page-header{display:flex;align-items:flex-end;justify-content:space-between;gap:20px;margin-bottom:28px}.eyebrow{color:var(--accent);font-size:.72rem;letter-spacing:.13em;text-transform:uppercase;font-weight:750;margin-bottom:7px}.page-title{font-size:clamp(1.65rem,3vw,2.35rem);font-weight:760;margin:0}.page-subtitle{color:var(--muted);margin:7px 0 0;max-width:700px}.card,.module-card,.table-card,.stat-card{background:linear-gradient(145deg,rgba(17,31,49,.98),rgba(12,24,39,.98));border:1px solid var(--border);border-radius:16px;color:var(--text);box-shadow:0 16px 45px rgba(0,0,0,.14)}.card-header,.card-footer{background:transparent;border-color:var(--border)}.module-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:16px}.module-card{padding:22px;display:flex;flex-direction:column;min-height:215px;transition:transform .18s,border-color .18s}.module-card:hover{transform:translateY(-3px);border-color:#365570}.module-icon{width:46px;height:46px;border-radius:13px;background:linear-gradient(145deg,rgba(34,199,240,.17),rgba(124,105,255,.15));display:grid;place-items:center;color:var(--accent);font-size:1.35rem;margin-bottom:22px}.module-card h2{font-size:1.03rem}.module-card p{color:var(--muted);font-size:.87rem;line-height:1.55;flex:1}.module-card .btn{align-self:flex-start}.stats-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:16px;margin-bottom:28px}.stat-card{padding:20px;position:relative;overflow:hidden}.stat-label{font-size:.76rem;color:var(--muted);text-transform:uppercase;letter-spacing:.06em}.stat-value{font-size:2.15rem;font-weight:780;line-height:1.1;margin-top:10px}.stat-meta{font-size:.78rem;color:var(--muted);margin-top:9px}.stat-card::after{content:"";position:absolute;width:90px;height:90px;border-radius:50%;right:-35px;top:-35px;background:rgba(34,199,240,.08)}.table-card{overflow:hidden}.table{--bs-table-bg:transparent;--bs-table-color:var(--text);--bs-table-border-color:var(--border);margin:0}.table th{font-size:.7rem;letter-spacing:.08em;text-transform:uppercase;color:var(--muted);font-weight:700;padding:15px 18px;white-space:nowrap}.table td{padding:15px 18px;vertical-align:middle}.table-hover>tbody>tr:hover>*{--bs-table-bg-state:rgba(34,199,240,.045);color:var(--text)}.form-control,.form-select,.form-control-color{background:#0a1727;border:1px solid #2a4058;color:var(--text);border-radius:10px;min-height:44px}.form-control:focus,.form-select:focus{background:#0c1a2b;color:var(--text);border-color:var(--accent);box-shadow:0 0 0 3px rgba(34,199,240,.13)}.form-control::placeholder{color:#60758b}.form-label{color:#c4d0dd;font-size:.82rem;font-weight:600}.form-check-input{background-color:#0a1727;border-color:#40566e}.btn{border-radius:10px;font-weight:650;font-size:.84rem;padding:.68rem 1rem}.btn-primary{border:0;color:#041119;background:linear-gradient(135deg,var(--accent),#53ddf8);box-shadow:0 7px 18px rgba(34,199,240,.17)}.btn-primary:hover{color:#041119;background:#65e3fb;transform:translateY(-1px)}.btn-success{border:0;background:var(--success);color:#052017}.btn-outline-primary,.btn-outline-info{border-color:#34718a;color:#55d8f7}.btn-outline-primary:hover,.btn-outline-info:hover{background:rgba(34,199,240,.13);border-color:var(--accent);color:#dffaff}.btn-outline-secondary{border-color:#405268;color:#afbdcb}.dropdown-menu{background:#101d2e;border-color:var(--border);box-shadow:0 18px 50px rgba(0,0,0,.35);padding:7px}.dropdown-item{color:#bfccda;border-radius:7px}.dropdown-item:hover,.dropdown-item:focus{color:#fff;background:#1a3048}.dropdown-item.active{background:rgba(34,199,240,.18);color:var(--accent)}.alert{border-radius:12px;border:1px solid var(--border);color:var(--text);background:#13243a}.alert-info{border-color:#245a72;background:#102c3d}.alert-success{border-color:#23624f;background:#102e29}.alert-warning{border-color:#6b5429;background:#332b1d}.alert-danger{border-color:#713141;background:#361b28}.btn-close{filter:invert(1)}.badge{padding:.5em .7em;border-radius:7px}.mobile-menu,.sidebar-backdrop{display:none}.public-page{background:radial-gradient(circle at 15% 10%,rgba(34,199,240,.12),transparent 29%),radial-gradient(circle at 90% 70%,rgba(124,105,255,.12),transparent 32%),var(--bg)}.public-header{height:78px;display:flex;align-items:center;max-width:1200px;margin:auto;padding:0 28px}.public-header .sidebar-brand{padding:0}.public-content{width:min(1120px,calc(100% - 32px));margin:0 auto;padding:40px 0 80px}.auth-layout{min-height:calc(100vh - 160px);display:grid;place-items:center}.auth-card{width:min(430px,100%);padding:34px}.login-logo{height:58px;width:auto;max-width:180px;object-fit:contain}.invitation-card{max-width:920px;margin:0 auto}.invitation-card .card-body{padding:clamp(25px,5vw,60px)!important}.invitation-message{white-space:pre-line}.checkin-container{max-width:1120px;margin:auto}.checkin-grid{display:grid;grid-template-columns:1.2fr .8fr;gap:18px}.qr-reader{width:100%;min-height:330px;border:1px dashed #3c586f;border-radius:13px;display:flex;align-items:center;justify-content:center;background:#081522}.guest-photo{width:180px;height:180px;object-fit:cover;border-radius:16px;border:2px solid var(--border)}#search-results .list-group-item{cursor:pointer;background:#0c1a2b;border-color:var(--border);color:var(--text)}#search-results .list-group-item:hover{background:#132c42;color:var(--accent)}.sidebar-backdrop{position:fixed;inset:0;border:0;background:rgba(0,0,0,.58);z-index:1030} +@media(max-width:1100px){.stats-grid{grid-template-columns:repeat(2,1fr)}} +@media(max-width:991px){.app-sidebar{transform:translateX(-100%);transition:transform .22s}.sidebar-open .app-sidebar{transform:translateX(0)}.sidebar-open .sidebar-backdrop{display:block}.app-main{margin-left:0}.mobile-menu{display:grid}.content-wrap{padding:26px 20px}.checkin-grid{grid-template-columns:1fr}} +@media(max-width:620px){.app-topbar{height:66px;padding:0 14px}.topbar-context small{display:none}.event-switcher{max-width:46vw}.page-header{align-items:flex-start;flex-direction:column}.page-header .btn{width:100%}.stats-grid{grid-template-columns:1fr 1fr;gap:10px}.stat-card{padding:16px}.stat-value{font-size:1.65rem}.module-grid{grid-template-columns:1fr}.public-header{height:68px;padding:0 16px}.public-content{padding-top:22px}.table-responsive{border-radius:14px}} diff --git a/app/static/images/icons.svg b/app/static/images/icons.svg new file mode 100644 index 0000000..4370327 --- /dev/null +++ b/app/static/images/icons.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + 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 zcmeHNi96Km+n+>EI?}1AENL00LL^((_8PnFYbp{#IJOK!C8vnfcS4q-l4Ku*#@LrM zDayW$DW;TVWW+Sa^4_EK{(isry{`8^cwN_+neRNy{oKpvUY`4osqqQD@~j;(%k8(1KXzWfM*pS2Z(^qF zgEnpY19@YK*az5{ofp@ z`!e88S)1j+?Oq9fI;DG5LYMtr%kpsi8WhS|G5ZBA^5WAX-t1fpBR+%mJRu(?j$@s3 z>UfNO-l00c!=D+bij`JojiNdGWP2LcIkESvPtMlNIg|qGP-*}I*u5Y&S3gQinIBh|wySuty!~sh}-MPrs zIoUAa%+uASJQ!P#^G5dAof5V2Vy{k;?IP59P}cH3t33IilnIEz!&SFQ)>0s zYQ*;H(|GOijrNC4R=H>&atY^8mG2pfmMMU7ANB1+3AmV9nPM9gX-#_;kEe0vbLww4 z+vab$BM0C5QYF5iE3{2E#{`Cb*OKA!|6}lw%5-G{T1{^o`dC>`oZmm<9227BCxIv(G!Fo@FQ}%W~t3s?6bR3nVO2uGcR$v^3+;-!2*xk zjmHIgd>ww!D(K{?4dZtDFU`BJ6nbxY74#|d7qO4fB;)7h4kx@uk62BO0?#6*(2)Ur zsiq(NHhoJz2krcic@D&O}2aOC8mFu8RMR ze!$;@?Xpld@3fge$NdR|ob7Fo2XCI{4j&E*jFX?YKF~k#zDc7z04L}*&2?%OgyViB zSvXG_D3GJ=UioVOvkrm3N4Lwb(otblBYmKSC)`1OK2K?noQWJ)$rkf}mhNB6lW9cR zGu}_ODORl-aoO`TJPwv4bpz2veX;Cr<+#zDFdPgc6`)D;nTc=G|6>~c}Po>%49n^ zM#$nhWZJ}!sI$|rkv5-&t=UaA$8>V!2-=V76N`;+G(nWY#kaom-)`R_-H$zd-s_5o z{GdinBThKuaN@WYT3peBVFj)!^v%0OHN}+t#iMlP*w(DW%-X``e&?DdLyPNZoUb= zNC}L9>B(6*OSL$r>*$^vOxG{7-sjculgXN#xgWIrzAk(=S|zzHBY_bubbGNPyr@%d zyNLdEJ7hvMmYoVaCcqXQHx7NewJy{4Tl>_~b!KqiQoUGV=)RjDCjY=XeQ3z?(b^fP z=<%r%b{V`vO|g?!D> z#+t6S{hhvrV&R)i9?<1$t(QmH=+Qe&hkU&E9n4RakPW#qNbHNRzY<78)4KxoP|h?^ z%2!^@2ow6u+D^X9vSo2&6Bd@o4p9H7b#GMjX@ z_fY2snf$PWsLdHNxAfpu+_#VA^2MGf1`XDV3^14TH^>)xnEbX*LR_MUI!C}=8YF7m2QsfC z(G@fiR3HCl8!U5%l9yLCkSLU;_QKIDpf6AaEB|p^ci4Fn@1ryyY4mK?6Sm@-Je$S9 zE6DKK>|hxUlUu^cNliK-EHUpVv|tUec|Cwg%O= z)jpNCre!tBsW8|?-(?NfiNKf;|BGua+@zgD{WJU?n*Daqg}LvvjIf?!pX!J z1th+$0UNvQ6*RIhQG~B<4fx_W_lfU3%4$!S#rqJ&P0IkG($Q>V)>Cvw-&p5C6V@3l zvwu3E%{|wLbgb|`Lu-I}68Zgf>7WeRdTm&jNT zYSW8oD^q)A$Y;bbT$&O!8(9oRf-oVDi1m+9BHlf#lvJ}bp&98d&#B>yIPn}4=_;5{ z$X!y+1%L)}w_w})z3S?R+>}yim$fIIy8QIoyg3Uf(*1E>d~viO{HbZ0RbIxr%zh$* zYu=p^j;1P#O>RV8-70Eh|8&0hIe>qiSg6}$jd*?5gX6^;FC~xX`68|Q0sJU6BUMow zEs351XIy|9s*f)N%X-Y`%T(U}j%l;2?ET zP_{H}wFz7)s5l9jYM-AXB+r#5I!qOrfy0PUuu_f;hz6Ugm?3fJkIxu;D5XrHIdL+_ zaff1NR<0)RTvPYOEVBEY=gpb!ew6cuv~)&+z-YmON`s&a`E&e=(#}9VBEvvvP7tML z3eosL1>Uu##QWwS=J~R3++Esg0lCO?hveZW5ydy$wU&7h_&9j4!Ilym6)YC?}v;9%9Ob6+gbwxPMIoL`Y?K%Ovo9e_8VJM+R?W{y?O3c zBeO*?v!7c!qdz0sC&-I zBjkbenc&J>z~h)0upF^93RJwlU%?jb#Y)Q5Y1^SqXzHb)(JDPE2OC~XpK$21z&{oP zrP9Riyx-hGD#H2^!nCJ;(0UB~zU(|i&tP>G~C&cJt=2w^KTK7)l37Z3bBaL5y0 z%0xW{YW%j}IR?NTJdu$C-gK~MH2jIpMcG+G1esRA4h9-?3DL8@`AigTl^#je8lbpYY(RV6NhgpvBY9%b1BhqI% zsZ*EcN*<09fZRO7x2l2h)UcO(BIg#i=#D$rgej#Ij0AluO~NhHr4p_knYtA}v| z;LQ*D2|VJ$1C&y{W^sIdXS>m)f#I{EThxZtYBVJVT|x7lm5H?~RD+V*&!VjwI#hqy zwG@bYEEfnIprb?ZWN*iB7lfOJT5p=yT;!xR%v^czqNx`KL5~$uA;am(Umj&&w@uS! zr-R4oxm#3y(AWS0n<_+0@84RQ#PHnEJkaT^qNPgRoAmqa-OTVBCQ@vt4w@kyd)f04 z4@%10FcJ4GO!y{VUoKl!(F)^xQvl>X$&3|OPPN6x@nWo#a&lV{`DE;@e!!P~dHT*_ zVV{DNT<=G?tOb7yQC>P)FVQ9Wh-t$+Le&)zp-<3CftO!(W_Y-3{Y#^a(?5l6Yf8Sl zVgYRe5r@!kJIk&B-)J~c$@W1SenW(+wGrQjN0%v~%&H?X1cAZt3fOdV(`>5E>7M~0 zos$whN#``p0*`^}o&5B0s~V7V1Ol#y3`?wcPar;SW(hkC5tz@1z%y1~zw$S71V+Or zoK-wo{x1Fe{evxy%+MhGa38uApzDjXiv0vnH0AEPYpy=^fRxR;{_64WSb&=6bnuC` zh@OF8Ya)f!2TXnbjsUUz={wkPl{h8I>;iR+CKCvOi|s zK|NY|>H&$R+GO4;h!=Ej3rIB_>utSAHmsehnY_LLt8#W{44xFV>TLbZKv8<}8d57S zNw@(Da}Qax6bsELpbWa+ZyLD$=Dr+3ee?pG<)B(zn(wM^tC#N6<`-%ug=LLoE?3>X z+xY~9{^}hj!3H&vm%?u~fvJ~zv5MVY%zi9$I1m#Dh-vJr8N1EZR}zQK+G1$bg1-Qq zzX+TMMes>!*7av!r6>ws8$_?TZJ2M;EMIu;a(porqT4O0nTn~~LuVnrtuw=Whqgao zyCe3pgjUAn9RX50AYob9<{{ZMNjLC9Pi1jgHF^?D5qGR6m#K2za zZ&?Og;tP}qAM*D&s~pZi?JG>@@45ePkKbKsiMW-~?u@=XjkHL!$f+-4zt``lxQrD5 zFgXgqPGDj(F@)XI9|F%Oe*X&6wi+Z}dGC}xov^6AKTP~ng)6x|zLik*<6-UKU-Uu2 zOj}W&9J{qMP--2g7f<{~iVELFH5yc&j%g`m=zbBT_+KPzWQS~_2*)jqT5J-&bi=LPU|)qH?? zbiZ1u?mxc7^UH#*Jo(7`({F(CEnp{>wicKNtVclcyLI!42WpO zh4lupNRd9K8?tupqCA(R^oa*T6Vm9}2|y359(&3z&JQ|rdjWD`U^ zQAi`XZ945K18ac?$zBH5)-iS6+D$lb65C}%t|Qs(P-0A+|5PWUy^q?SJ?Z30*8{5_ zm<|{|uk=MzV|dwQ?3p2-oUQy`2Tg!~t&*W|%M1w61Qv?V0l?1i9GRkKTbV?v31Du{ z=!J@M4nOI>TGrMb5qX*jLl~Q$*t{~hlu`P#c?pjR@wf6n{rLnS?1ck-P6;kfFBP+r zxVE}4rItex`)zTY9(2$+TT{=!ieZUB$YubRFNX~2QQ2tni}!OTSeBkpvmK^|OPrIz z)|h@37ugB=c3_iR|I!Pm!#e}j4}GHb@x1=86=I`l1}%++9DdUy|BBD*3)|5l9teF< zr`yK_q#}k5*QT0ekqLR4wrNTMkM2=nPdA{jwe)Od0*iz;*|)vOg>NDgyZpkr%JQcT zQZY4K9Q5W8rb*$Obm{Y#8qe7~8TH_oG8iozeTiE_P9dcbHrKF2CfJ&1kScI7cqOVF zARkd8B-9+$Zhg;oa7u)gD47eM<;@E=s`!hnDO@~GLC|8@Ft!*^;LPJdH+Ce z{gs~8198($08`E)+aL~p!qfGo$>6?%Rs38nc{#8B6)e8KcxQ;sbaOWaxG+UB0K^pa z{f>!ci>rOAIpPfY9~rcxk_!fwNuR*|*?*MM`bPiYu_HzFi3hoxZ0|rU-hy2Y){7?^ zj!hf*QEnuyB*L?r8#8fZN#=p=X|?*SDNuI*=n%i!-jFF5&6URwY#Rcn(DYPB?}jG3 zZI}hO<*EmRDvM^edm_uYi>~1^WO;5{sOPi$JZ%wC&42*TQ!q0b#FBhHansKK4i*%a zm8myHDvOMObS_%NsH4m8ftD021l-%>8%urEi)k|2`(pHC&n=Uj<%OW;q2A8QZjzd! zBWYoMjBZh2`UXN(;n{dGOtN>(1_W1WnqSmUyR}403_D-#p*uoTG1O=9EC3k`;BapbWx*Zx8b9Do=~0lBD~< z@m)eF@*zw2>~$Zm`_c(9D`nLQUesNaU&<>>s^xX_0{)r+4~yvzbiD5Cbj?r*d+~_h ziph@;Z4M*kF|mo}h7+=#A$Rb-`Z5ihQ!9#yfHehZJsTy4G#Im^|sRhdTJc1{=D!m3fDDjPJ#1)`}d@>?gaWaVDdSIDFB z7g0yPM{Z{F(rf>}#)xH%07Z@0w?BI};P{+fd5*_Xpme?E;aApfnI79PA%XS5cw+PL z9vMfxGEuSA*XJ95dr9kx{z2)*&Ks?7_Kl@(c(pKlJMph&ug4nf16$pu?nToS(IM{j ze+%lL20Wz5kQZa6NKQlM}{OOt`(uOQHBZme^ zma8#BCyviX3qnXcz4gpTP(FcUTvqhl^(9sOuaJQY8GWD}hO93)!pLkF&FP(A?f|y% zV%n~Fh-UV0jX4g!G)abR`S@Cf3He@Qe1O>-Vs22yao--NPfKykO3HpN77j&}^c!Vo zR^dQf!+&Y(TMWu77s7-=OubhF)%TV4*tYIU6)IhQ6WdX^scIf+SF~02eTNp&W%x@! zVAw@1_=eZ^+K&GgEbcpfqj zF-6Xz+EyX}Opue(aKfuq#JqENvrTIEYOm`wQi(F>$4}@*pbCiq+V>CJ-3k( z`<_+5Kr405584`AmSiG!*FWaCui7bz*55=bhrZiLV_6F_mtNF^0%VP2ou&HotyWX^ zo*c6R5!F9l00L=JtFHB#C-T6AWAlc&#Ci|<{BY4bXKx3c25!i1pO~vxhcH_vYUF{Y zrT<=l^q}k^e5w}UASL}Pue6YYtqG$}RQ(AFdyPx1+Cvjme%Cp5DU`~Wk#(2w2dKgl zvH0y{&m080-XTLWf-iSNp49k&a>xwgfHwK<6Sg2JTY*v4&#~oN?{D}MhRRIyOZ`XM zks>3%PMMLm!CaszyC7VC98Pc~NW`mEM(w9(1P@<2#n z2(oSK-BZV4%@=5O4~n$HOYcFcEA<@Px6PZ|253*0f+4yHhANLj$luNSzn?xlyFMZz z4-;8-;g?R?oxiFyKOjR2Oj29+t^eCghP3^v=Ei&;cO_&X_o#~sY{Q(nb z_P_)l0^4kK2m_fpU&WXm1T-P}ftvccxRu(iX8ZQp>jWzbLkk`Z;W*(utz6Q+#`ZmL z`L%Lv>)jI}gOwas`S7)*MQu*c=;G4aQk-bVvxCwq87Lbvgy#QUv3UO46TwxP=6lL; zD#LW=k5S^!_sH1FN4e;PVc~#O)j{S_1IUL$}AT0SEnF?RFpyaGg z_&kN@_t>$+VtCb}vaf8-^JiJejooZYEUAlCzYWld%fpc8 zOR;})eH@gT90T22qt%rlQ;9h6z+t|W&fjy4-Kv@HFVy^aM|K%)ja4CIjCvw&xzSlg zLYCZ(98wS9I0HxC-}oO%#v@sPZX@hLc~Nh0&YU|tm6F?{vEH^H?WU*Mnm+QIqQ9gK z1Pa=atz|hUAKQo=`O+?OUU^sx99L*k><8KZDYBbF2@Y2NCF8m0S_RwpZ;k$fE1cR$ zvIBQrYC9Qa*|0XqM5?Nxczwam0mgV<6OQkZz%Ll_-eMeg7&6hWY(_~2LFTzWniPi) z36IoJX=!bUczc{B>`jhBokz+dSq4evg7wWtzS$ zN;ez}Wp+b`P>ZHoi@tm{0Y3=}H9fZVep%7!NdP6vmqV=BQ0|u*sR{vmd4UVEbLA;a zcG}|Z)K80;;U6As7M-KF{BGm1GdVi_X560$qe^FZ%ru8*7)R602{)ud* z#PbkJ z@!rKbnRG6vtBldd1J`o3$bhJAy5C}n#J$v9dx=<|u+%0|2glmpWK2lF<7+Llq@51C zzRZGSw>pUR>6?>?T`Ip(+y-b#mdbOs_F3g71)m^wn42gkK#DX(XcE zMB~ns>MI!N=8GFkYp1|c`dH_w>&t}pKI(lJ*IyS99K`yXa*-kH4xjIJ;9(C%kG_}c z4yt|$f=fDb$ir;GeB~{a-mNP6#`39u)~E`OSbS{8*1Yd1(<3l$Bo@*hjPTrZvI$Bu ziND7zeMK%WugT-4Q2v}p`-&)4P9qt7S27xJv2Q306v#5tCbww$#J5{@jzTRIsCS20 zBQZj^t1OWgNYifeYKzFCt3eXJ8$i~@px3);HL|eTzdrv`+N_*gKI2w{nnu*Muz@2$ z`lhVJC5PhLH%p1J{>dkJ%v9qNPLS-2Cts$M?|#?iRmCn~<@i2I~i%*MGYHLJ#-G z$l`qc8c3sB&7WQW)CvKPFyTj1>?O`e%di)b4{M>ul)51Gxd?Hv(fG2K-~cFSq97S` zo)@XWmCiQU)s;TjEg?e5&}X&C)l3Qirj>}E_qP=1rF*~1Yc+o=rBny63c&L;--O2U z$NlR4{YMa$?t%A=;Bqfqv=@F?h(NUE#eNe#DlIW~=F^Lc=(frOW>P`GL1TE@PcapLmvK zeORq7`YNTUD)Y*C!tlDZYBQuYl9r)-UY46xv&v^A^WaY4h$2-{VaMow16?|+K2Jh* zJ2#bpYlMpybEMW-`Q62pC&tJih8!ej8K8w-o({hW1>AVJS<9B;6h(1U(YkQ@fw1 z)IeNk$9G=6&$=zUvF6#T*7P}Vw!wVo)?OzNTNZ~}@~|OoyCK;A=C zx?8dKw-I#GWO-}JORS!1Ow*7N;RlkI%-D!!ZddO!(ooOCIumPec;??sXv?m@av91i zyVvIvp6BFdKP#ThnxCmJhXi^$!*C3r%8;`kzcHSt!@I?S2h)arz2#iLO@QeLh>ebD zhYBQVsm63FCh4pp_zgc`JV95ue;4mW7kMfz4{R5qB>f7sPQ!(Lgi*=(yQHG;C-`A9;LG1+{<4QHU{*5U+X8$WgRMF>~K&Q`os(Uk3c(zZfo=-au z_o+f5zKG(BYcxH`(MrV%DfE5JHCiInXv z<#9e+oJ5|RfpT8iNU{w&mMD^b6uxAm@tuKs8zCn*B;X8?_Hx=bD^Kmo8o3z4SjzWE zG+ZRl53Js1tS%S7SNMgR%Aq(fDY|UcPSYYwYc5VDv3atEFu<(405=lqF2zS)m`qM} zc8yVEf|B-o6IZjn_rI;r59@RPn3w2#b>gSmr@4r`OBv{-!td9|K;zUB6}%q+E8d{- zG$4j|pQl2-gy4#&YDnkCFzy#Ka=jdMgR@qbGEIZlg}%LSeyYvPMcBcXCNd$-LW=I3 zkKj^~_7}7}jZn#HH((WD4|-A*^Irj;I={gg$myhSzadY|Yp z-1(&p?9J8$v1Smo%csKSfge?S;3~$!AZNeYZFlYk3D z@E#OB%uEitHkm;mV7`_G=|ENs&URW-gfhK;c*6& ztvN5QS#>T0lUrmA!Haj(YOPCW^)g9yZ)Cvcc`G^4d5(HcbvXaOcxvgmJx4zF3PNfLqA48K*AknVAe;vKlt954sVKER9 zgr5ds=WKYlH3-XrGM2-+U2)K319_Mtg!g5syt33hy!Nm1)5cu?gz?CXu*}YM{PNNn zqb8mE(?__%jh2o8gHo@iwF^9J*4V>6`gwI~Kek4WYKdn;%1M|V7|tNt=PYXh*u-2{xV8XLtl;gh`o-11p(I;3keB2A>=$aR zxk{ojI%W}VA9arZ3YvXZ+0ZW!U2)d$Ix-JcPH}hJX=9`6on1$Q1?uv4SvAo_r zjs5EFl<|H?)s5Hmug1f( zI?yrGwc2sm^*kMlDN?+#kN$u}?BW()zu^Dw5&YF{nERhCf?|Kb)~NqIJ?&THVKyX~ zzvhg?RUWU62|AV%e*IWymzT}gy65=nHr<4}l3Ty}2NO5(79{mptkOm5ff5Piu { + form.addEventListener("submit", event => { + if (!window.confirm(form.dataset.confirm)) { + event.preventDefault(); + } + }); +}); + +document.querySelectorAll("select[data-auto-submit]").forEach(select => { + select.addEventListener("change", () => select.form.submit()); +}); + +document.querySelectorAll("[data-sidebar-toggle]").forEach(button => { + button.addEventListener("click", () => document.body.classList.toggle("sidebar-open")); +}); + +document.addEventListener("keydown", event => { + if (event.key === "Escape") document.body.classList.remove("sidebar-open"); +}); diff --git a/app/static/js/checkin.js b/app/static/js/checkin.js new file mode 100644 index 0000000..be929bf --- /dev/null +++ b/app/static/js/checkin.js @@ -0,0 +1,333 @@ +let html5QrCode = null; +let currentToken = null; +let availableCameras = []; +let autoResetTimer = null; +const csrfToken = document.querySelector('meta[name="csrf-token"]').content; + +const qrReader = document.getElementById("qr-reader"); +const cameraSelect = document.getElementById("camera-select"); +const startScanBtn = document.getElementById("start-scan"); +const stopScanBtn = document.getElementById("stop-scan"); +const searchInput = document.getElementById("search-input"); +const searchResults = document.getElementById("search-results"); +const guestCard = document.getElementById("guest-card"); +const guestPhoto = document.getElementById("guest-photo"); +const guestName = document.getElementById("guest-name"); +const guestDepartment = document.getElementById("guest-department"); +const guestPosition = document.getElementById("guest-position"); +const guestEmail = document.getElementById("guest-email"); +const guestEvent = document.getElementById("guest-event"); +const eventSelect = document.getElementById("event-select"); +const checkInStatus = document.getElementById("check-in-status"); +const checkInBtn = document.getElementById("check-in-btn"); +const undoCheckInBtn = document.getElementById("undo-check-in-btn"); +const resetBtn = document.getElementById("reset-btn"); + +function signalResult(success) { + if (navigator.vibrate) navigator.vibrate(success ? 180 : [100, 80, 100]); + try { + const AudioContext = window.AudioContext || window.webkitAudioContext; + const context = new AudioContext(); + const oscillator = context.createOscillator(); + const gain = context.createGain(); + oscillator.frequency.value = success ? 880 : 220; + gain.gain.setValueAtTime(0.12, context.currentTime); + gain.gain.exponentialRampToValueAtTime(0.001, context.currentTime + 0.2); + oscillator.connect(gain); gain.connect(context.destination); + oscillator.onended = () => context.close(); + oscillator.start(); oscillator.stop(context.currentTime + 0.2); + } catch (_error) {} +} + +function scheduleReset(delay = 8000) { + clearTimeout(autoResetTimer); + autoResetTimer = setTimeout(resetGuestCard, delay); +} + +function resetGuestCard() { + clearTimeout(autoResetTimer); + guestCard.classList.add("d-none"); + currentToken = null; + guestPhoto.src = ""; + guestName.textContent = ""; + guestDepartment.textContent = ""; + guestPosition.textContent = ""; + guestEmail.textContent = ""; + const placeholder = guestPhoto.nextElementSibling; + if (placeholder && placeholder.classList.contains("guest-photo-placeholder")) placeholder.remove(); + if (guestEvent) guestEvent.textContent = ""; + checkInStatus.classList.add("d-none"); + checkInStatus.className = "alert d-none"; + checkInBtn.disabled = false; + checkInBtn.textContent = "Отметить вход"; + undoCheckInBtn.classList.add("d-none"); +} + +function showGuest(guest) { + clearTimeout(autoResetTimer); + currentToken = guest.token; + guestCard.classList.remove("d-none"); + + if (guest.photo_url) { + guestPhoto.src = guest.photo_url; + guestPhoto.classList.remove("d-none"); + const placeholder = guestPhoto.nextElementSibling; + if (placeholder && placeholder.classList.contains("guest-photo-placeholder")) placeholder.remove(); + } else { + guestPhoto.src = ""; + guestPhoto.classList.add("d-none"); + // Show placeholder + if (!guestPhoto.nextElementSibling || !guestPhoto.nextElementSibling.classList.contains("guest-photo-placeholder")) { + const placeholder = document.createElement("div"); + placeholder.className = "guest-photo-placeholder mx-auto mb-3"; + placeholder.textContent = "Нет фото"; + guestPhoto.parentNode.insertBefore(placeholder, guestPhoto.nextSibling); + } + } + + guestName.textContent = guest.full_name; + guestDepartment.textContent = guest.department; + guestPosition.textContent = guest.position || ""; + guestEmail.textContent = guest.email; + if (guestEvent) { + guestEvent.textContent = guest.event_name ? `Мероприятие: ${guest.event_name}` : ""; + } + + if (guest.checked_in) { + checkInStatus.classList.remove("d-none", "alert-success", "alert-warning"); + checkInStatus.classList.add("alert-warning"); + checkInStatus.textContent = "Гость уже отмечен как вошедший"; + checkInBtn.disabled = true; + undoCheckInBtn.classList.remove("d-none"); + } else { + checkInStatus.classList.add("d-none"); + checkInBtn.disabled = false; + undoCheckInBtn.classList.add("d-none"); + } + + window.scrollTo({ top: guestCard.offsetTop - 20, behavior: "smooth" }); +} + +function loadGuestByToken(token) { + fetch(`/api/guest/${token}`) + .then(response => { + if (!response.ok) throw new Error("Гость не найден"); + return response.json(); + }) + .then(guest => showGuest(guest)) + .catch(error => { + alert(error.message); + }); +} + +// Camera selection +function populateCameras() { + Html5Qrcode.getCameras() + .then(cameras => { + availableCameras = cameras || []; + cameraSelect.innerHTML = ''; + + if (availableCameras.length === 0) { + cameraSelect.innerHTML += ''; + return; + } + + availableCameras.forEach((camera, index) => { + const option = document.createElement("option"); + option.value = camera.id; + option.textContent = camera.label || `Камера ${index + 1}`; + cameraSelect.appendChild(option); + }); + + // Try to select back camera by default + const backCamera = availableCameras.find(c => + c.label.toLowerCase().includes("back") || + c.label.toLowerCase().includes("задняя") || + c.label.toLowerCase().includes("environment") + ); + if (backCamera) { + cameraSelect.value = backCamera.id; + } else if (availableCameras.length > 0) { + cameraSelect.value = availableCameras[availableCameras.length - 1].id; + } + }) + .catch(err => { + console.error("Ошибка получения списка камер:", err); + cameraSelect.innerHTML = ''; + }); +} + +// QR Scanner +startScanBtn.addEventListener("click", () => { + const cameraId = cameraSelect.value; + if (!cameraId) { + alert("Сначала выберите камеру"); + return; + } + + html5QrCode = new Html5Qrcode("qr-reader"); + html5QrCode.start( + cameraId, + { fps: 10, qrbox: { width: 250, height: 250 } }, + (decodedText) => { + html5QrCode.stop(); + stopScanBtn.classList.add("d-none"); + startScanBtn.classList.remove("d-none"); + + // Extract token from URL + try { + const url = new URL(decodedText); + const token = url.searchParams.get("token"); + if (token) { + loadGuestByToken(token); + } + } catch (e) { + // If decodedText is not a URL, treat it as token + loadGuestByToken(decodedText); + } + }, + (errorMessage) => {} + ); + startScanBtn.classList.add("d-none"); + stopScanBtn.classList.remove("d-none"); +}); + +stopScanBtn.addEventListener("click", () => { + if (html5QrCode) { + html5QrCode.stop(); + } + stopScanBtn.classList.add("d-none"); + startScanBtn.classList.remove("d-none"); +}); + +// Search by name +let searchTimeout = null; +searchInput.addEventListener("input", (e) => { + const query = e.target.value.trim(); + searchResults.innerHTML = ""; + + if (query.length < 2) return; + + clearTimeout(searchTimeout); + searchTimeout = setTimeout(() => { + const eventId = eventSelect ? eventSelect.value : ""; + let url = `/api/guests/search?q=${encodeURIComponent(query)}`; + if (eventId) { + url += `&event_id=${encodeURIComponent(eventId)}`; + } + fetch(url) + .then(response => { + if (!response.ok) { + return response.json().then(data => { + throw new Error(data.error || "Ошибка поиска"); + }); + } + return response.json(); + }) + .then(guests => { + searchResults.innerHTML = ""; + guests.forEach(guest => { + const item = document.createElement("button"); + item.className = "list-group-item list-group-item-action"; + const name = document.createElement("strong"); + name.textContent = guest.full_name; + const department = document.createElement("small"); + department.textContent = guest.department || ""; + item.append(name, document.createElement("br"), department); + item.addEventListener("click", () => { + searchResults.innerHTML = ""; + searchInput.value = ""; + showGuest(guest); + }); + searchResults.appendChild(item); + }); + }) + .catch(error => { + const item = document.createElement("div"); + item.className = "list-group-item text-danger"; + item.textContent = error.message; + searchResults.replaceChildren(item); + }); + }, 300); +}); + +// Check-in button +checkInBtn.addEventListener("click", () => { + if (!currentToken) return; + + checkInBtn.disabled = true; + checkInBtn.textContent = "Отметка..."; + + fetch(`/api/check-in/${currentToken}`, { + method: "POST", + headers: { "X-CSRFToken": csrfToken }, + }) + .then(response => response.json().then(data => ({ status: response.status, data }))) + .then(({ status, data }) => { + checkInStatus.classList.remove("d-none", "alert-success", "alert-warning", "alert-danger"); + if (data.success) { + checkInStatus.classList.add("alert-success"); + checkInStatus.textContent = "Вход успешно отмечен!"; + checkInBtn.textContent = "Вход отмечен"; + undoCheckInBtn.classList.remove("d-none"); signalResult(true); scheduleReset(); + } else if (status === 409) { + checkInStatus.classList.add("alert-warning"); + checkInStatus.textContent = "Гость уже отмечен как вошедший"; + checkInBtn.textContent = "Уже отмечен"; + undoCheckInBtn.classList.remove("d-none"); signalResult(false); scheduleReset(); + } else { + checkInStatus.classList.add("alert-danger"); + checkInStatus.textContent = data.message || "Ошибка"; + checkInBtn.disabled = false; + checkInBtn.textContent = "Отметить вход"; + } + }) + .catch(error => { + checkInStatus.classList.remove("d-none", "alert-success", "alert-warning", "alert-danger"); + checkInStatus.classList.add("alert-danger"); + checkInStatus.textContent = "Ошибка сети: " + error.message; + checkInBtn.disabled = false; + checkInBtn.textContent = "Отметить вход"; + }); +}); + +undoCheckInBtn.addEventListener("click", () => { + if (!currentToken) return; + clearTimeout(autoResetTimer); + const reason = window.prompt("Укажите причину отмены отметки входа:"); + if (!reason || reason.trim().length < 3) return; + undoCheckInBtn.disabled = true; + fetch(`/api/check-in/${currentToken}/undo`, { + method: "POST", + headers: {"Content-Type": "application/json", "X-CSRFToken": csrfToken}, + body: JSON.stringify({reason: reason.trim()}), + }) + .then(response => response.json().then(data => ({status: response.status, data}))) + .then(({status, data}) => { + checkInStatus.classList.remove("d-none", "alert-success", "alert-warning", "alert-danger"); + if (status === 200 && data.success) { + checkInStatus.classList.add("alert-warning"); + checkInStatus.textContent = "Отметка входа отменена"; + checkInBtn.disabled = false; checkInBtn.textContent = "Отметить вход"; + undoCheckInBtn.classList.add("d-none"); + } else { + checkInStatus.classList.add("alert-danger"); + checkInStatus.textContent = data.message || "Не удалось отменить отметку"; + } + }) + .catch(error => { checkInStatus.className = "alert alert-danger"; checkInStatus.textContent = "Ошибка сети: " + error.message; }) + .finally(() => { undoCheckInBtn.disabled = false; }); +}); + +resetBtn.addEventListener("click", resetGuestCard); + +// If token is in URL (scanned via external scanner) +document.addEventListener("DOMContentLoaded", () => { + populateCameras(); + + const urlParams = new URLSearchParams(window.location.search); + const token = urlParams.get("token"); + if (token) { + loadGuestByToken(token); + } +}); diff --git a/app/static/js/email_settings.js b/app/static/js/email_settings.js new file mode 100644 index 0000000..6d6d810 --- /dev/null +++ b/app/static/js/email_settings.js @@ -0,0 +1,25 @@ +const radios = document.querySelectorAll('input[name="mail_mode"]'); +const smtpFields = document.getElementById("smtp-fields"); +const ewsFields = document.getElementById("ews-fields"); +const outlookInfo = document.getElementById("outlook-info"); + +function toggleFields() { + const selected = document.querySelector('input[name="mail_mode"]:checked'); + if (!selected) return; + const mode = selected.value; + smtpFields.style.display = mode === "smtp" ? "block" : "none"; + ewsFields.style.display = mode === "ews" ? "block" : "none"; + outlookInfo.style.display = mode === "outlook" ? "block" : "none"; + + document.querySelectorAll("#smtp-fields input, #ews-fields input").forEach(input => { + input.disabled = mode === "outlook" + || (input.closest("#smtp-fields") && mode !== "smtp") + || (input.closest("#ews-fields") && mode !== "ews"); + }); + document.querySelectorAll("#common-fields input").forEach(input => { + input.disabled = mode === "outlook"; + }); +} + +radios.forEach(radio => radio.addEventListener("change", toggleFields)); +toggleFields(); diff --git a/app/static/js/ticket_constructor.js b/app/static/js/ticket_constructor.js new file mode 100644 index 0000000..50ef35e --- /dev/null +++ b/app/static/js/ticket_constructor.js @@ -0,0 +1,6 @@ +const refreshButton = document.getElementById("refresh-preview"); +const previewImage = document.getElementById("ticket-preview"); + +refreshButton.addEventListener("click", () => { + previewImage.src = `${previewImage.dataset.previewUrl}?t=${Date.now()}`; +}); diff --git a/app/static/uploads/logos/.gitkeep b/app/static/uploads/logos/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/app/static/uploads/photos/.gitkeep b/app/static/uploads/photos/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/app/static/uploads/qrcodes/.gitkeep b/app/static/uploads/qrcodes/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/app/static/uploads/reports/.gitkeep b/app/static/uploads/reports/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/app/static/uploads/temp/.gitkeep b/app/static/uploads/temp/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/app/static/uploads/tickets/.gitkeep b/app/static/uploads/tickets/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/app/tasks.py b/app/tasks.py new file mode 100644 index 0000000..931f171 --- /dev/null +++ b/app/tasks.py @@ -0,0 +1,50 @@ +import logging + +from rq import get_current_job + + +logger = logging.getLogger(__name__) + + +def send_event_invitations(event_id, initiated_by_id=None): + from app import create_app + app = create_app() + with app.app_context(): + return send_event_invitations_in_context(event_id, initiated_by_id) + + +def send_event_invitations_in_context(event_id, initiated_by_id=None): + from app import db + from app.models import AuditLog, Event, Guest, User + from app.services.email_service import send_invitation + from app.services.qr_service import generate_qr_for_guest + + event = db.session.get(Event, event_id) + if not event: + raise RuntimeError(f"Мероприятие {event_id} не найдено") + guests = Guest.query.filter_by(event_id=event.id, is_active=True, invitation_sent=False).order_by(Guest.id).all() + total = len(guests) + sent = failed = 0 + job = get_current_job() + for processed, guest in enumerate(guests, start=1): + if not guest.qr_filename: + generate_qr_for_guest(guest) + try: + send_invitation(guest) + sent += 1 + except Exception as exc: + failed += 1 + logger.warning("Invitation delivery failed for guest %s: %s", guest.id, exc) + db.session.commit() + if job: + job.meta.update({"event_id": event.id, "total": total, "processed": processed, "sent": sent, "failed": failed}) + job.save_meta() + initiator = db.session.get(User, initiated_by_id) if initiated_by_id else None + db.session.add(AuditLog( + user_id=initiator.id if initiator else None, + username=initiator.username if initiator else "system", + action="event_send_invitations", target_type="event", target_id=str(event.id), + details=f"Фоновая рассылка завершена: отправлено {sent}, ошибок {failed}", + )) + db.session.commit() + return {"event_id": event.id, "total": total, "sent": sent, "failed": failed} diff --git a/app/templates/admin/dashboard.html b/app/templates/admin/dashboard.html new file mode 100644 index 0000000..fcbefac --- /dev/null +++ b/app/templates/admin/dashboard.html @@ -0,0 +1,64 @@ +{% extends "base.html" %} + +{% block title %}Аналитика — InviteMe{% endblock %} + +{% block content %} + +
Всего гостей
{{ total }}
в списке мероприятия
Уже пришли
{{ checked_in }}
успешных входов
Ожидаются
{{ not_checked_in }}
ещё не отметились
Посещаемость
{{ attendance_percent }}%
от общего списка
+ +

Подтверждения участия

+
+
Подтвердили

{{ rsvp_confirmed }}

+
Отказались

{{ rsvp_declined }}

+
Ожидается ответ

{{ rsvp_pending }}

+
+ +
+
+
+
+
Быстрый поиск
+
+ + +
+
+
+
+
+
+
+
Действия
+ Выгрузить отчёт Excel +
+ + +
+
+
+
+
+ +

Последние входы

+ + + + + + + + + {% for check_in in recent_check_ins %} + + + + + + {% else %} + + + + {% endfor %} + +
ВремяФИООтдел
{{ check_in.checked_in_at | localtime('%d.%m.%Y %H:%M:%S') }}{{ check_in.guest.full_name }}{{ check_in.guest.department }}
Пока никто не отмечен
+{% endblock %} diff --git a/app/templates/admin/email_send.html b/app/templates/admin/email_send.html new file mode 100644 index 0000000..d4ca53f --- /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..906ca15 --- /dev/null +++ b/app/templates/admin/email_settings.html @@ -0,0 +1,143 @@ +{% extends "base.html" %} + +{% block title %}Настройки почты — InviteMe{% endblock %} + +{% block content %} +
+
+
+
+

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

+
+ + +
+ +
+ + +
+
+ + +
+
+ + +
+
+ SMTP/EWS — для серверной установки. Outlook — только если InviteMe запущен на ПК с Outlook. +
+
+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+
+ +
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ +
+
+ + +
URL вида https://mail.example.com/EWS/Exchange.asmx
+
+
+ +
+ Outlook должен быть установлен и запущен на сервере, где работает приложение. Браузер не может запустить Outlook на твоём компьютере. +
+ +
+
Копия отправленных писем в IMAP
+
+ + +
Работает после успешной отправки через SMTP/EWS/Outlook. Для отправки писем по-прежнему используется выбранный выше способ.
+
+ +
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
Название папки на сервере: обычно Sent, Sent Items или Отправленные.
+
+
+ +
+ + Перейти к рассылке +
+
+
+
+ +
+
+
Отправить тестовое письмо
+
+ +
+ + +
+
+
+
+
+
+{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/app/templates/admin/event_form.html b/app/templates/admin/event_form.html new file mode 100644 index 0000000..6b9c885 --- /dev/null +++ b/app/templates/admin/event_form.html @@ -0,0 +1,83 @@ +{% extends "base.html" %} + +{% block title %}{{ 'Редактировать' if event else 'Создать' }} мероприятие — InviteMe{% endblock %} + +{% block content %} +

{{ 'Редактировать' if event else 'Создать' }} мероприятие

+ +
+
+
+ +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
Приглашение и подтверждение участия
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ + +
Только выбранные сотрудники увидят мероприятие и данные его гостей.
+
+ {% if event %} +
+ + +
+ {% endif %} + + {% if event %} + Конструктор билета + {% endif %} + Отмена +
+
+
+{% endblock %} diff --git a/app/templates/admin/events.html b/app/templates/admin/events.html new file mode 100644 index 0000000..6710f28 --- /dev/null +++ b/app/templates/admin/events.html @@ -0,0 +1,82 @@ +{% extends "base.html" %} + +{% block title %}Мероприятия — InviteMe{% endblock %} + +{% block content %} + + +
+ {% for event in events %} +
+
+
+
{% if event.is_active %}Активно{% else %}Архив{% endif %}

{{ event.name }}

{{ event.total_guests }} гостей
+

+ {% if event.event_date %} + {{ event.event_date | localtime('%d.%m.%Y %H:%M') }}
+ {% endif %} + {% if event.location %} + {{ event.location }}
+ {% endif %} + Отметились на входе: {{ event.checked_in }} +

+ {% if event.description %} +

{{ event.description }}

+ {% 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 %} +
+
Пока нет ни одного мероприятия. Создайте первое.
+
+ {% endfor %} +
+{% endblock %} diff --git a/app/templates/admin/guest_form.html b/app/templates/admin/guest_form.html new file mode 100644 index 0000000..ff4702f --- /dev/null +++ b/app/templates/admin/guest_form.html @@ -0,0 +1,68 @@ +{% extends "base.html" %} + +{% block title %}{{ 'Редактировать' if guest else 'Добавить' }} гостя — InviteMe{% endblock %} + +{% block content %} +

{{ 'Редактировать' if guest else 'Добавить' }} гостя

+

Мероприятие: {{ event.name }}

+ +
+
+ {% if guest %} + + {% endif %} +
+ +
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ {% if not guest %} +
+ + +
+
+ + +
+ {% endif %} + + Отмена +
+
+
+{% endblock %} diff --git a/app/templates/admin/guests.html b/app/templates/admin/guests.html new file mode 100644 index 0000000..e5af8be --- /dev/null +++ b/app/templates/admin/guests.html @@ -0,0 +1,110 @@ +{% extends "base.html" %} + +{% block title %}Гости — InviteMe{% endblock %} + +{% block content %} + + +
+
+ +
+
+ +
+
+ +
+ + + + + + + + + + + + + + + + + + {% for guest in guests %} + + + + + + + + + + + + + + {% else %} + + + + {% endfor %} + +
ФИООтделДолжностьEmailТелефонФотоQRПриглашениеRSVPВходДействия
{{ guest.build_full_name() }}{{ guest.department }}{{ guest.position or '-' }}{{ guest.email }}{{ guest.phone or '-' }} + {% if guest.has_photo_file %} + Есть + {% else %} + Нет + {% endif %} + + {% if guest.qr_filename %} + Да + {% else %} + Нет + {% endif %} + + {% if guest.invitation_status == 'sent' %}Отправлено + {% elif guest.invitation_status == 'failed' %}Ошибка + {% elif guest.invitation_status == 'sending' %}Отправляется + {% else %}Не отправлено{% endif %} + {% if guest.invitation_attempts %}Попыток: {{ guest.invitation_attempts }}{% endif %} + + {% if guest.rsvp_status == 'confirmed' %}Будет + {% elif guest.rsvp_status == 'declined' %}Не будет + {% else %}Ожидается{% endif %} + {% if guest.dietary_requirements or guest.rsvp_comment %}Есть комментарий{% endif %} + + {% if guest.is_checked_in %} + Пришёл + {% else %} + Не пришёл + {% endif %} + + Изменить + Билет + QR + Страница +
+ + +
+
+ + +
+
Гости не найдены
+
+ +{% if pagination.pages > 1 %} + +{% endif %} +{% endblock %} 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..c10537f --- /dev/null +++ b/app/templates/admin/panel.html @@ -0,0 +1,21 @@ +{% extends "base.html" %} + +{% block title %}Админ-панель — InviteMe{% endblock %} + +{% block content %} + +
+ {% for card in cards %} + + {% endfor %} +
+{% endblock %} diff --git a/app/templates/admin/ticket_constructor.html b/app/templates/admin/ticket_constructor.html new file mode 100644 index 0000000..38c1208 --- /dev/null +++ b/app/templates/admin/ticket_constructor.html @@ -0,0 +1,94 @@ +{% extends "base.html" %} + +{% block title %}Конструктор билета — {{ event.name }}{% endblock %} + +{% block content %} +
+

Конструктор пригласительного билета

+ ← К мероприятиям +
+ +
+
+
+
+
Настройки дизайна
+
+ +
+ + + {% if event.logo_filename %} +
+ Логотип +
+ + +
+
+ {% endif %} +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+
+
+
+
+
Предпросмотр
+

Пример билета с тестовыми данными

+ Предпросмотр билета +
+
+
+
+{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/app/templates/admin/upload.html b/app/templates/admin/upload.html new file mode 100644 index 0000000..ceba94f --- /dev/null +++ b/app/templates/admin/upload.html @@ -0,0 +1,112 @@ +{% extends "base.html" %} + +{% block title %}Загрузка данных — InviteMe{% endblock %} + +{% block content %} +

Загрузка данных

+

Мероприятие: {{ event.name }}

+ +
+
+
+
+
1. Загрузить Excel со списком гостей
+

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

+ Скачать шаблон Excel +
+ +
+ + +
+
+ + +
+ +
+
+
+
+ +
+
+
+
2. Загрузить фото гостей
+

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

+
+ +
+ + +
+
+ + +
+ +
+
+
+
+
+ +
+
+
+
+
Пример заполнения Excel-таблицы
+

Имена столбцов должны совпадать точно. Значение в столбце «Название фото» должно совпадать с именем файла фото, включая расширение.

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ОтделФамилияИмяОтчествоДолжностьПочтаТелефонНазвание фото
ITИвановИванИвановичРазработчикivanov@company.com+7 (999) 111-22-33ivanov.jpg
МаркетингПетроваАннаСергеевнаМаркетологpetrova@company.com+7 (999) 222-33-44petrova.png
ПродажиСидоровАлексейПетровичМенеджерsidorov@company.com+7 (999) 333-44-55sidorov.jpg
+
+
+
+
+
+{% endblock %} diff --git a/app/templates/admin/user_form.html b/app/templates/admin/user_form.html new file mode 100644 index 0000000..6aaba20 --- /dev/null +++ b/app/templates/admin/user_form.html @@ -0,0 +1,40 @@ +{% extends "base.html" %} + +{% block title %}{{ 'Редактировать' if user else 'Добавить' }} пользователя — InviteMe{% endblock %} + +{% block content %} +

{{ 'Редактировать' if user else 'Добавить' }} пользователя

+ +
+
+
+ +
+ + +
+
+ + +
Минимум {{ config.MIN_PASSWORD_LENGTH }} символов и три группы символов.
+
+
+ + +
+ {% if user %} +
+ + +
+ {% endif %} + + Отмена +
+
+
+{% endblock %} diff --git a/app/templates/admin/users.html b/app/templates/admin/users.html new file mode 100644 index 0000000..844798a --- /dev/null +++ b/app/templates/admin/users.html @@ -0,0 +1,59 @@ +{% extends "base.html" %} + +{% block title %}Пользователи — InviteMe{% endblock %} + +{% block content %} +
+

Пользователи

+ Добавить пользователя +
+ + + + + + + + + + + + + {% for user in users %} + + + + + + + + {% else %} + + + + {% endfor %} + +
ЛогинРольСтатусСозданДействия
{{ user.username }} + {% if user.role == 'admin' %} + Админ + {% elif user.role == 'organizer' %} + Организатор + {% else %} + Сотрудник + {% endif %} + + {% if user.is_active %} + Активен + {% else %} + Заблокирован + {% endif %} + {{ user.created_at | localtime('%d.%m.%Y %H:%M') }} + Изменить + {% if current_user.id != user.id %} +
+ + +
+ {% endif %} +
Пользователи не найдены
+{% endblock %} diff --git a/app/templates/base.html b/app/templates/base.html new file mode 100644 index 0000000..ba99a7f --- /dev/null +++ b/app/templates/base.html @@ -0,0 +1,49 @@ + + + + + {% block title %}InviteMe{% endblock %} + + + + +{% macro nav_link(endpoint, label, icon) %} +{{ label }} +{% endmacro %} +{% if current_user %} +
+ + +
+
+ +
Рабочее пространство{% if current_event %}{{ current_event.name }}{% else %}Все мероприятия{% endif %}
+ {% if current_user.role in ('admin', 'organizer') and available_events %} + + {% endif %} +
+
+{% else %} +
IInviteMeGuest experience
+{% endif %} +{% with messages = get_flashed_messages(with_categories=true) %}{% if messages %}
{% for category, message in messages %}{% endfor %}
{% endif %}{% endwith %} +{% block content %}{% endblock %}
+{% if current_user %}
{% endif %} +{% block scripts %}{% endblock %} + diff --git a/app/templates/checkin/index.html b/app/templates/checkin/index.html new file mode 100644 index 0000000..00a981f --- /dev/null +++ b/app/templates/checkin/index.html @@ -0,0 +1,92 @@ +{% extends "base.html" %} + +{% block title %}Проверка приглашения — InviteMe{% endblock %} + +{% block content %} +
+ + + {% if active_events %} +
+
+
+
+
Выберите мероприятие
+
+ +
+ +
+ +
+
+
+
+
+ {% endif %} + + {% if checkin_event %} +
Работаем с мероприятием: {{ checkin_event.name }}
+ {% endif %} + +
+
+
+
+
Камера

Сканировать QR-код

+
+ + +
+
+ + +
+
+
+ +
+
+
+
Ручной поиск

Найти гостя по ФИО

+ +
+
+
+
+
+ +
+
+ Фото гостя +

+

+

+

+

+ +
+ + + + +
+
+
+{% endblock %} + +{% block scripts %} + + +{% endblock %} diff --git a/app/templates/email/invitation.html b/app/templates/email/invitation.html new file mode 100644 index 0000000..3747b4c --- /dev/null +++ b/app/templates/email/invitation.html @@ -0,0 +1,44 @@ + + + + + Приглашение на {{ event_name }} + + +
+

Приглашение на мероприятие

+

Здравствуйте, {{ guest.full_name }}!

+

Вы приглашены на мероприятие {{ event_name }}.

+ + {% if invitation_message %} +

{{ invitation_message }}

+ {% endif %} + +

+ + Открыть приглашение и подтвердить участие + +

+ + {% if ticket_cid %} +

Ваш персональный пригласительный билет:

+
+ Пригласительный билет +
+ {% else %} +

Ваш персональный QR-код для входа:

+
+ QR-код +
+ {% endif %} + + {% if additional_text %} +

{{ additional_text }}

+ {% endif %} + +

+ Пожалуйста, предъявите этот QR-код или билет при входе. Не передавайте их другим лицам. +

+
+ + diff --git a/app/templates/email/test_email.html b/app/templates/email/test_email.html new file mode 100644 index 0000000..039d647 --- /dev/null +++ b/app/templates/email/test_email.html @@ -0,0 +1,32 @@ + + + + + Тестовое письмо InviteMe + + + + + + +
+ + + + + + + +
+

InviteMe

+

+ Это тестовое письмо. Если вы его видите, SMTP-настройки работают корректно. +

+
+

+ Отправлено из панели управления InviteMe +

+
+
+ + diff --git a/app/templates/invitation.html b/app/templates/invitation.html new file mode 100644 index 0000000..98d743e --- /dev/null +++ b/app/templates/invitation.html @@ -0,0 +1,78 @@ +{% extends "base.html" %} + +{% block title %}Приглашение — {{ event.name }}{% endblock %} + +{% block content %} +
+
+
+
+
Персональное приглашение
+

{{ event.name }}

+

Здравствуйте, {{ guest.build_full_name() }}!

+ {% if event.description %}

{{ event.description }}

{% endif %} + +
+ {% if event.event_date %} +
Дата и время
{{ event.event_date | localtime('%d.%m.%Y %H:%M') }}
+ {% endif %} + {% if event.location %} +
Место
{{ event.location }}
+ {% endif %} +
+ + {% if event.invitation_message %} +
{{ event.invitation_message }}
+ {% endif %} + +
+ Скачать билет + {% if event.event_date %} + Добавить в календарь + {% endif %} +
+ + {% if guest.rsvp_status == 'confirmed' %} +
Вы подтвердили участие.
+ {% elif guest.rsvp_status == 'declined' %} +
Вы сообщили, что не сможете присутствовать.
+ {% endif %} + + {% if rsvp_open %} +
RSVP

Подтвердите участие

+ {% if event.rsvp_deadline %} +

Ответ можно изменить до {{ event.rsvp_deadline | localtime('%d.%m.%Y %H:%M') }}.

+ {% endif %} +
+ +
+ +
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+ +
+ {% elif event.rsvp_enabled %} +
Приём ответов завершён.
+ {% endif %} +
+
+
+
+{% endblock %} diff --git a/app/templates/login.html b/app/templates/login.html new file mode 100644 index 0000000..2e13928 --- /dev/null +++ b/app/templates/login.html @@ -0,0 +1,25 @@ +{% extends "base.html" %} + +{% block title %}Вход — InviteMe{% endblock %} + +{% block content %} +
+
+ +
+
Панель управления

С возвращением

+

Войдите, чтобы продолжить работу

+
+ +
+ + +
+
+ + +
+ +
+
+{% endblock %} diff --git a/app/templates/setup.html b/app/templates/setup.html new file mode 100644 index 0000000..acf5f31 --- /dev/null +++ b/app/templates/setup.html @@ -0,0 +1,32 @@ +{% extends "base.html" %} + +{% block title %}Первоначальная настройка — InviteMe{% endblock %} + +{% block content %} +
+
+ +
+
Первый запуск

Настроим InviteMe

+

+ Создайте первого администратора, чтобы начать работу с InviteMe. +

+
+ +
+ + +
+
+ + +
Минимум {{ config.MIN_PASSWORD_LENGTH }} символов и три группы символов.
+
+
+ + +
+ +
+
+{% endblock %} diff --git a/app/utils/__init__.py b/app/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/utils/paths.py b/app/utils/paths.py new file mode 100644 index 0000000..fe963a4 --- /dev/null +++ b/app/utils/paths.py @@ -0,0 +1,39 @@ +from pathlib import Path +from flask import current_app + + +def _base_upload_folder(): + return Path(current_app.config["UPLOAD_FOLDER"]) + + +def event_photo_folder(event_id): + return Path(current_app.config["PHOTO_FOLDER"]) / f"event_{event_id}" + + +def event_qr_folder(event_id): + return Path(current_app.config["QR_FOLDER"]) / f"event_{event_id}" + + +def event_logo_folder(event_id): + return Path(current_app.config["LOGO_FOLDER"]) / f"event_{event_id}" + + +def event_tickets_folder(event_id): + return _base_upload_folder() / "tickets" / f"event_{event_id}" + + +def ensure_event_folders(event_id): + """Создаёт все папки для конкретного мероприятия.""" + for folder in [event_photo_folder, event_qr_folder, event_logo_folder, event_tickets_folder]: + folder(event_id).mkdir(parents=True, exist_ok=True) + + +def safe_child_path(folder, filename): + """Resolve a database filename without allowing traversal outside its folder.""" + if not filename: + return None + folder = Path(folder).resolve() + candidate = (folder / filename).resolve() + if not candidate.is_relative_to(folder): + return None + return candidate diff --git a/app/utils/queue.py b/app/utils/queue.py new file mode 100644 index 0000000..0d99fdf --- /dev/null +++ b/app/utils/queue.py @@ -0,0 +1,35 @@ +from types import SimpleNamespace + +from flask import current_app +from redis import Redis +from rq import Queue +from rq.job import JobStatus + + +ACTIVE_JOB_STATUSES = {JobStatus.QUEUED, JobStatus.STARTED, JobStatus.DEFERRED, JobStatus.SCHEDULED} + + +def get_task_queue(): + redis_url = current_app.config.get("TASK_QUEUE_REDIS_URL", "") + if not redis_url: + raise RuntimeError("Очередь фоновых задач не настроена") + connection = Redis.from_url(redis_url, socket_connect_timeout=3, socket_timeout=3) + return Queue("inviteme", connection=connection, default_timeout=3600) + + +def enqueue_event_invitations(event_id, initiated_by_id): + if current_app.config.get("TASK_QUEUE_EAGER"): + from app.tasks import send_event_invitations_in_context + result = send_event_invitations_in_context(event_id, initiated_by_id) + return SimpleNamespace(id="eager", result=result), True + queue = get_task_queue() + job_id = f"event-invitations-{event_id}" + existing = queue.fetch_job(job_id) + if existing and existing.get_status(refresh=True) in ACTIVE_JOB_STATUSES: + return existing, False + if existing: + existing.delete() + return queue.enqueue( + "app.tasks.send_event_invitations", event_id, initiated_by_id, + job_id=job_id, job_timeout=3600, result_ttl=3600, failure_ttl=86400, + ), True diff --git a/app/utils/secrets.py b/app/utils/secrets.py new file mode 100644 index 0000000..8cd976f --- /dev/null +++ b/app/utils/secrets.py @@ -0,0 +1,34 @@ +import base64 +import hashlib + +from cryptography.fernet import Fernet, InvalidToken +from flask import current_app + + +PREFIX = "fernet:" + + +def _fernet(): + configured_key = str(current_app.config["CREDENTIAL_ENCRYPTION_KEY"]).encode("utf-8") + derived_key = base64.urlsafe_b64encode(hashlib.sha256(configured_key).digest()) + return Fernet(derived_key) + + +def encrypt_secret(value): + if not value: + return "" + if value.startswith(PREFIX): + return value + token = _fernet().encrypt(value.encode("utf-8")).decode("ascii") + return f"{PREFIX}{token}" + + +def decrypt_secret(value): + if not value: + return "" + if not value.startswith(PREFIX): + return value + try: + return _fernet().decrypt(value[len(PREFIX):].encode("ascii")).decode("utf-8") + except InvalidToken as exc: + raise RuntimeError("Не удалось расшифровать почтовые учётные данные") from exc diff --git a/app/utils/security.py b/app/utils/security.py new file mode 100644 index 0000000..8227f08 --- /dev/null +++ b/app/utils/security.py @@ -0,0 +1,121 @@ +import io +import os +import re +import secrets +import unicodedata +import warnings +import zipfile +from pathlib import Path + +from PIL import Image, ImageOps + + +ALLOWED_IMAGE_EXTENSIONS = {"jpg", "jpeg", "png", "webp"} +_FORMAT_EXTENSIONS = { + "JPEG": {"jpg", "jpeg"}, + "PNG": {"png"}, + "WEBP": {"webp"}, +} + + +def validate_password(password, min_length=12): + """Return a user-facing error or None when a password is acceptable.""" + if len(password) < min_length: + return f"Пароль должен содержать не менее {min_length} символов" + + groups = sum([ + any(c.islower() for c in password), + any(c.isupper() for c in password), + any(c.isdigit() for c in password), + any(not c.isalnum() for c in password), + ]) + if groups < 3: + return "Используйте минимум три группы: строчные, заглавные, цифры и спецсимволы" + return None + + +def safe_upload_filename(filename, max_length=160): + """Keep Unicode names while removing path and control characters.""" + if not filename: + raise ValueError("Пустое имя файла") + + normalized = unicodedata.normalize("NFKC", str(filename)).replace("\\", "/") + name = normalized.rsplit("/", 1)[-1].strip().strip(".") + name = "".join(ch for ch in name if unicodedata.category(ch)[0] != "C") + name = re.sub(r"[^\w.()\- ]", "_", name, flags=re.UNICODE) + name = re.sub(r"\s+", " ", name).strip() + if not name or name in {".", ".."}: + raise ValueError("Небезопасное имя файла") + + suffix = Path(name).suffix + stem_limit = max_length - len(suffix) + name = f"{Path(name).stem[:stem_limit]}{suffix}" + return name + + +def image_extension(filename): + return Path(filename).suffix.lower().lstrip(".") + + +def validate_zip_size(stream, max_files, max_uncompressed_bytes): + """Reject compressed containers that could expand beyond safe limits.""" + position = stream.tell() + try: + with zipfile.ZipFile(stream, "r") as archive: + members = [member for member in archive.infolist() if not member.is_dir()] + if len(members) > max_files: + raise ValueError("В архиве слишком много файлов") + if sum(member.file_size for member in members) > max_uncompressed_bytes: + raise ValueError("Архив слишком большой после распаковки") + except zipfile.BadZipFile as exc: + raise ValueError("Некорректный ZIP-контейнер") from exc + finally: + stream.seek(position) + + +def save_validated_image(stream, destination, max_bytes, max_pixels): + """Validate and re-encode a single raster image, stripping metadata.""" + filename = Path(destination).name + extension = image_extension(filename) + if extension not in ALLOWED_IMAGE_EXTENSIONS: + raise ValueError("Разрешены только JPG, PNG и WebP") + + data = stream.read(max_bytes + 1) + if len(data) > max_bytes: + raise ValueError("Файл изображения слишком большой") + if not data: + raise ValueError("Файл изображения пуст") + + Image.MAX_IMAGE_PIXELS = max_pixels + try: + with warnings.catch_warnings(): + warnings.simplefilter("error", Image.DecompressionBombWarning) + with Image.open(io.BytesIO(data)) as source: + image_format = (source.format or "").upper() + if image_format not in _FORMAT_EXTENSIONS: + raise ValueError("Неподдерживаемый формат изображения") + if extension not in _FORMAT_EXTENSIONS[image_format]: + raise ValueError("Расширение файла не соответствует его содержимому") + if getattr(source, "n_frames", 1) != 1: + raise ValueError("Анимированные изображения не поддерживаются") + source.load() + image = ImageOps.exif_transpose(source) + image = image.convert("RGB") if image_format == "JPEG" else image.copy() + except (Image.DecompressionBombError, Image.DecompressionBombWarning): + raise ValueError("Слишком большое разрешение изображения") + except (OSError, SyntaxError) as exc: + raise ValueError("Файл не является корректным изображением") from exc + + destination = Path(destination) + destination.parent.mkdir(parents=True, exist_ok=True) + temp_path = destination.with_name(f".{destination.name}.{secrets.token_hex(8)}.uploading") + save_options = {"optimize": True} + if image_format == "JPEG": + save_options.update({"quality": 90, "progressive": True}) + try: + image.save(temp_path, format=image_format, **save_options) + os.replace(temp_path, destination) + finally: + if temp_path.exists(): + temp_path.unlink() + return destination diff --git a/app/utils/timezone.py b/app/utils/timezone.py new file mode 100644 index 0000000..9f78f8a --- /dev/null +++ b/app/utils/timezone.py @@ -0,0 +1,27 @@ +from datetime import datetime, timezone +from zoneinfo import ZoneInfo +from flask import current_app + + +def get_timezone(): + tz_name = current_app.config.get("TIMEZONE", "UTC") + return ZoneInfo(tz_name) + + +def utc_to_local(dt): + """Конвертирует наивное UTC-время в локальное наивное время.""" + if dt is None: + return None + # Считаем, что хранимое время — UTC + utc_dt = dt.replace(tzinfo=timezone.utc) if dt.tzinfo is None else dt.astimezone(timezone.utc) + local_dt = utc_dt.astimezone(get_timezone()) + return local_dt.replace(tzinfo=None) + + +def local_to_utc(dt): + """Конвертирует наивное локальное время в UTC-наивное для хранения.""" + if dt is None: + return None + local_dt = dt.replace(tzinfo=get_timezone()) + utc_dt = local_dt.astimezone(timezone.utc) + return utc_dt.replace(tzinfo=None) diff --git a/config.py b/config.py new file mode 100644 index 0000000..aee4e24 --- /dev/null +++ b/config.py @@ -0,0 +1,110 @@ +import os +import secrets +from pathlib import Path + +from dotenv import load_dotenv + +BASE_DIR = Path(__file__).resolve().parent +load_dotenv(BASE_DIR / ".env") + + +def _env_bool(name, default=False): + return os.environ.get(name, str(default)).lower() in {"true", "1", "yes", "on"} + + +class Config: + APP_ENV = os.environ.get("APP_ENV", "development").lower() + IS_PRODUCTION = APP_ENV == "production" + SECRET_KEY = os.environ.get("SECRET_KEY") or (None if IS_PRODUCTION else secrets.token_hex(32)) + CREDENTIAL_ENCRYPTION_KEY = os.environ.get("CREDENTIAL_ENCRYPTION_KEY") or SECRET_KEY + BASE_URL = os.environ.get("BASE_URL") or None + + # Database + SQLALCHEMY_DATABASE_URI = os.environ.get("DATABASE_URL") or f"sqlite:///{BASE_DIR / 'instance' / 'inviteme.db'}" + SQLALCHEMY_TRACK_MODIFICATIONS = False + SQLALCHEMY_ENGINE_OPTIONS = {"pool_pre_ping": True} + + # Uploads + # User data must never live below Flask's public static directory. + UPLOAD_FOLDER = Path(os.environ.get("UPLOAD_FOLDER") or BASE_DIR / "instance" / "uploads") + PHOTO_FOLDER = UPLOAD_FOLDER / "photos" + QR_FOLDER = UPLOAD_FOLDER / "qrcodes" + LOGO_FOLDER = UPLOAD_FOLDER / "logos" + MAX_CONTENT_LENGTH = int(os.environ.get("MAX_CONTENT_LENGTH", 32 * 1024 * 1024)) + MAX_IMAGE_BYTES = int(os.environ.get("MAX_IMAGE_BYTES", 10 * 1024 * 1024)) + MAX_IMAGE_PIXELS = int(os.environ.get("MAX_IMAGE_PIXELS", 25_000_000)) + MAX_ZIP_FILES = int(os.environ.get("MAX_ZIP_FILES", 2_000)) + MAX_ZIP_UNCOMPRESSED_BYTES = int(os.environ.get("MAX_ZIP_UNCOMPRESSED_BYTES", 250 * 1024 * 1024)) + MAX_XLSX_UNCOMPRESSED_BYTES = int(os.environ.get("MAX_XLSX_UNCOMPRESSED_BYTES", 50 * 1024 * 1024)) + MAX_EXCEL_ROWS = int(os.environ.get("MAX_EXCEL_ROWS", 10_000)) + + # Email (SMTP) + MAIL_SERVER = os.environ.get("MAIL_SERVER") or "smtp.yandex.ru" + MAIL_PORT = int(os.environ.get("MAIL_PORT") or 465) + MAIL_USE_SSL = _env_bool("MAIL_USE_SSL", True) + MAIL_USERNAME = os.environ.get("MAIL_USERNAME") or "" + MAIL_PASSWORD = os.environ.get("MAIL_PASSWORD") or "" + MAIL_DEFAULT_SENDER = os.environ.get("MAIL_DEFAULT_SENDER") or MAIL_USERNAME + + # Default event name for migrating existing guests + DEFAULT_EVENT_NAME = os.environ.get("DEFAULT_EVENT_NAME") or "Архивное мероприятие" + + # Local timezone for display (e.g. Europe/Moscow) + TIMEZONE = os.environ.get("TIMEZONE") or "Europe/Moscow" + + # Session security (HTTPS is required for these flags) + SESSION_COOKIE_SECURE = _env_bool("SESSION_COOKIE_SECURE", IS_PRODUCTION) + SESSION_COOKIE_HTTPONLY = True + SESSION_COOKIE_SAMESITE = "Strict" + SESSION_COOKIE_NAME = "inviteme_session" + PERMANENT_SESSION_LIFETIME = int(os.environ.get("PERMANENT_SESSION_LIFETIME", "28800")) # 8 часов + + # CSRF protection + WTF_CSRF_ENABLED = True + WTF_CSRF_TIME_LIMIT = 3600 + + # Rate limiting + RATELIMIT_STORAGE_URI = os.environ.get("RATELIMIT_STORAGE_URI") or "memory://" + RATELIMIT_STRATEGY = "fixed-window" + TASK_QUEUE_REDIS_URL = os.environ.get("TASK_QUEUE_REDIS_URL") or ( + RATELIMIT_STORAGE_URI if RATELIMIT_STORAGE_URI.startswith("redis://") else "" + ) + TASK_QUEUE_EAGER = _env_bool("TASK_QUEUE_EAGER", not IS_PRODUCTION) + + # Deployment hardening + ALLOW_WEB_SETUP = _env_bool("ALLOW_WEB_SETUP", not IS_PRODUCTION) + ENFORCE_HTTPS = _env_bool("ENFORCE_HTTPS", IS_PRODUCTION) + TRUST_PROXY_HEADERS = _env_bool("TRUST_PROXY_HEADERS", False) + PREFERRED_URL_SCHEME = "https" if IS_PRODUCTION else "http" + TRUSTED_HOSTS = [host.strip() for host in os.environ.get("TRUSTED_HOSTS", "").split(",") if host.strip()] or None + MIN_PASSWORD_LENGTH = int(os.environ.get("MIN_PASSWORD_LENGTH", "12")) + SKIP_DB_BOOTSTRAP = _env_bool("SKIP_DB_BOOTSTRAP", False) + + @classmethod + def validate(cls): + if not cls.IS_PRODUCTION: + return + + placeholders = { + "change-me-in-production", + "dev-secret-key-change-in-production", + "your-strong-secret-key-here", + } + if not cls.SECRET_KEY or cls.SECRET_KEY in placeholders or len(str(cls.SECRET_KEY)) < 32: + raise RuntimeError("Production requires a unique SECRET_KEY of at least 32 characters") + if not cls.CREDENTIAL_ENCRYPTION_KEY or len(str(cls.CREDENTIAL_ENCRYPTION_KEY)) < 32: + raise RuntimeError("Production requires CREDENTIAL_ENCRYPTION_KEY of at least 32 characters") + if not cls.BASE_URL or not str(cls.BASE_URL).startswith("https://"): + raise RuntimeError("Production requires an HTTPS BASE_URL") + if not cls.TRUSTED_HOSTS: + raise RuntimeError("Production requires TRUSTED_HOSTS") + if str(cls.SQLALCHEMY_DATABASE_URI).startswith("sqlite:"): + raise RuntimeError("Production requires PostgreSQL via DATABASE_URL") + if cls.RATELIMIT_STORAGE_URI == "memory://": + raise RuntimeError("Production requires shared rate-limit storage, for example Redis") + if not cls.TASK_QUEUE_REDIS_URL.startswith("redis://"): + raise RuntimeError("Production requires Redis task queue storage") + if cls.TASK_QUEUE_EAGER: + raise RuntimeError("Production cannot execute task queue jobs eagerly") + if not cls.SESSION_COOKIE_SECURE or not cls.ENFORCE_HTTPS or cls.ALLOW_WEB_SETUP: + raise RuntimeError("Unsafe production HTTP or setup configuration") diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..c88d7ae --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,123 @@ +services: + inviteme: + build: . + restart: unless-stopped + command: ["sh", "-c", "SKIP_DB_BOOTSTRAP=true flask --app run.py db upgrade && python run_waitress.py"] + environment: &inviteme_environment + APP_ENV: production + SECRET_KEY: ${SECRET_KEY:?SECRET_KEY is required} + CREDENTIAL_ENCRYPTION_KEY: ${CREDENTIAL_ENCRYPTION_KEY:?CREDENTIAL_ENCRYPTION_KEY is required} + BASE_URL: https://${DOMAIN:?DOMAIN is required} + TRUSTED_HOSTS: ${DOMAIN:?DOMAIN is required} + DATABASE_URL: postgresql+psycopg://inviteme:${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required}@database:5432/inviteme + RATELIMIT_STORAGE_URI: redis://redis:6379/0 + TASK_QUEUE_REDIS_URL: redis://redis:6379/1 + TRUST_PROXY_HEADERS: "true" + ENFORCE_HTTPS: "true" + ALLOW_WEB_SETUP: "false" + TIMEZONE: ${TIMEZONE:-Europe/Moscow} + MAIL_SERVER: ${MAIL_SERVER:-smtp.yandex.ru} + MAIL_PORT: ${MAIL_PORT:-465} + MAIL_USE_SSL: ${MAIL_USE_SSL:-true} + MAIL_USERNAME: ${MAIL_USERNAME:-} + MAIL_PASSWORD: ${MAIL_PASSWORD:-} + MAIL_DEFAULT_SENDER: ${MAIL_DEFAULT_SENDER:-InviteMe} + expose: + - "8888" + volumes: + - inviteme_data:/app/instance + depends_on: + database: + condition: service_healthy + redis: + condition: service_healthy + read_only: true + tmpfs: + - /tmp:size=64m,noexec,nosuid + cap_drop: + - ALL + security_opt: + - no-new-privileges:true + networks: + - backend + + worker: + build: . + restart: unless-stopped + command: ["rq", "worker", "--url", "redis://redis:6379/1", "inviteme"] + environment: *inviteme_environment + volumes: + - inviteme_data:/app/instance + depends_on: + inviteme: + condition: service_healthy + read_only: true + tmpfs: + - /tmp:size=64m,noexec,nosuid + cap_drop: + - ALL + security_opt: + - no-new-privileges:true + networks: + - backend + + database: + image: postgres:17-alpine + restart: unless-stopped + environment: + POSTGRES_DB: inviteme + POSTGRES_USER: inviteme + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required} + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U inviteme -d inviteme"] + interval: 10s + timeout: 5s + retries: 10 + networks: + - backend + + redis: + image: redis:8-alpine + restart: unless-stopped + command: ["redis-server", "--appendonly", "yes", "--appendfsync", "everysec"] + volumes: + - redis_data:/data + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 3s + retries: 10 + networks: + - backend + + caddy: + image: caddy:2-alpine + restart: unless-stopped + environment: + DOMAIN: ${DOMAIN:?DOMAIN is required} + ports: + - "80:80" + - "443:443" + - "443:443/udp" + volumes: + - ./Caddyfile:/etc/caddy/Caddyfile:ro + - caddy_data:/data + - caddy_config:/config + depends_on: + inviteme: + condition: service_healthy + networks: + - backend + +volumes: + inviteme_data: + postgres_data: + redis_data: + caddy_data: + caddy_config: + +networks: + backend: + driver: bridge diff --git a/docs/CONTEXT.md b/docs/CONTEXT.md new file mode 100644 index 0000000..2488c43 --- /dev/null +++ b/docs/CONTEXT.md @@ -0,0 +1,195 @@ +# Контекст проекта InviteMe + +## Назначение + +InviteMe — веб-приложение для организации регистрации гостей на корпоративные мероприятия. Является упрощённым аналогом Timepad: загрузка списка участников, генерация персональных QR-кодов, рассылка приглашений, контроль входа на мероприятии и административная аналитика. + +## Бизнес-процесс + +### 1. Подготовка до мероприятия + +1. Администратор создаёт учётные записи организаторов и сотрудников на входе. +2. Организатор создаёт новое мероприятие в админ-панели. +3. Организатор формирует Excel-файл со столбцами: + - `Отдел` + - `ФИО` + - `Должность` + - `e-mail` + - `название фото` +4. Собирает фотографии гостей. Название каждого файла должно совпадать со значением в столбце `название фото`. +5. В админ-панели выбирает созданное мероприятие и загружает Excel. Система создаёт записи гостей в рамках этого мероприятия и генерирует для каждого уникальный QR-код. +6. Загружает фото (по одному или ZIP-архивом) — они сохраняются в закрытой папке мероприятия `instance/uploads/photos/event_/`. +7. При необходимости вручную добавляет, редактирует или блокирует гостей. +8. Запускает массовую рассылку приглашений. Каждый гость получает письмо с QR-кодом. + +### 2. Вход на мероприятие + +1. Сотрудники на входе открывают страницу `/check-in` на телефоне/планшете. +2. Авторизуются под учётной записью `staff`. +3. Проверяют гостей одним из способов: + - Сканирование QR-кода с экрана телефона или распечатки. + - Поиск по ФИО (вводят фамилию, выбирают из выпадающего списка). +4. На экране отображается крупное фото гостя, отдел, должность, email и кнопка «Отметить вход». +5. Сотрудник сверяет фото с гостем и нажимает кнопку. +6. Если гость уже был отмечен, система показывает предупреждение и время входа. + +### 3. Аналитика во время и после мероприятия + +1. Организатор в админ-панели видит: + - Общее количество гостей. + - Количество пришедших. + - Процент посещаемости. + - Список последних входов. +2. Может быстро проверить по ФИО, пришёл ли конкретный человек. +3. Может выгрузить отчёт в Excel с отметкой о посещении и временем входа для каждого участника. + +## Роли пользователей + +| Роль | Возможности | +|---|---| +| Администратор | Полный доступ: управление пользователями, всеми мероприятиями, гостями, рассылка, аналитика, отчёты | +| Организатор | Создание и управление своими мероприятиями, гостями, рассылка, аналитика, отчёты | +| Сотрудник на входе | Страница `/check-in`: доступ только к назначенным мероприятиям, сканирование QR, поиск, отметка входа | + +Пользователи создаются и управляются через веб-интерфейс администратора. При первом запуске открывается страница создания первого администратора. + +## Архитектура + +```text +┌─────────────┐ ┌─────────────┐ ┌─────────────────┐ +│ Браузер │──────│ Flask │──────│ PostgreSQL │ +│ (Admin/ │ │ (Python) │ │ (inviteme.db) │ +│ Organizer/ │ │ │ └─────────────────┘ +│ Staff) │ └──────┬──────┘ +└─────────────┘ │ + ┌────────────────┼────────────────┐ + │ │ │ + ┌─────▼─────┐ ┌─────▼─────┐ ┌─────▼─────┐ + │ QR-сервис │ │ Email │ │ Excel │ + │ (qrcode) │ │ (SMTP) │ │ (openpyxl)│ + └────────────┘ └───────────┘ └───────────┘ +``` + +- **Flask** обрабатывает HTTP-запросы и рендерит HTML-шаблоны. +- **PostgreSQL** хранит пользователей, мероприятия, гостей, RSVP и историю входов. SQLite используется только локально. +- **RQ worker + Redis** выполняют массовую рассылку в фоне и сохраняют прогресс независимо от браузера. +- **Локальная файловая система** хранит фото и QR-коды. +- **SMTP** используется для отправки приглашений. + +## Поток данных + +### Импорт гостей + +```text +Создание мероприятия в админ-панели + ↓ +Excel-файл + ↓ +excel_service.read_guests_from_excel() + ↓ +Создание/обновление записей Guest в базе данных (с привязкой к мероприятию) + ↓ +qr_service.generate_qr_for_guest() для каждого нового гостя + ↓ +Готовность к рассылке +``` + +### Рассылка приглашений + +```text +Гость с invitation_sent = false + ↓ +email_service.send_invitation() + ↓ +HTML-шаблон + QR-код как вложение + ↓ +Отправка через SMTP + ↓ +invitation_sent = true +``` + +### Проверка входа + +```text +QR-код → URL /check-in?token= + ↓ +Загрузка карточки гостя + ↓ +POST /api/check-in/ + ↓ +Проверка: уже отмечен? + ↓ +Создание записи CheckIn или возврат ошибки 409 +``` + +## Формат Excel + +Обязательные столбцы (имена должны совпадать точно): + +| Столбец | Описание | Пример | +|---|---|---| +| `Отдел` | Название отдела | IT | +| `ФИО` | Полное имя | Иванов Иван Иванович | +| `Должность` | Должность гостя | Разработчик | +| `e-mail` | Email для рассылки | ivanov@company.com | +| `название фото` | Имя файла фото | ivanov.jpg | + +> Важно: значение в столбце `название фото` должно точно совпадать с именем загруженного файла, включая расширение. + +## Хранение файлов + +```text +instance/uploads/ +├── photos/ # Фото гостей +├── qrcodes/ # Сгенерированные QR-коды +├── reports/ # Выгруженные отчёты +└── temp/ # Временные файлы при загрузке +``` + +Эти папки создаются автоматически при старте приложения. + +## Ограничения и предположения + +- Production-конфигурация использует PostgreSQL и рассчитана на мероприятия с сотнями или тысячами гостей при одном экземпляре приложения. +- Все фото и QR-коды хранятся локально на диске. Перед переносом на другой сервер их нужно копировать отдельно (они не хранятся в git). +- Массовая рассылка писем выполняется фоновым worker-процессом; результат и ошибка сохраняются для каждого гостя. +- QR-код содержит прямую ссылку на страницу входа. При переносе на другой хост необходимо указать корректный `BASE_URL` в `.env` или при загрузке Excel. + +## Чеклист перед мероприятием + +- [ ] Созданы учётные записи организаторов и сотрудников на входе. +- [ ] Создано мероприятие в админ-панели. +- [ ] Подготовлен Excel со списком гостей. +- [ ] Собраны и загружены фото всех гостей. +- [ ] Загружен Excel в админ-панель в рамках нужного мероприятия. +- [ ] Проверено, что QR-коды сгенерированы. +- [ ] Настроен SMTP и отправлено тестовое письмо. +- [ ] Разосланы приглашения. +- [ ] Протестирована страница `/check-in` на телефоне/планшете. +- [ ] Проверена работа камеры и сканирования QR. +- [ ] Проверен поиск по ФИО. +- [ ] Настроено резервное копирование базы и загруженных файлов. + +## Распространённые проблемы + +### Гость не находится по QR +- Проверьте, что гость не заблокирован (`is_active = true`). +- Проверьте, что QR-код не был перегенерирован, а старый распечатан. + +### Фото не отображается на странице входа +- Проверьте точное совпадение имени файла в Excel и в папке `uploads/photos/`. +- Проверьте, что файл действительно загружен. + +### Письма не отправляются +- Проверьте настройки SMTP в `.env`. +- Для Yandex/Google используйте пароль приложения, а не основной пароль. +- Проверьте логи консоли Flask. + +### QR-код ведёт на localhost +- Укажите `BASE_URL` в `.env`. +- Или введите базовый URL при загрузке Excel в админ-панели. + +## История изменений + +- **2026-08-28** — Первая рабочая версия (MVP). Реализованы импорт Excel, QR-коды, рассылка, check-in, аналитика. +- **2026-08-28** — Добавлены пользователи, роли и поддержка нескольких мероприятий. diff --git a/docs/DEPLOY.md b/docs/DEPLOY.md new file mode 100644 index 0000000..b48efa8 --- /dev/null +++ b/docs/DEPLOY.md @@ -0,0 +1,21 @@ +# Развёртывание InviteMe + +Для боевого сервера используйте инструкцию [PRODUCTION.md](PRODUCTION.md). Она включает PostgreSQL, Redis, автоматический HTTPS, миграции, закрытое хранение файлов и резервное копирование. + +## Локальная разработка + +```bash +python -m venv venv +venv/bin/pip install -r requirements-dev.txt +venv/bin/python run.py +``` + +На Windows замените `venv/bin/` на `venv\Scripts\`. + +Локальный режим: + +- слушает только `127.0.0.1`; +- использует SQLite в `instance/inviteme.db`; +- хранит пользовательские файлы в закрытом каталоге `instance/uploads`; +- разрешает создание первого администратора через `/setup`; +- не предназначен для доступа из локальной сети или интернета. diff --git a/docs/PRODUCTION.md b/docs/PRODUCTION.md new file mode 100644 index 0000000..b618f3b --- /dev/null +++ b/docs/PRODUCTION.md @@ -0,0 +1,90 @@ +# Развёртывание InviteMe в production + +## 1. Сервер и DNS + +Используйте отдельный Linux-сервер с актуальными Docker Engine и Docker Compose. Создайте `A`/`AAAA`-запись домена InviteMe и направьте её на сервер. Разрешите входящие TCP 80/443 и UDP 443. Не публикуйте наружу PostgreSQL, Redis и порт 8888. + +## 2. Конфигурация + +```bash +cp .env.example .env +openssl rand -hex 32 +openssl rand -hex 32 +openssl rand -hex 32 +``` + +Запишите полученные значения в `SECRET_KEY`, `CREDENTIAL_ENCRYPTION_KEY` и `POSTGRES_PASSWORD`. В `DOMAIN` укажите публичное имя сервера без схемы и пути. + +Ограничьте доступ к файлу конфигурации: + +```bash +chmod 600 .env +``` + +`CREDENTIAL_ENCRYPTION_KEY` защищает сохранённые SMTP/EWS/IMAP-пароли. При его потере или изменении расшифровать эти пароли будет невозможно, поэтому сохраните ключ в менеджере паролей. + +## 3. Первый запуск + +```bash +docker compose up -d --build +docker compose ps +docker compose exec inviteme flask --app run.py create-admin +``` + +В `docker compose ps` должны быть запущены приложение, отдельный `worker`, PostgreSQL, Redis и Caddy. Массовая рассылка выполняется worker-процессом и не зависит от открытой страницы браузера. + +Веб-страница первичной настройки в production отключена. Команда создания администратора проверяет сложность пароля и не принимает пароль аргументом командной строки. + +Проверьте состояние приложения: + +```bash +curl -fsS https://events.example.com/healthz +``` + +Замените адрес на значение `DOMAIN`. + +После создания пользователей откройте каждое мероприятие и назначьте сотрудников на входе. Неназначенный сотрудник не видит мероприятие и его гостей. + +## 4. Резервное копирование + +Копируйте и PostgreSQL, и закрытый том приложения. Дамп базы не содержит фотографии гостей, QR-коды и сгенерированные билеты. + +```bash +docker compose exec -T database pg_dump -U inviteme -d inviteme -Fc > inviteme.dump +docker run --rm --volumes-from "$(docker compose ps -q inviteme)" \ + -v "$PWD":/backup alpine \ + tar -czf /backup/inviteme-uploads.tar.gz -C /app/instance . +``` + +Храните резервные копии в зашифрованном виде и регулярно проверяйте восстановление. Никогда не добавляйте `.env` и бэкапы в Git. + +## 5. Обновление + +Сначала создайте свежую резервную копию, затем выполните: + +```bash +git pull --ff-only +docker compose build --pull +docker compose up -d +docker compose ps +``` + +Перед запуском Waitress сервис автоматически выполняет `flask db upgrade`. При ошибке миграции приложение не стартует — восстановите резервную копию или исправьте миграцию до повторного запуска. + +## 6. Переход с существующего SQLite + +Не подключайте старый SQLite-файл к production-стеку. Сначала сохраните полную копию `instance/`. Новая версия при локальном запуске переносит старые файлы из `app/static/uploads` в закрытый каталог, но записи из SQLite в PostgreSQL нужно переносить как отдельную контролируемую операцию и проверять до переключения DNS. + +После переноса данных назначьте сотрудников нужным мероприятиям: новая production-модель не даёт роли `staff` автоматический доступ ко всем событиям. + +## 7. Эксплуатация + +```bash +docker compose logs -f --tail=200 inviteme +docker compose logs -f --tail=200 worker +docker compose restart inviteme +docker compose restart worker +docker compose exec inviteme flask --app run.py routes +``` + +Контролируйте свободное место, ответы HTTP 5xx, состояние worker, неудачные входы, ошибки отправки почты и возраст последней резервной копии PostgreSQL. Ротацию секретов выполняйте только как плановое обслуживание. diff --git a/migrations/README b/migrations/README new file mode 100644 index 0000000..0e04844 --- /dev/null +++ b/migrations/README @@ -0,0 +1 @@ +Single-database configuration for Flask. diff --git a/migrations/alembic.ini b/migrations/alembic.ini new file mode 100644 index 0000000..ec9d45c --- /dev/null +++ b/migrations/alembic.ini @@ -0,0 +1,50 @@ +# A generic, single database configuration. + +[alembic] +# template used to generate migration files +# file_template = %%(rev)s_%%(slug)s + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + + +# Logging configuration +[loggers] +keys = root,sqlalchemy,alembic,flask_migrate + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[logger_flask_migrate] +level = INFO +handlers = +qualname = flask_migrate + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/migrations/env.py b/migrations/env.py new file mode 100644 index 0000000..4c97092 --- /dev/null +++ b/migrations/env.py @@ -0,0 +1,113 @@ +import logging +from logging.config import fileConfig + +from flask import current_app + +from alembic import context + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +# Interpret the config file for Python logging. +# This line sets up loggers basically. +fileConfig(config.config_file_name) +logger = logging.getLogger('alembic.env') + + +def get_engine(): + try: + # this works with Flask-SQLAlchemy<3 and Alchemical + return current_app.extensions['migrate'].db.get_engine() + except (TypeError, AttributeError): + # this works with Flask-SQLAlchemy>=3 + return current_app.extensions['migrate'].db.engine + + +def get_engine_url(): + try: + return get_engine().url.render_as_string(hide_password=False).replace( + '%', '%%') + except AttributeError: + return str(get_engine().url).replace('%', '%%') + + +# add your model's MetaData object here +# for 'autogenerate' support +# from myapp import mymodel +# target_metadata = mymodel.Base.metadata +config.set_main_option('sqlalchemy.url', get_engine_url()) +target_db = current_app.extensions['migrate'].db + +# other values from the config, defined by the needs of env.py, +# can be acquired: +# my_important_option = config.get_main_option("my_important_option") +# ... etc. + + +def get_metadata(): + if hasattr(target_db, 'metadatas'): + return target_db.metadatas[None] + return target_db.metadata + + +def run_migrations_offline(): + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, target_metadata=get_metadata(), literal_binds=True + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online(): + """Run migrations in 'online' mode. + + In this scenario we need to create an Engine + and associate a connection with the context. + + """ + + # this callback is used to prevent an auto-migration from being generated + # when there are no changes to the schema + # reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html + def process_revision_directives(context, revision, directives): + if getattr(config.cmd_opts, 'autogenerate', False): + script = directives[0] + if script.upgrade_ops.is_empty(): + directives[:] = [] + logger.info('No changes in schema detected.') + + conf_args = current_app.extensions['migrate'].configure_args + if conf_args.get("process_revision_directives") is None: + conf_args["process_revision_directives"] = process_revision_directives + + connectable = get_engine() + + with connectable.connect() as connection: + context.configure( + connection=connection, + target_metadata=get_metadata(), + **conf_args + ) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/migrations/script.py.mako b/migrations/script.py.mako new file mode 100644 index 0000000..2c01563 --- /dev/null +++ b/migrations/script.py.mako @@ -0,0 +1,24 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} +branch_labels = ${repr(branch_labels)} +depends_on = ${repr(depends_on)} + + +def upgrade(): + ${upgrades if upgrades else "pass"} + + +def downgrade(): + ${downgrades if downgrades else "pass"} diff --git a/migrations/versions/5e84226bd650_initial_production_schema.py b/migrations/versions/5e84226bd650_initial_production_schema.py new file mode 100644 index 0000000..e13417e --- /dev/null +++ b/migrations/versions/5e84226bd650_initial_production_schema.py @@ -0,0 +1,139 @@ +"""Initial production schema + +Revision ID: 5e84226bd650 +Revises: +Create Date: 2026-08-31 10:22:33.543712 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '5e84226bd650' +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('smtp_settings', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('mail_server', sa.String(length=255), nullable=False), + sa.Column('mail_port', sa.Integer(), nullable=False), + sa.Column('mail_use_ssl', sa.Boolean(), nullable=False), + sa.Column('mail_username', sa.String(length=255), nullable=False), + sa.Column('mail_password', sa.String(length=1024), nullable=False), + sa.Column('mail_default_sender', sa.String(length=255), nullable=True), + sa.Column('use_local_outlook', sa.Boolean(), nullable=False), + sa.Column('use_ews', sa.Boolean(), nullable=False), + sa.Column('ews_url', sa.String(length=500), nullable=True), + sa.Column('imap_server', sa.String(length=255), nullable=True), + sa.Column('imap_port', sa.Integer(), nullable=False), + sa.Column('imap_use_ssl', sa.Boolean(), nullable=False), + sa.Column('imap_username', sa.String(length=255), nullable=True), + sa.Column('imap_password', sa.String(length=1024), nullable=True), + sa.Column('imap_folder', sa.String(length=255), nullable=True), + sa.Column('imap_save_copy', sa.Boolean(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('users', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('username', sa.String(length=100), nullable=False), + sa.Column('password_hash', sa.String(length=255), nullable=False), + sa.Column('role', sa.String(length=20), nullable=False), + sa.Column('is_active', sa.Boolean(), nullable=False), + sa.Column('session_version', sa.Integer(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('username') + ) + op.create_table('audit_logs', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=True), + sa.Column('username', sa.String(length=100), nullable=True), + sa.Column('action', sa.String(length=50), nullable=False), + sa.Column('target_type', sa.String(length=50), nullable=True), + sa.Column('target_id', sa.String(length=50), nullable=True), + sa.Column('details', sa.Text(), nullable=True), + sa.Column('ip_address', sa.String(length=45), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('events', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=255), nullable=False), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('event_date', sa.DateTime(), nullable=True), + sa.Column('location', sa.String(length=255), nullable=True), + sa.Column('is_active', sa.Boolean(), nullable=False), + sa.Column('created_by_id', sa.Integer(), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('ticket_title', sa.String(length=255), nullable=True), + sa.Column('ticket_subtitle', sa.String(length=255), nullable=True), + sa.Column('ticket_body_text', sa.Text(), nullable=True), + sa.Column('ticket_background_color', sa.String(length=7), nullable=True), + sa.Column('ticket_text_color', sa.String(length=7), nullable=True), + sa.Column('ticket_accent_color', sa.String(length=7), nullable=True), + sa.Column('ticket_show_qr', sa.Boolean(), nullable=False), + sa.Column('ticket_show_photo', sa.Boolean(), nullable=False), + sa.Column('ticket_layout', sa.String(length=20), nullable=True), + sa.Column('logo_filename', sa.String(length=255), nullable=True), + sa.ForeignKeyConstraint(['created_by_id'], ['users.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('event_staff', + sa.Column('event_id', sa.Integer(), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['event_id'], ['events.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('event_id', 'user_id') + ) + op.create_table('guests', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('event_id', sa.Integer(), nullable=False), + sa.Column('department', sa.String(length=255), nullable=False), + sa.Column('last_name', sa.String(length=255), nullable=True), + sa.Column('first_name', sa.String(length=255), nullable=True), + sa.Column('middle_name', sa.String(length=255), nullable=True), + sa.Column('full_name', sa.String(length=255), nullable=False), + sa.Column('position', sa.String(length=255), nullable=True), + sa.Column('email', sa.String(length=255), nullable=False), + sa.Column('phone', sa.String(length=50), nullable=True), + sa.Column('photo_filename', sa.String(length=255), nullable=True), + sa.Column('token', sa.String(length=36), nullable=False), + sa.Column('qr_filename', sa.String(length=255), nullable=True), + sa.Column('is_active', sa.Boolean(), nullable=False), + sa.Column('invitation_sent', sa.Boolean(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(['event_id'], ['events.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('token') + ) + op.create_table('check_ins', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('guest_id', sa.Integer(), nullable=False), + sa.Column('event_id', sa.Integer(), nullable=True), + sa.Column('checked_in_at', sa.DateTime(), nullable=False), + sa.Column('device_info', sa.String(length=500), nullable=True), + sa.ForeignKeyConstraint(['event_id'], ['events.id'], ), + sa.ForeignKeyConstraint(['guest_id'], ['guests.id'], ), + sa.PrimaryKeyConstraint('id') + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('check_ins') + op.drop_table('guests') + op.drop_table('event_staff') + op.drop_table('events') + op.drop_table('audit_logs') + op.drop_table('users') + op.drop_table('smtp_settings') + # ### end Alembic commands ### diff --git a/migrations/versions/8c365be4d3a0_add_delivery_reliability_and_rsvp.py b/migrations/versions/8c365be4d3a0_add_delivery_reliability_and_rsvp.py new file mode 100644 index 0000000..cb79841 --- /dev/null +++ b/migrations/versions/8c365be4d3a0_add_delivery_reliability_and_rsvp.py @@ -0,0 +1,102 @@ +"""Add delivery reliability and RSVP + +Revision ID: 8c365be4d3a0 +Revises: 5e84226bd650 +Create Date: 2026-09-01 09:06:31.547215 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '8c365be4d3a0' +down_revision = '5e84226bd650' +branch_labels = None +depends_on = None + + +def upgrade(): + with op.batch_alter_table('audit_logs', schema=None) as batch_op: + batch_op.create_index('ix_audit_logs_action', ['action'], unique=False) + batch_op.create_index('ix_audit_logs_created_at', ['created_at'], unique=False) + batch_op.create_index('ix_audit_logs_username', ['username'], unique=False) + + op.execute(sa.text( + "DELETE FROM check_ins WHERE id NOT IN " + "(SELECT MIN(id) FROM check_ins GROUP BY guest_id)" + )) + with op.batch_alter_table('check_ins', schema=None) as batch_op: + batch_op.add_column(sa.Column('checked_in_by_id', sa.Integer(), nullable=True)) + batch_op.create_index('ix_check_ins_event_time', ['event_id', 'checked_in_at'], unique=False) + batch_op.create_unique_constraint('uq_check_ins_guest_id', ['guest_id']) + batch_op.create_foreign_key('fk_check_ins_checked_in_by_id_users', 'users', ['checked_in_by_id'], ['id']) + + with op.batch_alter_table('events', schema=None) as batch_op: + batch_op.add_column(sa.Column('event_end_date', sa.DateTime(), nullable=True)) + batch_op.add_column(sa.Column('rsvp_enabled', sa.Boolean(), nullable=False, server_default=sa.true())) + batch_op.add_column(sa.Column('rsvp_deadline', sa.DateTime(), nullable=True)) + batch_op.add_column(sa.Column('invitation_subject', sa.String(length=255), nullable=True)) + batch_op.add_column(sa.Column('invitation_message', sa.Text(), nullable=True)) + batch_op.create_index('ix_events_created_by_active', ['created_by_id', 'is_active'], unique=False) + batch_op.create_index('ix_events_event_date', ['event_date'], unique=False) + + with op.batch_alter_table('guests', schema=None) as batch_op: + batch_op.add_column(sa.Column('invitation_status', sa.String(length=20), nullable=False, server_default='pending')) + batch_op.add_column(sa.Column('invitation_attempts', sa.Integer(), nullable=False, server_default='0')) + batch_op.add_column(sa.Column('invitation_last_error', sa.String(length=1000), nullable=True)) + batch_op.add_column(sa.Column('invitation_last_attempt_at', sa.DateTime(), nullable=True)) + batch_op.add_column(sa.Column('invitation_sent_at', sa.DateTime(), nullable=True)) + batch_op.add_column(sa.Column('rsvp_status', sa.String(length=20), nullable=False, server_default='pending')) + batch_op.add_column(sa.Column('rsvp_responded_at', sa.DateTime(), nullable=True)) + batch_op.add_column(sa.Column('rsvp_comment', sa.String(length=1000), nullable=True)) + batch_op.add_column(sa.Column('dietary_requirements', sa.String(length=1000), nullable=True)) + batch_op.create_index('ix_guests_event_active', ['event_id', 'is_active'], unique=False) + batch_op.create_index('ix_guests_event_email', ['event_id', 'email'], unique=False) + batch_op.create_index('ix_guests_event_full_name', ['event_id', 'full_name'], unique=False) + batch_op.create_index('ix_guests_event_rsvp', ['event_id', 'rsvp_status'], unique=False) + + op.execute(sa.text("UPDATE guests SET invitation_status = 'sent' WHERE invitation_sent = true")) + with op.batch_alter_table('events', schema=None) as batch_op: + batch_op.alter_column('rsvp_enabled', server_default=None) + with op.batch_alter_table('guests', schema=None) as batch_op: + batch_op.alter_column('invitation_status', server_default=None) + batch_op.alter_column('invitation_attempts', server_default=None) + batch_op.alter_column('rsvp_status', server_default=None) + + +def downgrade(): + with op.batch_alter_table('guests', schema=None) as batch_op: + batch_op.drop_index('ix_guests_event_rsvp') + batch_op.drop_index('ix_guests_event_full_name') + batch_op.drop_index('ix_guests_event_email') + batch_op.drop_index('ix_guests_event_active') + batch_op.drop_column('dietary_requirements') + batch_op.drop_column('rsvp_comment') + batch_op.drop_column('rsvp_responded_at') + batch_op.drop_column('rsvp_status') + batch_op.drop_column('invitation_sent_at') + batch_op.drop_column('invitation_last_attempt_at') + batch_op.drop_column('invitation_last_error') + batch_op.drop_column('invitation_attempts') + batch_op.drop_column('invitation_status') + + with op.batch_alter_table('events', schema=None) as batch_op: + batch_op.drop_index('ix_events_event_date') + batch_op.drop_index('ix_events_created_by_active') + batch_op.drop_column('invitation_message') + batch_op.drop_column('invitation_subject') + batch_op.drop_column('rsvp_deadline') + batch_op.drop_column('rsvp_enabled') + batch_op.drop_column('event_end_date') + + with op.batch_alter_table('check_ins', schema=None) as batch_op: + batch_op.drop_constraint('fk_check_ins_checked_in_by_id_users', type_='foreignkey') + batch_op.drop_constraint('uq_check_ins_guest_id', type_='unique') + batch_op.drop_index('ix_check_ins_event_time') + batch_op.drop_column('checked_in_by_id') + + with op.batch_alter_table('audit_logs', schema=None) as batch_op: + batch_op.drop_index('ix_audit_logs_username') + batch_op.drop_index('ix_audit_logs_created_at') + batch_op.drop_index('ix_audit_logs_action') diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..8150fa5 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,4 @@ +-r requirements.txt +pytest==9.1.1 +bandit==1.9.4 +pip-audit==2.10.1 diff --git a/run.py b/run.py new file mode 100644 index 0000000..4a2f9ac --- /dev/null +++ b/run.py @@ -0,0 +1,6 @@ +from app import create_app + +app = create_app() + +if __name__ == "__main__": + app.run(host="127.0.0.1", port=5000, debug=False) diff --git a/run_waitress.py b/run_waitress.py new file mode 100644 index 0000000..048c5f8 --- /dev/null +++ b/run_waitress.py @@ -0,0 +1,21 @@ +import os + +from waitress import serve +from app import create_app + +app = create_app() + +if __name__ == "__main__": + # host=0.0.0.0 — слушаем все интерфейсы + # port — из переменной окружения или 8888 по умолчанию + port = int(os.environ.get("PORT", "8888")) + serve( + app, + # This port is reachable only on the private Compose network. + host="0.0.0.0", # nosec B104 + port=port, + threads=int(os.environ.get("WAITRESS_THREADS", "8")), + connection_limit=int(os.environ.get("WAITRESS_CONNECTION_LIMIT", "200")), + channel_timeout=60, + clear_untrusted_proxy_headers=True, + ) diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..8396b48 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,47 @@ +from pathlib import Path + +import pytest + +from app import create_app, db +from config import Config + + +@pytest.fixture() +def app(tmp_path): + class TestConfig(Config): + TESTING = True + IS_PRODUCTION = False + SECRET_KEY = "test-secret-key-that-is-long-enough" + CREDENTIAL_ENCRYPTION_KEY = "test-encryption-key-that-is-long-enough" + SQLALCHEMY_DATABASE_URI = f"sqlite:///{tmp_path / 'test.db'}" + SQLALCHEMY_ENGINE_OPTIONS = {} + WTF_CSRF_ENABLED = False + SESSION_COOKIE_SECURE = False + ENFORCE_HTTPS = False + ALLOW_WEB_SETUP = True + RATELIMIT_ENABLED = False + UPLOAD_FOLDER = tmp_path / "uploads" + PHOTO_FOLDER = UPLOAD_FOLDER / "photos" + QR_FOLDER = UPLOAD_FOLDER / "qrcodes" + LOGO_FOLDER = UPLOAD_FOLDER / "logos" + + application = create_app(TestConfig) + yield application + with application.app_context(): + db.session.remove() + db.drop_all() + + +@pytest.fixture() +def client(app): + return app.test_client() + + +def login_as(client, user, current_event_id=None): + with client.session_transaction() as session: + session["user_id"] = user.id + session["role"] = user.role + session["session_version"] = user.session_version + if current_event_id: + session["current_event_id"] = current_event_id + session["checkin_event_id"] = current_event_id diff --git a/tests/test_security.py b/tests/test_security.py new file mode 100644 index 0000000..c5f19dd --- /dev/null +++ b/tests/test_security.py @@ -0,0 +1,300 @@ +import io + +import pytest +from PIL import Image +from sqlalchemy.exc import IntegrityError + +from app import db +from app.models import AuditLog, CheckIn, Event, Guest, SmtpSettings, User, utcnow +from app.services.email_service import send_invitation +from app.tasks import send_event_invitations_in_context +from app.utils.paths import event_photo_folder +from config import Config + + +def _user(username, role): + user = User(username=username, role=role, is_active=True) + user.set_password("Valid-Password-123") + db.session.add(user) + db.session.flush() + return user + + +def _guest(event, email="guest@example.com"): + guest = Guest( + event_id=event.id, + department="IT", + full_name="Тестовый Гость", + email=email, + photo_filename="guest.png", + ) + db.session.add(guest) + db.session.flush() + return guest + + +def test_organizer_cannot_read_another_event_api(app, client): + with app.app_context(): + owner = _user("owner", "organizer") + other = _user("other", "organizer") + own_event = Event(name="Own", created_by_id=owner.id) + other_event = Event(name="Other", created_by_id=other.id) + db.session.add_all([own_event, other_event]) + db.session.flush() + _guest(own_event, "own@example.com") + _guest(other_event, "other@example.com") + db.session.commit() + owner_id = owner.id + owner_version = owner.session_version + other_event_id = other_event.id + + with client.session_transaction() as session: + session["user_id"] = owner_id + session["role"] = "organizer" + session["session_version"] = owner_version + + response = client.get(f"/api/guests/search?q=Тест&event_id={other_event_id}") + assert response.status_code == 403 + + +def test_private_photo_requires_event_access(app, client): + with app.app_context(): + owner = _user("owner", "organizer") + other = _user("other", "organizer") + event = Event(name="Private", created_by_id=owner.id) + db.session.add(event) + db.session.flush() + guest = _guest(event) + folder = event_photo_folder(event.id) + folder.mkdir(parents=True, exist_ok=True) + Image.new("RGB", (10, 10), "white").save(folder / guest.photo_filename) + db.session.commit() + other_id = other.id + other_version = other.session_version + guest_id = guest.id + + with client.session_transaction() as session: + session["user_id"] = other_id + session["role"] = "organizer" + session["session_version"] = other_version + + assert client.get(f"/media/guests/{guest_id}/photo").status_code == 403 + + +def test_staff_access_requires_event_assignment(app, client): + with app.app_context(): + admin = _user("admin", "admin") + staff = _user("staff-assigned", "staff") + event = Event(name="Assigned", created_by_id=admin.id) + db.session.add(event) + db.session.flush() + guest = _guest(event) + db.session.commit() + staff_id = staff.id + staff_version = staff.session_version + event_id = event.id + guest_id = guest.id + + with client.session_transaction() as session: + session["user_id"] = staff_id + session["role"] = "staff" + session["session_version"] = staff_version + + assert client.get(f"/api/guests/search?q=Тест&event_id={event_id}").status_code == 403 + + with app.app_context(): + event = db.session.get(Event, event_id) + staff = db.session.get(User, staff_id) + event.staff_members.append(staff) + db.session.commit() + + response = client.get(f"/api/guests/search?q=Тест&event_id={event_id}") + assert response.status_code == 200 + assert response.get_json()[0]["id"] == guest_id + + +def test_invalid_image_upload_is_not_saved(app, client): + with app.app_context(): + organizer = _user("organizer", "organizer") + event = Event(name="Event", created_by_id=organizer.id) + db.session.add(event) + db.session.commit() + organizer_id = organizer.id + organizer_version = organizer.session_version + event_id = event.id + + with client.session_transaction() as session: + session["user_id"] = organizer_id + session["role"] = "organizer" + session["session_version"] = organizer_version + session["current_event_id"] = event_id + + response = client.post( + "/admin/upload", + data={"photo_files": (io.BytesIO(b""), "photo.jpg")}, + content_type="multipart/form-data", + ) + assert response.status_code == 302 + with app.app_context(): + assert not (event_photo_folder(event_id) / "photo.jpg").exists() + + +def test_mail_password_is_encrypted_at_rest(app): + with app.app_context(): + settings = SmtpSettings.get_settings() + settings.set_mail_password("smtp-password") + db.session.commit() + raw_value = settings.mail_password + assert raw_value.startswith("fernet:") + assert "smtp-password" not in raw_value + assert settings.get_mail_password() == "smtp-password" + + +def test_security_headers_are_present(client): + response = client.get("/login") + assert response.headers["X-Content-Type-Options"] == "nosniff" + assert response.headers["X-Frame-Options"] == "DENY" + assert "frame-ancestors 'none'" in response.headers["Content-Security-Policy"] + + +def test_web_setup_can_be_disabled(app, client): + app.config["ALLOW_WEB_SETUP"] = False + assert client.get("/setup").status_code == 404 + + +def test_unsafe_production_config_is_rejected(): + class UnsafeProductionConfig(Config): + IS_PRODUCTION = True + SECRET_KEY = "change-me-in-production" + CREDENTIAL_ENCRYPTION_KEY = "x" * 32 + BASE_URL = "https://events.example.com" + TRUSTED_HOSTS = ["events.example.com"] + SQLALCHEMY_DATABASE_URI = "postgresql+psycopg://example" + RATELIMIT_STORAGE_URI = "redis://example" + SESSION_COOKIE_SECURE = True + ENFORCE_HTTPS = True + ALLOW_WEB_SETUP = False + + with pytest.raises(RuntimeError, match="SECRET_KEY"): + UnsafeProductionConfig.validate() + + +def test_checkin_api_requires_csrf_when_enabled(app, client): + with app.app_context(): + staff = _user("staff", "staff") + event = Event(name="Event", created_by_id=staff.id) + db.session.add(event) + db.session.flush() + guest = _guest(event) + db.session.commit() + staff_id = staff.id + staff_version = staff.session_version + token = guest.token + + with client.session_transaction() as session: + session["user_id"] = staff_id + session["role"] = "staff" + session["session_version"] = staff_version + app.config["WTF_CSRF_ENABLED"] = True + + assert client.post(f"/api/check-in/{token}").status_code == 400 + + +def test_admin_guest_search_and_pagination(app, client): + with app.app_context(): + organizer = _user("search-owner", "organizer") + event = Event(name="Search", created_by_id=organizer.id) + db.session.add(event); db.session.flush() + for number in range(55): + guest = _guest(event, f"guest-{number:02d}@example.com") + guest.full_name = f"Гость {number:02d}" + db.session.commit() + user_id, version, event_id = organizer.id, organizer.session_version, event.id + with client.session_transaction() as session: + session.update(user_id=user_id, role="organizer", session_version=version, current_event_id=event_id) + assert client.get("/admin/guests").data.count(b"guest-") == 50 + assert client.get("/admin/guests?page=2").data.count(b"guest-") == 5 + assert b"guest-54@example.com" in client.get("/admin/guests?q=guest-54@example.com").data + assert client.get("/admin/upload/template").data.startswith(b"PK") + + +def test_duplicate_checkin_is_rejected_and_can_be_undone(app, client): + with app.app_context(): + admin = _user("gate-admin", "admin") + staff = _user("gate-staff", "staff") + event = Event(name="Gate", created_by_id=admin.id) + event.staff_members.append(staff) + db.session.add(event); db.session.flush() + guest = _guest(event, "gate@example.com") + db.session.commit() + staff_id, version, token, guest_id = staff.id, staff.session_version, guest.token, guest.id + with client.session_transaction() as session: + session.update(user_id=staff_id, role="staff", session_version=version) + assert client.post(f"/api/check-in/{token}").status_code == 200 + assert client.post(f"/api/check-in/{token}").status_code == 409 + assert client.post(f"/api/check-in/{token}/undo", json={"reason": "Ошибка оператора"}).status_code == 200 + with app.app_context(): + assert CheckIn.query.filter_by(guest_id=guest_id).count() == 0 + assert AuditLog.query.filter_by(action="check_in_undo", target_id=str(guest_id)).count() == 1 + + +def test_database_rejects_duplicate_checkins(app): + with app.app_context(): + owner = _user("constraint-owner", "admin") + event = Event(name="Constraint", created_by_id=owner.id) + db.session.add(event); db.session.flush() + guest = _guest(event, "constraint@example.com") + db.session.flush() + db.session.add_all([CheckIn(guest_id=guest.id, event_id=event.id), CheckIn(guest_id=guest.id, event_id=event.id)]) + with pytest.raises(IntegrityError): + db.session.commit() + db.session.rollback() + + +def test_public_invitation_rsvp_calendar_and_ticket(app, client, monkeypatch, tmp_path): + with app.app_context(): + owner = _user("rsvp-owner", "organizer") + event = Event( + name="RSVP Event", created_by_id=owner.id, event_date=utcnow(), + event_end_date=utcnow(), rsvp_enabled=True, + ) + db.session.add(event); db.session.flush() + guest = _guest(event, "rsvp@example.com") + db.session.commit() + token, guest_id = guest.token, guest.id + + assert client.get(f"/invite/{token}").status_code == 200 + response = client.post(f"/invite/{token}", data={ + "rsvp_status": "confirmed", + "dietary_requirements": "Без орехов", + "rsvp_comment": "Буду вовремя", + }) + assert response.status_code == 302 + calendar = client.get(f"/invite/{token}/calendar.ics") + assert calendar.status_code == 200 + assert b"BEGIN:VCALENDAR" in calendar.data + + ticket_path = tmp_path / "ticket.png" + Image.new("RGB", (10, 10), "white").save(ticket_path) + monkeypatch.setattr("app.routes.generate_ticket_image", lambda *_args, **_kwargs: ticket_path) + assert client.get(f"/invite/{token}/ticket").status_code == 200 + with app.app_context(): + updated = db.session.get(Guest, guest_id) + assert updated.rsvp_status == "confirmed" + assert updated.dietary_requirements == "Без орехов" + + +def test_invitation_delivery_and_worker_status(app, monkeypatch): + with app.app_context(): + owner = _user("worker-owner", "organizer") + event = Event(name="Worker", created_by_id=owner.id) + db.session.add(event); db.session.flush() + guest = _guest(event, "worker@example.com") + db.session.commit() + monkeypatch.setattr("app.services.email_service.generate_ticket_image", lambda *_args, **_kwargs: None) + monkeypatch.setattr("app.services.email_service._send_email", lambda **_kwargs: None) + result = send_event_invitations_in_context(event.id, owner.id) + db.session.refresh(guest) + assert result["sent"] == 1 + assert guest.invitation_status == "sent" + assert guest.invitation_attempts == 1