Initial commit: InviteMe production redesign
This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
.git
|
||||
.gitignore
|
||||
venv/
|
||||
.env
|
||||
.env.*
|
||||
instance/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.pyd
|
||||
.Python
|
||||
*.db
|
||||
*.sqlite3
|
||||
app/static/uploads/*
|
||||
!app/static/uploads/.gitkeep
|
||||
@@ -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
|
||||
+30
@@ -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
|
||||
@@ -0,0 +1,7 @@
|
||||
{$DOMAIN} {
|
||||
encode zstd gzip
|
||||
request_body {
|
||||
max_size 32MB
|
||||
}
|
||||
reverse_proxy inviteme:8888
|
||||
}
|
||||
+31
@@ -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"]
|
||||
@@ -0,0 +1,71 @@
|
||||
# InviteMe
|
||||
|
||||
Production-система регистрации гостей, рассылки приглашений, выпуска QR-билетов и контроля входа на мероприятия.
|
||||
|
||||
## Возможности
|
||||
|
||||
- Мероприятия и ролевая модель: администратор, организатор и сотрудник на входе.
|
||||
- Импорт гостей из `.xlsx`, закрытое хранение фотографий и генерация QR-кодов.
|
||||
- Фоновая рассылка через SMTP, EWS или локальный Outlook с индивидуальными статусами доставки.
|
||||
- Персональная страница приглашения: RSVP, комментарий, особенности питания, билет и календарь `.ics`.
|
||||
- Сканирование на входе, отчёты о посещаемости и журнал аудита.
|
||||
- PostgreSQL, общие лимиты запросов через Redis, автоматический HTTPS и миграции базы.
|
||||
|
||||
## Запуск в production
|
||||
|
||||
Нужны Linux-сервер с Docker Compose, домен, направленный на сервер, и открытые входящие порты 80/443.
|
||||
|
||||
```bash
|
||||
git clone https://git.mycloud24.ru/insight/InviteMe.git
|
||||
cd InviteMe
|
||||
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 инструкция по эксплуатации
|
||||
```
|
||||
|
||||
## Лицензия
|
||||
|
||||
Открытая лицензия не предоставляется. Исходный код предназначен только для разрешённого правообладателем использования.
|
||||
+330
@@ -0,0 +1,330 @@
|
||||
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"):
|
||||
_run_migrations()
|
||||
if not app.config.get("IS_PRODUCTION"):
|
||||
_migrate_existing_data(app)
|
||||
_migrate_legacy_public_uploads(app)
|
||||
_load_smtp_settings(app)
|
||||
|
||||
return app
|
||||
|
||||
|
||||
def _run_migrations():
|
||||
"""Применяет миграции Alembic к текущей базе данных."""
|
||||
from alembic.config import Config as AlembicConfig
|
||||
from alembic import command
|
||||
|
||||
migrations_dir = Path(__file__).resolve().parent.parent / "migrations"
|
||||
alembic_ini = migrations_dir / "alembic.ini"
|
||||
alembic_cfg = AlembicConfig(str(alembic_ini))
|
||||
alembic_cfg.set_main_option("script_location", str(migrations_dir))
|
||||
command.upgrade(alembic_cfg, "head")
|
||||
|
||||
|
||||
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}")
|
||||
+337
@@ -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"<User {self.username} ({self.role})>"
|
||||
|
||||
|
||||
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"<Event {self.name}>"
|
||||
|
||||
|
||||
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"<Guest {self.full_name} ({self.email})>"
|
||||
|
||||
|
||||
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"<CheckIn guest_id={self.guest_id} at={self.checked_in_at}>"
|
||||
|
||||
|
||||
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"<AuditLog {self.action} by {self.username} at {self.created_at}>"
|
||||
|
||||
|
||||
class SmtpSettings(db.Model):
|
||||
"""Глобальные настройки SMTP для рассылки приглашений."""
|
||||
__tablename__ = "smtp_settings"
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
mail_server = db.Column(db.String(255), nullable=False, default="smtp.yandex.ru")
|
||||
mail_port = db.Column(db.Integer, nullable=False, default=465)
|
||||
mail_use_ssl = db.Column(db.Boolean, nullable=False, default=True)
|
||||
mail_username = db.Column(db.String(255), nullable=False, default="")
|
||||
mail_password = db.Column(db.String(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"<SmtpSettings {self.mail_server}:{self.mail_port} {self.mail_username}>"
|
||||
+1714
File diff suppressed because it is too large
Load Diff
@@ -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() после основных изменений.
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,14 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg"><defs>
|
||||
<symbol id="grid" viewBox="0 0 24 24"><path d="M4 4h6v6H4zM14 4h6v6h-6zM4 14h6v6H4zM14 14h6v6h-6z"/></symbol>
|
||||
<symbol id="calendar" viewBox="0 0 24 24"><path d="M6 2v3m12-3v3M3 9h18M5 4h14a2 2 0 0 1 2 2v14H3V6a2 2 0 0 1 2-2Z"/></symbol>
|
||||
<symbol id="chart" viewBox="0 0 24 24"><path d="M4 20V10m6 10V4m6 16v-7m5 7H2"/></symbol>
|
||||
<symbol id="users" viewBox="0 0 24 24"><path d="M16 20v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2m7-10a4 4 0 1 0 0-8 4 4 0 0 0 0 8Zm13 10v-2a4 4 0 0 0-3-3.87M16 2.13a4 4 0 0 1 0 7.75"/></symbol>
|
||||
<symbol id="upload" viewBox="0 0 24 24"><path d="M12 16V3m-5 5 5-5 5 5M4 15v5h16v-5"/></symbol>
|
||||
<symbol id="mail" viewBox="0 0 24 24"><path d="M3 5h18v14H3zM3 6l9 7 9-7"/></symbol>
|
||||
<symbol id="scan" viewBox="0 0 24 24"><path d="M3 8V3h5m8 0h5v5M3 16v5h5m8 0h5v-5M8 8h8v8H8z"/></symbol>
|
||||
<symbol id="shield" viewBox="0 0 24 24"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10Z"/></symbol>
|
||||
<symbol id="log" viewBox="0 0 24 24"><path d="M5 3h14v18H5zM8 8h8M8 12h8M8 16h5"/></symbol>
|
||||
<symbol id="settings" viewBox="0 0 24 24"><path d="M12 15.5a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7Zm0-13v3m0 13v3m9-9h-3m-12 0H3m15.36-6.36-2.12 2.12M7.76 16.24l-2.12 2.12m12.72 0-2.12-2.12M7.76 7.76 5.64 5.64"/></symbol>
|
||||
<symbol id="logout" viewBox="0 0 24 24"><path d="M10 4H4v16h6m5-4 4-4-4-4m4 4H9"/></symbol>
|
||||
<symbol id="menu" viewBox="0 0 24 24"><path d="M4 7h16M4 12h16M4 17h16"/></symbol>
|
||||
</defs></svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
@@ -0,0 +1,19 @@
|
||||
document.querySelectorAll("form[data-confirm]").forEach(form => {
|
||||
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");
|
||||
});
|
||||
@@ -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 = '<option value="">-- Выберите камеру --</option>';
|
||||
|
||||
if (availableCameras.length === 0) {
|
||||
cameraSelect.innerHTML += '<option value="" disabled>Камеры не найдены</option>';
|
||||
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 = '<option value="">Ошибка доступа к камерам</option>';
|
||||
});
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
});
|
||||
@@ -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();
|
||||
@@ -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()}`;
|
||||
});
|
||||
@@ -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}
|
||||
@@ -0,0 +1,64 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Аналитика — InviteMe{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<header class="page-header"><div><div class="eyebrow">Live-аналитика</div><h1 class="page-title">{{ event.name }}</h1><p class="page-subtitle">Сводка по приглашениям, ответам и фактическим входам.</p></div><a class="btn btn-primary" href="{{ url_for('main.check_in') }}">Открыть контроль входа</a></header>
|
||||
<div class="stats-grid"><div class="stat-card"><div class="stat-label">Всего гостей</div><div class="stat-value">{{ total }}</div><div class="stat-meta">в списке мероприятия</div></div><div class="stat-card"><div class="stat-label">Уже пришли</div><div class="stat-value text-success">{{ checked_in }}</div><div class="stat-meta">успешных входов</div></div><div class="stat-card"><div class="stat-label">Ожидаются</div><div class="stat-value">{{ not_checked_in }}</div><div class="stat-meta">ещё не отметились</div></div><div class="stat-card"><div class="stat-label">Посещаемость</div><div class="stat-value">{{ attendance_percent }}%</div><div class="stat-meta">от общего списка</div></div></div>
|
||||
|
||||
<h2 class="h5 mb-3">Подтверждения участия</h2>
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-4"><div class="card"><div class="card-body"><h5 class="card-title">Подтвердили</h5><p class="display-6 text-success">{{ rsvp_confirmed }}</p></div></div></div>
|
||||
<div class="col-md-4"><div class="card"><div class="card-body"><h5 class="card-title">Отказались</h5><p class="display-6 text-muted">{{ rsvp_declined }}</p></div></div></div>
|
||||
<div class="col-md-4"><div class="card"><div class="card-body"><h5 class="card-title">Ожидается ответ</h5><p class="display-6 text-warning">{{ rsvp_pending }}</p></div></div></div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Быстрый поиск</h5>
|
||||
<form action="{{ url_for('main.admin_guests') }}" method="GET" class="d-flex">
|
||||
<input type="text" name="q" class="form-control me-2" placeholder="Фамилия или ФИО" required>
|
||||
<button type="submit" class="btn btn-primary">Найти</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Действия</h5>
|
||||
<a href="{{ url_for('main.admin_report') }}" class="btn btn-success">Выгрузить отчёт Excel</a>
|
||||
<form action="{{ url_for('main.admin_send_invitations') }}" method="POST" class="d-inline">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="btn btn-outline-primary">Разослать приглашения</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 class="h5 mt-5 mb-3">Последние входы</h2><div class="table-card table-responsive"><table class="table table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Время</th>
|
||||
<th>ФИО</th>
|
||||
<th>Отдел</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for check_in in recent_check_ins %}
|
||||
<tr>
|
||||
<td>{{ check_in.checked_in_at | localtime('%d.%m.%Y %H:%M:%S') }}</td>
|
||||
<td>{{ check_in.guest.full_name }}</td>
|
||||
<td>{{ check_in.guest.department }}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="3" class="text-muted">Пока никто не отмечен</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table></div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,46 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Рассылка приглашений — InviteMe{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-lg-8">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h4 class="card-title mb-0">Рассылка приглашений</h4>
|
||||
<a href="{{ url_for('main.admin_email_settings') }}" class="btn btn-outline-secondary btn-sm">Настройки почты</a>
|
||||
</div>
|
||||
|
||||
{% if event_counts %}
|
||||
<form method="POST" action="{{ url_for('main.admin_email_send') }}" data-confirm="Начать рассылку приглашений?">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
|
||||
<div class="mb-4">
|
||||
<label for="event_id" class="form-label">Мероприятие</label>
|
||||
<select class="form-select" id="event_id" name="event_id" required>
|
||||
<option value="">Выберите мероприятие</option>
|
||||
{% for item in event_counts %}
|
||||
<option value="{{ item.event.id }}">
|
||||
{{ item.event.name }} — неотправлено: {{ item.unsent }}{% if item.failed %}, ошибок: {{ item.failed }}{% endif %}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-info">
|
||||
Перед рассылкой убедитесь, что в <a href="{{ url_for('main.admin_email_settings') }}">настройках почты</a> указаны корректные SMTP-данные.
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary">
|
||||
Разослать приглашения
|
||||
</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<div class="alert alert-warning">У вас пока нет мероприятий для рассылки.</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,143 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Настройки почты — InviteMe{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-lg-8">
|
||||
<div class="card mb-4">
|
||||
<div class="card-body">
|
||||
<h4 class="card-title mb-4">Настройки почты для рассылки приглашений</h4>
|
||||
<form method="POST" action="{{ url_for('main.admin_email_settings') }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="form-label d-block">Способ отправки</label>
|
||||
<div class="form-check form-check-inline">
|
||||
<input class="form-check-input" type="radio" name="mail_mode" id="mode_smtp" value="smtp" {% if settings.mail_mode == 'smtp' %}checked{% endif %}>
|
||||
<label class="form-check-label" for="mode_smtp">SMTP</label>
|
||||
</div>
|
||||
<div class="form-check form-check-inline">
|
||||
<input class="form-check-input" type="radio" name="mail_mode" id="mode_ews" value="ews" {% if settings.mail_mode == 'ews' %}checked{% endif %}>
|
||||
<label class="form-check-label" for="mode_ews">EWS (Exchange)</label>
|
||||
</div>
|
||||
<div class="form-check form-check-inline">
|
||||
<input class="form-check-input" type="radio" name="mail_mode" id="mode_outlook" value="outlook" {% if settings.mail_mode == 'outlook' %}checked{% endif %}>
|
||||
<label class="form-check-label" for="mode_outlook">Локальный Outlook</label>
|
||||
</div>
|
||||
<div class="form-text text-muted">
|
||||
SMTP/EWS — для серверной установки. Outlook — только если InviteMe запущен на ПК с Outlook.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="common-fields">
|
||||
<div class="mb-3">
|
||||
<label for="mail_username" class="form-label">Логин / Email</label>
|
||||
<input type="text" class="form-control" id="mail_username" name="mail_username" value="{{ settings.mail_username }}">
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="mail_password" class="form-label">Пароль</label>
|
||||
<input type="password" class="form-control" id="mail_password" name="mail_password" placeholder="Оставьте пустым, чтобы не менять">
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label for="mail_default_sender" class="form-label">Имя отправителя (необязательно)</label>
|
||||
<input type="text" class="form-control" id="mail_default_sender" name="mail_default_sender" value="{{ settings.mail_default_sender or '' }}">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="smtp-fields" {% if settings.mail_mode != 'smtp' %}style="display:none;"{% endif %}>
|
||||
<div class="row">
|
||||
<div class="col-md-8 mb-3">
|
||||
<label for="mail_server" class="form-label">SMTP-сервер</label>
|
||||
<input type="text" class="form-control" id="mail_server" name="mail_server" value="{{ settings.mail_server }}">
|
||||
</div>
|
||||
<div class="col-md-4 mb-3">
|
||||
<label for="mail_port" class="form-label">Порт</label>
|
||||
<input type="number" class="form-control" id="mail_port" name="mail_port" value="{{ settings.mail_port }}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-4 form-check">
|
||||
<input type="checkbox" class="form-check-input" id="mail_use_ssl" name="mail_use_ssl" value="1" {% if settings.mail_use_ssl %}checked{% endif %}>
|
||||
<label class="form-check-label" for="mail_use_ssl">Использовать SSL</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="ews-fields" {% if settings.mail_mode != 'ews' %}style="display:none;"{% endif %}>
|
||||
<div class="mb-4">
|
||||
<label for="ews_url" class="form-label">EWS URL</label>
|
||||
<input type="text" class="form-control" id="ews_url" name="ews_url" value="{{ settings.ews_url or '' }}" placeholder="https://mail.company.com/EWS/Exchange.asmx">
|
||||
<div class="form-text text-muted">URL вида <code>https://mail.example.com/EWS/Exchange.asmx</code></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="outlook-info" {% if settings.mail_mode != 'outlook' %}style="display:none;"{% endif %} class="alert alert-info">
|
||||
Outlook должен быть установлен и запущен на сервере, где работает приложение. Браузер не может запустить Outlook на твоём компьютере.
|
||||
</div>
|
||||
|
||||
<hr class="my-4">
|
||||
<h5 class="mb-3">Копия отправленных писем в IMAP</h5>
|
||||
<div class="mb-3 form-check">
|
||||
<input type="checkbox" class="form-check-input" id="imap_save_copy" name="imap_save_copy" value="1" {% if settings.imap_save_copy %}checked{% endif %}>
|
||||
<label class="form-check-label" for="imap_save_copy">Сохранять копию отправленного письма в папку IMAP</label>
|
||||
<div class="form-text text-muted">Работает после успешной отправки через SMTP/EWS/Outlook. Для отправки писем по-прежнему используется выбранный выше способ.</div>
|
||||
</div>
|
||||
|
||||
<div id="imap-fields">
|
||||
<div class="row">
|
||||
<div class="col-md-8 mb-3">
|
||||
<label for="imap_server" class="form-label">IMAP-сервер</label>
|
||||
<input type="text" class="form-control" id="imap_server" name="imap_server" value="{{ settings.imap_server or '' }}" placeholder="imap.example.com">
|
||||
</div>
|
||||
<div class="col-md-4 mb-3">
|
||||
<label for="imap_port" class="form-label">Порт</label>
|
||||
<input type="number" class="form-control" id="imap_port" name="imap_port" value="{{ settings.imap_port or 993 }}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3 form-check">
|
||||
<input type="checkbox" class="form-check-input" id="imap_use_ssl" name="imap_use_ssl" value="1" {% if settings.imap_use_ssl %}checked{% endif %}>
|
||||
<label class="form-check-label" for="imap_use_ssl">Использовать SSL/TLS</label>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="imap_username" class="form-label">IMAP логин (если пустой — используется логин отправки)</label>
|
||||
<input type="text" class="form-control" id="imap_username" name="imap_username" value="{{ settings.imap_username or '' }}">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="imap_password" class="form-label">IMAP пароль (если пустой — используется пароль отправки)</label>
|
||||
<input type="password" class="form-control" id="imap_password" name="imap_password" placeholder="Оставьте пустым, чтобы не менять">
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label for="imap_folder" class="form-label">Папка для сохранения копий</label>
|
||||
<input type="text" class="form-control" id="imap_folder" name="imap_folder" value="{{ settings.imap_folder or 'Sent' }}" placeholder="Sent">
|
||||
<div class="form-text text-muted">Название папки на сервере: обычно <code>Sent</code>, <code>Sent Items</code> или <code>Отправленные</code>.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex gap-2">
|
||||
<button type="submit" class="btn btn-primary">Сохранить настройки</button>
|
||||
<a href="{{ url_for('main.admin_email_send') }}" class="btn btn-outline-primary">Перейти к рассылке</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title mb-3">Отправить тестовое письмо</h5>
|
||||
<form method="POST" action="{{ url_for('main.admin_email_test') }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="input-group">
|
||||
<input type="email" class="form-control" name="test_address" placeholder="address@example.com" required>
|
||||
<button class="btn btn-success" type="submit">Отправить тест</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script src="{{ url_for('static', filename='js/email_settings.js') }}"></script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,83 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ 'Редактировать' if event else 'Создать' }} мероприятие — InviteMe{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1 class="mb-4">{{ 'Редактировать' if event else 'Создать' }} мероприятие</h1>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<form method="POST">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="mb-3">
|
||||
<label for="name" class="form-label">Название *</label>
|
||||
<input type="text" class="form-control" id="name" name="name" value="{{ event.name if event else '' }}" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="description" class="form-label">Описание</label>
|
||||
<textarea class="form-control" id="description" name="description" rows="3">{{ event.description if event else '' }}</textarea>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="location" class="form-label">Место проведения</label>
|
||||
<input type="text" class="form-control" id="location" name="location" value="{{ event.location if event else '' }}">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="event_date" class="form-label">Начало</label>
|
||||
<input type="datetime-local" class="form-control" id="event_date" name="event_date"
|
||||
value="{{ event.event_date | localtime('%Y-%m-%dT%H:%M') if event and event.event_date else '' }}">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="event_end_date" class="form-label">Окончание</label>
|
||||
<input type="datetime-local" class="form-control" id="event_end_date" name="event_end_date"
|
||||
value="{{ event.event_end_date | localtime('%Y-%m-%dT%H:%M') if event and event.event_end_date else '' }}">
|
||||
</div>
|
||||
<div class="card mb-3">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Приглашение и подтверждение участия</h5>
|
||||
<div class="form-check mb-3">
|
||||
<input type="checkbox" class="form-check-input" id="rsvp_enabled" name="rsvp_enabled"
|
||||
{% if not event or event.rsvp_enabled %}checked{% endif %}>
|
||||
<label class="form-check-label" for="rsvp_enabled">Разрешить гостям подтверждать участие</label>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="rsvp_deadline" class="form-label">Подтверждение до</label>
|
||||
<input type="datetime-local" class="form-control" id="rsvp_deadline" name="rsvp_deadline"
|
||||
value="{{ event.rsvp_deadline | localtime('%Y-%m-%dT%H:%M') if event and event.rsvp_deadline else '' }}">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="invitation_subject" class="form-label">Тема письма</label>
|
||||
<input type="text" class="form-control" id="invitation_subject" name="invitation_subject" maxlength="255"
|
||||
value="{{ event.invitation_subject if event and event.invitation_subject else '' }}"
|
||||
placeholder="Приглашение на мероприятие: название">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="invitation_message" class="form-label">Текст для гостя</label>
|
||||
<textarea class="form-control" id="invitation_message" name="invitation_message" rows="4" maxlength="5000"
|
||||
placeholder="Организационная информация, дресс-код, контакты">{{ event.invitation_message if event and event.invitation_message else '' }}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="staff_ids" class="form-label">Сотрудники на входе</label>
|
||||
<select class="form-select" id="staff_ids" name="staff_ids" multiple size="5">
|
||||
{% for staff in staff_users %}
|
||||
<option value="{{ staff.id }}" {% if event and staff in event.staff_members %}selected{% endif %}>{{ staff.username }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<div class="form-text">Только выбранные сотрудники увидят мероприятие и данные его гостей.</div>
|
||||
</div>
|
||||
{% if event %}
|
||||
<div class="mb-3 form-check">
|
||||
<input type="checkbox" class="form-check-input" id="is_active" name="is_active" {% if event.is_active %}checked{% endif %}>
|
||||
<label class="form-check-label" for="is_active">Активно</label>
|
||||
</div>
|
||||
{% endif %}
|
||||
<button type="submit" class="btn btn-primary">Сохранить</button>
|
||||
{% if event %}
|
||||
<a href="{{ url_for('main.admin_event_ticket', event_id=event.id) }}" class="btn btn-outline-info">Конструктор билета</a>
|
||||
{% endif %}
|
||||
<a href="{{ url_for('main.admin_events') }}" class="btn btn-secondary">Отмена</a>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,82 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Мероприятия — InviteMe{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<header class="page-header"><div><div class="eyebrow">Планирование</div><h1 class="page-title">Мероприятия</h1><p class="page-subtitle">Настройте даты, площадки, билеты и правила RSVP.</p></div><a href="{{ url_for('main.admin_event_add') }}" class="btn btn-primary">+ Создать мероприятие</a></header>
|
||||
|
||||
<div class="row">
|
||||
{% for event in events %}
|
||||
<div class="col-md-4 mb-4">
|
||||
<div class="card h-100 {% if not event.is_active %}border-secondary{% endif %}">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between gap-2 mb-3"><div><div class="eyebrow">{% if event.is_active %}Активно{% else %}Архив{% endif %}</div><h2 class="h5 card-title mb-0">{{ event.name }}</h2></div><span class="badge {% if event.is_active %}bg-success{% else %}bg-secondary{% endif %} align-self-start">{{ event.total_guests }} гостей</span></div>
|
||||
<p class="card-text text-muted">
|
||||
{% if event.event_date %}
|
||||
{{ event.event_date | localtime('%d.%m.%Y %H:%M') }}<br>
|
||||
{% endif %}
|
||||
{% if event.location %}
|
||||
{{ event.location }}<br>
|
||||
{% endif %}
|
||||
Отметились на входе: {{ event.checked_in }}
|
||||
</p>
|
||||
{% if event.description %}
|
||||
<p class="card-text">{{ event.description }}</p>
|
||||
{% endif %}
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
<form action="{{ url_for('main.admin_event_switch', event_id=event.id) }}" method="POST" class="d-inline">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="btn btn-success btn-sm">Выбрать</button>
|
||||
</form>
|
||||
<a href="{{ url_for('main.admin_event_edit', event_id=event.id) }}" class="btn btn-outline-primary btn-sm">Изменить</a>
|
||||
<a href="{{ url_for('main.admin_event_ticket', event_id=event.id) }}" class="btn btn-outline-info btn-sm">Билет</a>
|
||||
{% if current_user and current_user.is_admin %}
|
||||
<button type="button" class="btn btn-outline-danger btn-sm" data-bs-toggle="modal" data-bs-target="#deleteEventModal{{ event.id }}">Удалить</button>
|
||||
{% endif %}
|
||||
{% if not event.is_active %}
|
||||
<span class="badge bg-secondary">Неактивно</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer text-muted">
|
||||
Создано: {{ event.created_at | localdate('%d.%m.%Y') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if current_user and current_user.is_admin %}
|
||||
<!-- Modal for hard delete -->
|
||||
<div class="modal fade" id="deleteEventModal{{ event.id }}" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<form action="{{ url_for('main.admin_event_delete_hard', event_id=event.id) }}" method="POST">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Удаление мероприятия</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>Вы собираетесь полностью удалить мероприятие <strong>{{ event.name }}</strong>.</p>
|
||||
<p class="text-danger">Все гости, QR-коды, билеты и логотипы этого мероприятия будут удалены безвозвратно.</p>
|
||||
<div class="mb-3">
|
||||
<label for="deleteReason{{ event.id }}" class="form-label">Причина удаления <span class="text-danger">*</span></label>
|
||||
<textarea class="form-control" id="deleteReason{{ event.id }}" name="reason" rows="3" required></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Отмена</button>
|
||||
<button type="submit" class="btn btn-danger">Удалить</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% else %}
|
||||
<div class="col-12">
|
||||
<div class="alert alert-info">Пока нет ни одного мероприятия. <a href="{{ url_for('main.admin_event_add') }}">Создайте первое</a>.</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,68 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ 'Редактировать' if guest else 'Добавить' }} гостя — InviteMe{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1 class="mb-4">{{ 'Редактировать' if guest else 'Добавить' }} гостя</h1>
|
||||
<p class="text-muted">Мероприятие: {{ event.name }}</p>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
{% if guest %}
|
||||
<div class="mb-3">
|
||||
<a href="{{ url_for('main.admin_guest_ticket', guest_id=guest.id) }}" target="_blank" class="btn btn-outline-info">Посмотреть билет</a>
|
||||
<a href="{{ url_for('main.admin_guest_qr', guest_id=guest.id) }}" target="_blank" class="btn btn-outline-success">QR-код</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
<form method="POST">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<label for="last_name" class="form-label">Фамилия</label>
|
||||
<input type="text" class="form-control" id="last_name" name="last_name" value="{{ guest.last_name if guest else '' }}" required>
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label for="first_name" class="form-label">Имя</label>
|
||||
<input type="text" class="form-control" id="first_name" name="first_name" value="{{ guest.first_name if guest else '' }}" required>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="middle_name" class="form-label">Отчество</label>
|
||||
<input type="text" class="form-control" id="middle_name" name="middle_name" value="{{ guest.middle_name if guest else '' }}">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="department" class="form-label">Отдел</label>
|
||||
<input type="text" class="form-control" id="department" name="department" value="{{ guest.department if guest else '' }}" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="position" class="form-label">Должность</label>
|
||||
<input type="text" class="form-control" id="position" name="position" value="{{ guest.position if guest else '' }}">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="email" class="form-label">Email</label>
|
||||
<input type="email" class="form-control" id="email" name="email" value="{{ guest.email if guest else '' }}" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="phone" class="form-label">Телефон</label>
|
||||
<input type="text" class="form-control" id="phone" name="phone" value="{{ guest.phone if guest else '' }}">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="photo_filename" class="form-label">Название фото</label>
|
||||
<input type="text" class="form-control" id="photo_filename" name="photo_filename" value="{{ guest.photo_filename if guest else '' }}">
|
||||
</div>
|
||||
{% if not guest %}
|
||||
<div class="mb-3">
|
||||
<label for="base_url" class="form-label">Базовый URL для QR-кода (опционально)</label>
|
||||
<input type="url" class="form-control" id="base_url" name="base_url" placeholder="https://events.company.com">
|
||||
</div>
|
||||
<div class="mb-3 form-check">
|
||||
<input type="checkbox" class="form-check-input" id="send_invitation" name="send_invitation" checked>
|
||||
<label class="form-check-label" for="send_invitation">Отправить приглашение сразу</label>
|
||||
</div>
|
||||
{% endif %}
|
||||
<button type="submit" class="btn btn-primary">Сохранить</button>
|
||||
<a href="{{ url_for('main.admin_guests') }}" class="btn btn-secondary">Отмена</a>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,110 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Гости — InviteMe{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<header class="page-header"><div><div class="eyebrow">Аудитория</div><h1 class="page-title">Гости</h1><p class="page-subtitle">{{ event.name }} · приглашения, ответы и статусы входа.</p></div><a href="{{ url_for('main.admin_guest_add') }}" class="btn btn-primary">+ Добавить гостя</a></header>
|
||||
|
||||
<form method="GET" class="row g-3 mb-4">
|
||||
<div class="col-auto">
|
||||
<input type="text" name="q" class="form-control" placeholder="Поиск по ФИО, отделу, email..." value="{{ search }}">
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<button type="submit" class="btn btn-outline-primary">Найти</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="table-card table-responsive">
|
||||
<table class="table table-striped table-hover align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ФИО</th>
|
||||
<th>Отдел</th>
|
||||
<th>Должность</th>
|
||||
<th>Email</th>
|
||||
<th>Телефон</th>
|
||||
<th>Фото</th>
|
||||
<th>QR</th>
|
||||
<th>Приглашение</th>
|
||||
<th>RSVP</th>
|
||||
<th>Вход</th>
|
||||
<th>Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for guest in guests %}
|
||||
<tr class="{{ 'table-secondary' if not guest.is_active }}">
|
||||
<td>{{ guest.build_full_name() }}</td>
|
||||
<td>{{ guest.department }}</td>
|
||||
<td>{{ guest.position or '-' }}</td>
|
||||
<td>{{ guest.email }}</td>
|
||||
<td>{{ guest.phone or '-' }}</td>
|
||||
<td>
|
||||
{% if guest.has_photo_file %}
|
||||
<a href="{{ guest.photo_url }}" target="_blank" class="badge bg-success text-decoration-none">Есть</a>
|
||||
{% else %}
|
||||
<span class="badge bg-warning text-dark" title="{% if guest.photo_filename %}Имя файла указано, но файл не загружен{% else %}Не указано{% endif %}">Нет</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if guest.qr_filename %}
|
||||
<a href="{{ url_for('main.admin_guest_qr', guest_id=guest.id) }}" target="_blank" class="badge bg-success text-decoration-none">Да</a>
|
||||
{% else %}
|
||||
<a href="{{ url_for('main.admin_guest_qr', guest_id=guest.id) }}" target="_blank" class="badge bg-secondary text-decoration-none">Нет</a>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if guest.invitation_status == 'sent' %}<span class="badge bg-success">Отправлено</span>
|
||||
{% elif guest.invitation_status == 'failed' %}<span class="badge bg-danger" title="{{ guest.invitation_last_error or '' }}">Ошибка</span>
|
||||
{% elif guest.invitation_status == 'sending' %}<span class="badge bg-info text-dark">Отправляется</span>
|
||||
{% else %}<span class="badge bg-secondary">Не отправлено</span>{% endif %}
|
||||
{% if guest.invitation_attempts %}<small class="d-block text-muted">Попыток: {{ guest.invitation_attempts }}</small>{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if guest.rsvp_status == 'confirmed' %}<span class="badge bg-success">Будет</span>
|
||||
{% elif guest.rsvp_status == 'declined' %}<span class="badge bg-secondary">Не будет</span>
|
||||
{% else %}<span class="badge bg-warning text-dark">Ожидается</span>{% endif %}
|
||||
{% if guest.dietary_requirements or guest.rsvp_comment %}<small class="d-block text-info" title="{{ guest.dietary_requirements or guest.rsvp_comment }}">Есть комментарий</small>{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if guest.is_checked_in %}
|
||||
<span class="badge bg-success">Пришёл</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary">Не пришёл</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<a href="{{ url_for('main.admin_guest_edit', guest_id=guest.id) }}" class="btn btn-sm btn-outline-primary">Изменить</a>
|
||||
<a href="{{ url_for('main.admin_guest_ticket', guest_id=guest.id) }}" target="_blank" class="btn btn-sm btn-outline-info">Билет</a>
|
||||
<a href="{{ url_for('main.admin_guest_qr', guest_id=guest.id) }}" target="_blank" class="btn btn-sm btn-outline-success">QR</a>
|
||||
<a href="{{ url_for('main.public_invitation', token=guest.token) }}" target="_blank" class="btn btn-sm btn-outline-light">Страница</a>
|
||||
<form action="{{ url_for('main.admin_guest_resend', guest_id=guest.id) }}" method="POST" class="d-inline" data-confirm="Отправить приглашение повторно?">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="btn btn-sm btn-outline-warning">Пригласить</button>
|
||||
</form>
|
||||
<form action="{{ url_for('main.admin_guest_delete', guest_id=guest.id) }}" method="POST" class="d-inline" data-confirm="Заблокировать гостя?">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="btn btn-sm btn-outline-danger">Блок</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="11" class="text-center text-muted">Гости не найдены</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{% if pagination.pages > 1 %}
|
||||
<nav aria-label="Страницы списка гостей"><ul class="pagination justify-content-center">
|
||||
{% if pagination.has_prev %}<li class="page-item"><a class="page-link" href="{{ url_for('main.admin_guests', page=pagination.prev_num, q=search) }}">Назад</a></li>{% endif %}
|
||||
{% for page_num in pagination.iter_pages(left_edge=1, right_edge=1, left_current=2, right_current=2) %}
|
||||
{% if page_num %}<li class="page-item {% if page_num == pagination.page %}active{% endif %}"><a class="page-link" href="{{ url_for('main.admin_guests', page=page_num, q=search) }}">{{ page_num }}</a></li>
|
||||
{% else %}<li class="page-item disabled"><span class="page-link">…</span></li>{% endif %}
|
||||
{% endfor %}
|
||||
{% if pagination.has_next %}<li class="page-item"><a class="page-link" href="{{ url_for('main.admin_guests', page=pagination.next_num, q=search) }}">Вперёд</a></li>{% endif %}
|
||||
</ul></nav>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,88 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Аудит-лог — InviteMe{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h1>Аудит-лог</h1>
|
||||
</div>
|
||||
|
||||
<form method="GET" class="row g-3 mb-4">
|
||||
<div class="col-md-3">
|
||||
<select name="action" class="form-select">
|
||||
<option value="">Все действия</option>
|
||||
{% for action in actions %}
|
||||
<option value="{{ action }}" {% if action == action_filter %}selected{% endif %}>{{ action }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<input type="text" name="user" class="form-control" placeholder="Пользователь" value="{{ user_filter }}">
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<button type="submit" class="btn btn-primary">Фильтр</button>
|
||||
<a href="{{ url_for('main.admin_logs') }}" class="btn btn-secondary">Сброс</a>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm table-striped table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Время</th>
|
||||
<th>Пользователь</th>
|
||||
<th>IP</th>
|
||||
<th>Действие</th>
|
||||
<th>Объект</th>
|
||||
<th>ID объекта</th>
|
||||
<th>Детали</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for log in logs.items %}
|
||||
<tr>
|
||||
<td>{{ log.created_at | localtime('%d.%m.%Y %H:%M:%S') }}</td>
|
||||
<td>{{ log.username or '-' }}</td>
|
||||
<td>{{ log.ip_address or '-' }}</td>
|
||||
<td><span class="badge bg-secondary">{{ log.action }}</span></td>
|
||||
<td>{{ log.target_type or '-' }}</td>
|
||||
<td>{{ log.target_id or '-' }}</td>
|
||||
<td>{{ log.details or '-' }}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="7" class="text-center text-muted">Записи не найдены</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{% if logs.pages > 1 %}
|
||||
<nav aria-label="Page navigation">
|
||||
<ul class="pagination justify-content-center">
|
||||
{% if logs.has_prev %}
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="{{ url_for('main.admin_logs', page=logs.prev_num, action=action_filter, user=user_filter) }}">Назад</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% for page_num in logs.iter_pages(left_edge=1, right_edge=1, left_current=2, right_current=2) %}
|
||||
{% if page_num %}
|
||||
{% if page_num == logs.page %}
|
||||
<li class="page-item active"><span class="page-link">{{ page_num }}</span></li>
|
||||
{% else %}
|
||||
<li class="page-item"><a class="page-link" href="{{ url_for('main.admin_logs', page=page_num, action=action_filter, user=user_filter) }}">{{ page_num }}</a></li>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<li class="page-item disabled"><span class="page-link">...</span></li>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% if logs.has_next %}
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="{{ url_for('main.admin_logs', page=logs.next_num, action=action_filter, user=user_filter) }}">Вперёд</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</nav>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,21 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Админ-панель — InviteMe{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<header class="page-header"><div><div class="eyebrow">Центр управления</div><h1 class="page-title">Добро пожаловать, {{ current_user.username }}</h1><p class="page-subtitle">Управляйте гостями, приглашениями и входом из одного рабочего пространства.</p></div>{% if current_user.role in ('admin', 'organizer') %}<a class="btn btn-primary" href="{{ url_for('main.admin_event_add') }}">+ Создать мероприятие</a>{% endif %}</header>
|
||||
<div class="module-grid">
|
||||
{% for card in cards %}
|
||||
<article class="module-card {% if card.disabled %}opacity-50{% endif %}">
|
||||
<div class="module-icon">{{ card.icon }}</div>
|
||||
<h2>{{ card.title }}</h2>
|
||||
<p>{{ card.description }}</p>
|
||||
{% if card.disabled %}
|
||||
<a href="{{ card.url }}" class="btn btn-outline-secondary disabled">Сначала выберите мероприятие</a>
|
||||
{% else %}
|
||||
<a href="{{ card.url }}" class="btn btn-primary">{{ card.btn }}</a>
|
||||
{% endif %}
|
||||
</article>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,94 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Конструктор билета — {{ event.name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h1>Конструктор пригласительного билета</h1>
|
||||
<a href="{{ url_for('main.admin_events') }}" class="btn btn-secondary">← К мероприятиям</a>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-lg-5">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Настройки дизайна</h5>
|
||||
<form method="POST" id="ticket-form" enctype="multipart/form-data">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="mb-3">
|
||||
<label for="logo_file" class="form-label">Логотип компании</label>
|
||||
<input type="file" class="form-control" id="logo_file" name="logo_file" accept=".jpg,.jpeg,.png,.webp">
|
||||
{% if event.logo_filename %}
|
||||
<div class="mt-2 d-flex align-items-center gap-2">
|
||||
<img src="{{ url_for('main.event_logo', event_id=event.id) }}" alt="Логотип" style="max-height: 60px; max-width: 200px;">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="delete_logo" name="delete_logo" value="1">
|
||||
<label class="form-check-label" for="delete_logo">Удалить</label>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="ticket_title" class="form-label">Заголовок билета</label>
|
||||
<input type="text" class="form-control" id="ticket_title" name="ticket_title" value="{{ event.ticket_title or event.name }}">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="ticket_subtitle" class="form-label">Подзаголовок</label>
|
||||
<input type="text" class="form-control" id="ticket_subtitle" name="ticket_subtitle" value="{{ event.ticket_subtitle or '' }}">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="ticket_body_text" class="form-label">Дополнительный текст</label>
|
||||
<textarea class="form-control" id="ticket_body_text" name="ticket_body_text" rows="3">{{ event.ticket_body_text or '' }}</textarea>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-4 mb-3">
|
||||
<label for="ticket_background_color" class="form-label">Фон</label>
|
||||
<input type="color" class="form-control form-control-color w-100" id="ticket_background_color" name="ticket_background_color" value="{{ event.ticket_background_color or '#ffffff' }}">
|
||||
</div>
|
||||
<div class="col-md-4 mb-3">
|
||||
<label for="ticket_text_color" class="form-label">Текст</label>
|
||||
<input type="color" class="form-control form-control-color w-100" id="ticket_text_color" name="ticket_text_color" value="{{ event.ticket_text_color or '#000000' }}">
|
||||
</div>
|
||||
<div class="col-md-4 mb-3">
|
||||
<label for="ticket_accent_color" class="form-label">Акцент</label>
|
||||
<input type="color" class="form-control form-control-color w-100" id="ticket_accent_color" name="ticket_accent_color" value="{{ event.ticket_accent_color or '#0d6efd' }}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="ticket_layout" class="form-label">Ориентация</label>
|
||||
<select class="form-select" id="ticket_layout" name="ticket_layout">
|
||||
<option value="portrait" {% if (event.ticket_layout or 'portrait') == 'portrait' %}selected{% endif %}>Портретная (600×900)</option>
|
||||
<option value="landscape" {% if (event.ticket_layout or 'portrait') == 'landscape' %}selected{% endif %}>Альбомная (900×600)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-3 form-check">
|
||||
<input type="checkbox" class="form-check-input" id="ticket_show_qr" name="ticket_show_qr" {% if event.ticket_show_qr %}checked{% endif %}>
|
||||
<label class="form-check-label" for="ticket_show_qr">Показывать QR-код</label>
|
||||
</div>
|
||||
<div class="mb-3 form-check">
|
||||
<input type="checkbox" class="form-check-input" id="ticket_show_photo" name="ticket_show_photo" {% if event.ticket_show_photo %}checked{% endif %}>
|
||||
<label class="form-check-label" for="ticket_show_photo">Показывать фото гостя</label>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<button type="submit" class="btn btn-primary">Сохранить</button>
|
||||
<button type="button" id="refresh-preview" class="btn btn-outline-secondary">Обновить предпросмотр</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-7">
|
||||
<div class="card">
|
||||
<div class="card-body text-center">
|
||||
<h5 class="card-title">Предпросмотр</h5>
|
||||
<p class="text-muted">Пример билета с тестовыми данными</p>
|
||||
<img id="ticket-preview" src="{{ url_for('main.admin_event_ticket_preview', event_id=event.id) }}" data-preview-url="{{ url_for('main.admin_event_ticket_preview', event_id=event.id) }}" alt="Предпросмотр билета" class="img-fluid border shadow-sm" style="max-height: 80vh;">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script src="{{ url_for('static', filename='js/ticket_constructor.js') }}"></script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,112 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Загрузка данных — InviteMe{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1 class="mb-4">Загрузка данных</h1>
|
||||
<p class="text-muted">Мероприятие: {{ event.name }}</p>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">1. Загрузить Excel со списком гостей</h5>
|
||||
<p class="text-muted">Столбцы: Отдел, Фамилия, Имя, Отчество, Должность, Почта, Телефон, Название фото</p>
|
||||
<a href="{{ url_for('main.admin_upload_template') }}" class="btn btn-outline-info btn-sm mb-3">Скачать шаблон Excel</a>
|
||||
<form method="POST" enctype="multipart/form-data">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="mb-3">
|
||||
<label for="excel_file" class="form-label">Excel-файл</label>
|
||||
<input type="file" class="form-control" id="excel_file" name="excel_file" accept=".xlsx" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="base_url" class="form-label">Базовый URL для QR-кодов (опционально)</label>
|
||||
<input type="url" class="form-control" id="base_url" name="base_url" placeholder="https://events.company.com">
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">Загрузить и сгенерировать QR</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">2. Загрузить фото гостей</h5>
|
||||
<p class="text-muted">Фото должны называться точно так же, как указано в столбце «Название фото»</p>
|
||||
<form method="POST" enctype="multipart/form-data">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="mb-3">
|
||||
<label for="photo_files" class="form-label">Фото (можно несколько)</label>
|
||||
<input type="file" class="form-control" id="photo_files" name="photo_files" accept=".jpg,.jpeg,.png,.webp" multiple>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="zip_file" class="form-label">Или ZIP-архив</label>
|
||||
<input type="file" class="form-control" id="zip_file" name="zip_file" accept=".zip">
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">Загрузить фото</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mt-4">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Пример заполнения Excel-таблицы</h5>
|
||||
<p class="text-muted">Имена столбцов должны совпадать точно. Значение в столбце «Название фото» должно совпадать с именем файла фото, включая расширение.</p>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm table-bordered">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Отдел</th>
|
||||
<th>Фамилия</th>
|
||||
<th>Имя</th>
|
||||
<th>Отчество</th>
|
||||
<th>Должность</th>
|
||||
<th>Почта</th>
|
||||
<th>Телефон</th>
|
||||
<th>Название фото</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>IT</td>
|
||||
<td>Иванов</td>
|
||||
<td>Иван</td>
|
||||
<td>Иванович</td>
|
||||
<td>Разработчик</td>
|
||||
<td>ivanov@company.com</td>
|
||||
<td>+7 (999) 111-22-33</td>
|
||||
<td>ivanov.jpg</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Маркетинг</td>
|
||||
<td>Петрова</td>
|
||||
<td>Анна</td>
|
||||
<td>Сергеевна</td>
|
||||
<td>Маркетолог</td>
|
||||
<td>petrova@company.com</td>
|
||||
<td>+7 (999) 222-33-44</td>
|
||||
<td>petrova.png</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Продажи</td>
|
||||
<td>Сидоров</td>
|
||||
<td>Алексей</td>
|
||||
<td>Петрович</td>
|
||||
<td>Менеджер</td>
|
||||
<td>sidorov@company.com</td>
|
||||
<td>+7 (999) 333-44-55</td>
|
||||
<td>sidorov.jpg</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,40 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ 'Редактировать' if user else 'Добавить' }} пользователя — InviteMe{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1 class="mb-4">{{ 'Редактировать' if user else 'Добавить' }} пользователя</h1>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<form method="POST">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="mb-3">
|
||||
<label for="username" class="form-label">Логин *</label>
|
||||
<input type="text" class="form-control" id="username" name="username" value="{{ user.username if user else '' }}" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="password" class="form-label">Пароль {% if user %}(оставьте пустым, чтобы не менять){% else %}*{% endif %}</label>
|
||||
<input type="password" class="form-control" id="password" name="password" minlength="{{ config.MIN_PASSWORD_LENGTH }}" {% if not user %}required{% endif %}>
|
||||
<div class="form-text">Минимум {{ config.MIN_PASSWORD_LENGTH }} символов и три группы символов.</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="role" class="form-label">Роль *</label>
|
||||
<select class="form-select" id="role" name="role" required>
|
||||
<option value="admin" {% if user and user.role == 'admin' %}selected{% endif %}>Администратор</option>
|
||||
<option value="organizer" {% if user and user.role == 'organizer' %}selected{% endif %}>Организатор</option>
|
||||
<option value="staff" {% if user and user.role == 'staff' %}selected{% endif %} {% if not user %}selected{% endif %}>Сотрудник на входе</option>
|
||||
</select>
|
||||
</div>
|
||||
{% if user %}
|
||||
<div class="mb-3 form-check">
|
||||
<input type="checkbox" class="form-check-input" id="is_active" name="is_active" {% if user.is_active %}checked{% endif %}>
|
||||
<label class="form-check-label" for="is_active">Активен</label>
|
||||
</div>
|
||||
{% endif %}
|
||||
<button type="submit" class="btn btn-primary">Сохранить</button>
|
||||
<a href="{{ url_for('main.admin_users') }}" class="btn btn-secondary">Отмена</a>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,59 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Пользователи — InviteMe{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h1>Пользователи</h1>
|
||||
<a href="{{ url_for('main.admin_user_add') }}" class="btn btn-primary">Добавить пользователя</a>
|
||||
</div>
|
||||
|
||||
<table class="table table-striped table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Логин</th>
|
||||
<th>Роль</th>
|
||||
<th>Статус</th>
|
||||
<th>Создан</th>
|
||||
<th>Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for user in users %}
|
||||
<tr class="{{ 'table-secondary' if not user.is_active }}">
|
||||
<td>{{ user.username }}</td>
|
||||
<td>
|
||||
{% if user.role == 'admin' %}
|
||||
<span class="badge bg-danger">Админ</span>
|
||||
{% elif user.role == 'organizer' %}
|
||||
<span class="badge bg-warning text-dark">Организатор</span>
|
||||
{% else %}
|
||||
<span class="badge bg-info text-dark">Сотрудник</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if user.is_active %}
|
||||
<span class="badge bg-success">Активен</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary">Заблокирован</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ user.created_at | localtime('%d.%m.%Y %H:%M') }}</td>
|
||||
<td>
|
||||
<a href="{{ url_for('main.admin_user_edit', user_id=user.id) }}" class="btn btn-sm btn-outline-primary">Изменить</a>
|
||||
{% if current_user.id != user.id %}
|
||||
<form action="{{ url_for('main.admin_user_delete', user_id=user.id) }}" method="POST" class="d-inline" data-confirm="Заблокировать пользователя?">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="btn btn-sm btn-outline-danger">Блок</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="5" class="text-center text-muted">Пользователи не найдены</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,49 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><meta name="csrf-token" content="{{ csrf_token() }}">
|
||||
<title>{% block title %}InviteMe{% endblock %}</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-T3c6CoIi6uLrA9TneNEoa7RxnatzjcDSCmG1MXxSR1GASXEV/Dwwykc2MPK8M2HN" crossorigin="anonymous">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}"><link rel="icon" type="image/png" href="{{ url_for('static', filename='images/inviteme_logo.png') }}">
|
||||
</head>
|
||||
<body class="{% if current_user %}has-app-shell{% else %}public-page{% endif %}">
|
||||
{% macro nav_link(endpoint, label, icon) %}
|
||||
<a class="sidebar-link {% if request.endpoint == endpoint %}active{% endif %}" href="{{ url_for(endpoint) }}"><svg class="nav-icon" aria-hidden="true"><use href="{{ url_for('static', filename='images/icons.svg') }}#{{ icon }}"></use></svg><span>{{ label }}</span></a>
|
||||
{% endmacro %}
|
||||
{% if current_user %}
|
||||
<div class="app-shell">
|
||||
<aside class="app-sidebar" id="app-sidebar">
|
||||
<a class="sidebar-brand" href="{{ url_for('main.admin_panel') }}"><span class="brand-mark">I</span><span><strong>InviteMe</strong><small>Event operations</small></span></a>
|
||||
<nav class="sidebar-nav" aria-label="Основная навигация">
|
||||
<p class="nav-caption">Рабочая область</p>
|
||||
{{ nav_link('main.admin_panel', 'Обзор', 'grid') }}
|
||||
{% if current_user.role in ('admin', 'organizer') %}{{ nav_link('main.admin_events', 'Мероприятия', 'calendar') }}{% endif %}
|
||||
{% if current_event and current_user.role in ('admin', 'organizer') %}
|
||||
{{ nav_link('main.admin_dashboard', 'Аналитика', 'chart') }}{{ nav_link('main.admin_guests', 'Гости', 'users') }}{{ nav_link('main.admin_upload', 'Импорт гостей', 'upload') }}{{ nav_link('main.admin_email_send', 'Рассылки', 'mail') }}
|
||||
{% endif %}
|
||||
{{ nav_link('main.check_in', 'Контроль входа', 'scan') }}
|
||||
{% if current_user.role == 'admin' %}
|
||||
<p class="nav-caption nav-caption-spaced">Система</p>
|
||||
{{ nav_link('main.admin_users', 'Пользователи', 'shield') }}{{ nav_link('main.admin_logs', 'Журнал действий', 'log') }}{{ nav_link('main.admin_email_settings', 'Настройки почты', 'settings') }}
|
||||
{% endif %}
|
||||
</nav>
|
||||
<div class="sidebar-profile"><span class="avatar">{{ current_user.username[:1] | upper }}</span><span class="profile-copy"><strong>{{ current_user.username }}</strong><small>{{ current_user.role }}</small></span><form method="POST" action="{{ url_for('main.logout') }}"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><button class="icon-button" type="submit" title="Выйти" aria-label="Выйти"><svg class="nav-icon"><use href="{{ url_for('static', filename='images/icons.svg') }}#logout"></use></svg></button></form></div>
|
||||
</aside>
|
||||
<button class="sidebar-backdrop" type="button" data-sidebar-toggle aria-label="Закрыть меню"></button>
|
||||
<section class="app-main">
|
||||
<header class="app-topbar">
|
||||
<button class="icon-button mobile-menu" type="button" data-sidebar-toggle aria-controls="app-sidebar" aria-label="Открыть меню"><svg class="nav-icon"><use href="{{ url_for('static', filename='images/icons.svg') }}#menu"></use></svg></button>
|
||||
<div class="topbar-context"><small>Рабочее пространство</small><strong>{% if current_event %}{{ current_event.name }}{% else %}Все мероприятия{% endif %}</strong></div>
|
||||
{% if current_user.role in ('admin', 'organizer') and available_events %}
|
||||
<div class="dropdown ms-auto"><button class="event-switcher dropdown-toggle" type="button" data-bs-toggle="dropdown"><span class="status-dot"></span>{{ current_event.name if current_event else 'Выбрать мероприятие' }}</button><ul class="dropdown-menu dropdown-menu-end">{% for event in available_events %}<li><form action="{{ url_for('main.admin_event_switch', event_id=event.id) }}" method="POST"><input type="hidden" name="csrf_token" value="{{ csrf_token() }}"><button type="submit" class="dropdown-item {% if current_event and current_event.id == event.id %}active{% endif %}">{{ event.name }}</button></form></li>{% endfor %}</ul></div>
|
||||
{% endif %}
|
||||
</header>
|
||||
<main class="content-wrap">
|
||||
{% else %}
|
||||
<header class="public-header"><a class="sidebar-brand" href="{{ url_for('main.index') }}"><span class="brand-mark">I</span><span><strong>InviteMe</strong><small>Guest experience</small></span></a></header><main class="public-content">
|
||||
{% endif %}
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}{% if messages %}<div class="flash-stack">{% for category, message in messages %}<div class="alert alert-{{ 'danger' if category == 'error' else category }} alert-dismissible fade show" role="alert">{{ message }}<button type="button" class="btn-close" data-bs-dismiss="alert"></button></div>{% endfor %}</div>{% endif %}{% endwith %}
|
||||
{% block content %}{% endblock %}</main>
|
||||
{% if current_user %}</section></div>{% endif %}
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js" integrity="sha384-C6RzsynM9kWDrMNeT87bh95OGNyZPhcTNXj1NW7RuBCsyN/o0jlpcV8Qyq46cDfL" crossorigin="anonymous"></script><script src="{{ url_for('static', filename='js/app.js') }}"></script>{% block scripts %}{% endblock %}
|
||||
</body></html>
|
||||
@@ -0,0 +1,92 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Проверка приглашения — InviteMe{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="checkin-container">
|
||||
<header class="page-header"><div><div class="eyebrow">Операционный режим</div><h1 class="page-title">Контроль входа</h1><p class="page-subtitle">Сканируйте QR-код или быстро найдите гостя по имени.</p></div>{% if checkin_event %}<span class="badge bg-success">● {{ checkin_event.name }}</span>{% endif %}</header>
|
||||
|
||||
{% if active_events %}
|
||||
<div class="row justify-content-center mb-4">
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Выберите мероприятие</h5>
|
||||
<form method="POST" action="{{ url_for('main.check_in', token=token) }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="mb-3">
|
||||
<select id="event-select" name="event_id" class="form-select" data-auto-submit>
|
||||
<option value="">-- Мероприятие --</option>
|
||||
{% for event in active_events %}
|
||||
<option value="{{ event.id }}" {% if checkin_event and checkin_event.id == event.id %}selected{% endif %}>
|
||||
{{ event.name }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<noscript><button type="submit" class="btn btn-primary">Выбрать</button></noscript>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if checkin_event %}
|
||||
<div class="alert alert-info">Работаем с мероприятием: <strong>{{ checkin_event.name }}</strong></div>
|
||||
{% endif %}
|
||||
|
||||
<div class="checkin-grid">
|
||||
<div>
|
||||
<div class="card h-100">
|
||||
<div class="card-body">
|
||||
<div class="eyebrow">Камера</div><h2 class="h5 card-title">Сканировать QR-код</h2>
|
||||
<div class="mb-3">
|
||||
<label for="camera-select" class="form-label">Камера</label>
|
||||
<select id="camera-select" class="form-select">
|
||||
<option value="">-- Выберите камеру --</option>
|
||||
</select>
|
||||
</div>
|
||||
<div id="qr-reader" class="qr-reader"></div>
|
||||
<button id="start-scan" class="btn btn-primary w-100 mt-2">Включить камеру</button>
|
||||
<button id="stop-scan" class="btn btn-secondary w-100 mt-2 d-none">Выключить камеру</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="card h-100">
|
||||
<div class="card-body">
|
||||
<div class="eyebrow">Ручной поиск</div><h2 class="h5 card-title">Найти гостя по ФИО</h2>
|
||||
<input type="text" id="search-input" class="form-control" placeholder="Введите фамилию..." autocomplete="off">
|
||||
<div id="search-results" class="list-group mt-2"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="guest-card" class="card mt-4 d-none">
|
||||
<div class="card-body text-center">
|
||||
<img id="guest-photo" src="" alt="Фото гостя" class="guest-photo mb-3">
|
||||
<h2 id="guest-name" class="card-title"></h2>
|
||||
<p id="guest-department" class="lead"></p>
|
||||
<p id="guest-position" class="text-muted"></p>
|
||||
<p id="guest-email" class="text-muted"></p>
|
||||
<p id="guest-event" class="text-info"></p>
|
||||
|
||||
<div id="check-in-status" class="alert d-none"></div>
|
||||
|
||||
<button id="check-in-btn" class="btn btn-success btn-lg w-100">Отметить вход</button>
|
||||
<button id="undo-check-in-btn" class="btn btn-outline-warning w-100 mt-2 d-none">Отменить отметку входа</button>
|
||||
<button id="reset-btn" class="btn btn-outline-secondary w-100 mt-2">Сбросить</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script src="https://unpkg.com/html5-qrcode@2.3.8/html5-qrcode.min.js"
|
||||
integrity="sha384-c9d8RFSL+u3exBOJ4Yp3HUJXS4znl9f+z66d1y54ig+ea249SpqR+w1wyvXz/lk+"
|
||||
crossorigin="anonymous"></script>
|
||||
<script src="{{ url_for('static', filename='js/checkin.js') }}"></script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,44 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Приглашение на {{ event_name }}</title>
|
||||
</head>
|
||||
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
|
||||
<div style="max-width: 600px; margin: 0 auto; padding: 20px; border: 1px solid #ddd; border-radius: 8px;">
|
||||
<h2 style="color: #0d6efd;">Приглашение на мероприятие</h2>
|
||||
<p>Здравствуйте, <strong>{{ guest.full_name }}</strong>!</p>
|
||||
<p>Вы приглашены на мероприятие <strong>{{ event_name }}</strong>.</p>
|
||||
|
||||
{% if invitation_message %}
|
||||
<p>{{ invitation_message }}</p>
|
||||
{% endif %}
|
||||
|
||||
<p style="text-align: center; margin: 24px 0;">
|
||||
<a href="{{ invitation_url }}" style="display:inline-block;padding:12px 20px;background:#0891b2;color:#fff;text-decoration:none;border-radius:8px;font-weight:bold;">
|
||||
Открыть приглашение и подтвердить участие
|
||||
</a>
|
||||
</p>
|
||||
|
||||
{% if ticket_cid %}
|
||||
<p>Ваш персональный пригласительный билет:</p>
|
||||
<div style="text-align: center; margin: 20px 0;">
|
||||
<img src="cid:{{ ticket_cid }}" alt="Пригласительный билет" style="max-width: 100%; border: 1px solid #ddd; border-radius: 8px;">
|
||||
</div>
|
||||
{% else %}
|
||||
<p>Ваш персональный QR-код для входа:</p>
|
||||
<div style="text-align: center; margin: 20px 0;">
|
||||
<img src="cid:qr_code_image" alt="QR-код" style="max-width: 250px;">
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if additional_text %}
|
||||
<p>{{ additional_text }}</p>
|
||||
{% endif %}
|
||||
|
||||
<p style="color: #666; font-size: 14px;">
|
||||
Пожалуйста, предъявите этот QR-код или билет при входе. Не передавайте их другим лицам.
|
||||
</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,32 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Тестовое письмо InviteMe</title>
|
||||
</head>
|
||||
<body style="margin:0;padding:0;background-color:#0f172a;font-family:'Segoe UI',Roboto,Arial,sans-serif;">
|
||||
<table width="100%" cellpadding="0" cellspacing="0" border="0" style="background-color:#0f172a;padding:40px 0;">
|
||||
<tr>
|
||||
<td align="center">
|
||||
<table width="600" cellpadding="0" cellspacing="0" border="0" style="background:rgba(30,41,59,0.95);border:1px solid rgba(148,163,184,0.15);border-radius:16px;overflow:hidden;">
|
||||
<tr>
|
||||
<td style="padding:30px;text-align:center;">
|
||||
<h2 style="color:#22d3ee;margin:0 0 16px 0;">InviteMe</h2>
|
||||
<p style="color:#e2e8f0;font-size:16px;line-height:1.6;margin:0;">
|
||||
Это тестовое письмо. Если вы его видите, SMTP-настройки работают корректно.
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:20px 30px;background:rgba(15,23,42,0.6);text-align:center;">
|
||||
<p style="color:#94a3b8;font-size:13px;margin:0;">
|
||||
Отправлено из панели управления InviteMe
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,78 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Приглашение — {{ event.name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-lg-8">
|
||||
<div class="card invitation-card">
|
||||
<div class="card-body p-4 p-md-5">
|
||||
<div class="eyebrow">Персональное приглашение</div>
|
||||
<h1 class="display-5 mb-3">{{ event.name }}</h1>
|
||||
<p class="lead text-muted">Здравствуйте, <span class="text-white">{{ guest.build_full_name() }}</span>!</p>
|
||||
{% if event.description %}<p>{{ event.description }}</p>{% endif %}
|
||||
|
||||
<div class="row g-3 my-4">
|
||||
{% if event.event_date %}
|
||||
<div class="col-md-6"><div class="stat-card"><div class="stat-label">Дата и время</div><div class="h5 mt-2 mb-0">{{ event.event_date | localtime('%d.%m.%Y %H:%M') }}</div></div></div>
|
||||
{% endif %}
|
||||
{% if event.location %}
|
||||
<div class="col-md-6"><div class="stat-card"><div class="stat-label">Место</div><div class="h5 mt-2 mb-0">{{ event.location }}</div></div></div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if event.invitation_message %}
|
||||
<div class="alert alert-info invitation-message">{{ event.invitation_message }}</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="d-flex flex-wrap gap-2 mb-4">
|
||||
<a class="btn btn-outline-info" href="{{ url_for('main.public_invitation_ticket', token=guest.token) }}">Скачать билет</a>
|
||||
{% if event.event_date %}
|
||||
<a class="btn btn-outline-primary" href="{{ url_for('main.public_invitation_calendar', token=guest.token) }}">Добавить в календарь</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if guest.rsvp_status == 'confirmed' %}
|
||||
<div class="alert alert-success">Вы подтвердили участие.</div>
|
||||
{% elif guest.rsvp_status == 'declined' %}
|
||||
<div class="alert alert-secondary">Вы сообщили, что не сможете присутствовать.</div>
|
||||
{% endif %}
|
||||
|
||||
{% if rsvp_open %}
|
||||
<div class="eyebrow mt-5">RSVP</div><h2 class="h4">Подтвердите участие</h2>
|
||||
{% if event.rsvp_deadline %}
|
||||
<p class="text-muted">Ответ можно изменить до {{ event.rsvp_deadline | localtime('%d.%m.%Y %H:%M') }}.</p>
|
||||
{% endif %}
|
||||
<form method="POST">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Ваш ответ</label>
|
||||
<div class="d-flex flex-wrap gap-3">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="radio" name="rsvp_status" id="rsvp-confirmed" value="confirmed" {% if guest.rsvp_status == 'confirmed' %}checked{% endif %} required>
|
||||
<label class="form-check-label" for="rsvp-confirmed">Буду участвовать</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="radio" name="rsvp_status" id="rsvp-declined" value="declined" {% if guest.rsvp_status == 'declined' %}checked{% endif %} required>
|
||||
<label class="form-check-label" for="rsvp-declined">Не смогу прийти</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="dietary_requirements" class="form-label">Особенности питания</label>
|
||||
<textarea class="form-control" id="dietary_requirements" name="dietary_requirements" maxlength="1000" rows="2">{{ guest.dietary_requirements or '' }}</textarea>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="rsvp_comment" class="form-label">Комментарий организатору</label>
|
||||
<textarea class="form-control" id="rsvp_comment" name="rsvp_comment" maxlength="1000" rows="3">{{ guest.rsvp_comment or '' }}</textarea>
|
||||
</div>
|
||||
<button class="btn btn-primary btn-lg" type="submit">Сохранить ответ</button>
|
||||
</form>
|
||||
{% elif event.rsvp_enabled %}
|
||||
<div class="alert alert-warning">Приём ответов завершён.</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,25 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Вход — InviteMe{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="auth-layout"><div class="card auth-card">
|
||||
<div class="text-center mb-3">
|
||||
<img src="{{ url_for('static', filename='images/inviteme_logo.png') }}" alt="InviteMe" class="login-logo">
|
||||
</div>
|
||||
<div class="eyebrow text-center">Панель управления</div><h1 class="h3 text-center mb-2">С возвращением</h1>
|
||||
<p class="text-muted text-center mb-4">Войдите, чтобы продолжить работу</p>
|
||||
<form method="POST" action="{{ url_for('main.login') }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="mb-3">
|
||||
<label for="username" class="form-label">Логин</label>
|
||||
<input type="text" class="form-control" id="username" name="username" required autofocus>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="password" class="form-label">Пароль</label>
|
||||
<input type="password" class="form-control" id="password" name="password" required>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary w-100 mt-2">Войти в систему</button>
|
||||
</form>
|
||||
</div></div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,32 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Первоначальная настройка — InviteMe{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="auth-layout"><div class="card auth-card">
|
||||
<div class="text-center mb-4">
|
||||
<img src="{{ url_for('static', filename='images/inviteme_logo.png') }}" alt="InviteMe" class="login-logo">
|
||||
</div>
|
||||
<div class="eyebrow text-center">Первый запуск</div><h1 class="h3 text-center mb-3">Настроим InviteMe</h1>
|
||||
<p class="text-muted text-center mb-4">
|
||||
Создайте первого администратора, чтобы начать работу с InviteMe.
|
||||
</p>
|
||||
<form method="POST" action="{{ url_for('main.setup') }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="mb-3">
|
||||
<label for="username" class="form-label">Логин администратора</label>
|
||||
<input type="text" class="form-control" id="username" name="username" required autofocus>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="password" class="form-label">Пароль</label>
|
||||
<input type="password" class="form-control" id="password" name="password" minlength="{{ config.MIN_PASSWORD_LENGTH }}" required>
|
||||
<div class="form-text">Минимум {{ config.MIN_PASSWORD_LENGTH }} символов и три группы символов.</div>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label for="password_confirm" class="form-label">Повторите пароль</label>
|
||||
<input type="password" class="form-control" id="password_confirm" name="password_confirm" minlength="{{ config.MIN_PASSWORD_LENGTH }}" required>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary w-100">Создать администратора</button>
|
||||
</form>
|
||||
</div></div>
|
||||
{% endblock %}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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")
|
||||
@@ -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
|
||||
+195
@@ -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_<id>/`.
|
||||
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=<uuid>
|
||||
↓
|
||||
Загрузка карточки гостя
|
||||
↓
|
||||
POST /api/check-in/<token>
|
||||
↓
|
||||
Проверка: уже отмечен?
|
||||
↓
|
||||
Создание записи 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** — Добавлены пользователи, роли и поддержка нескольких мероприятий.
|
||||
@@ -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`;
|
||||
- не предназначен для доступа из локальной сети или интернета.
|
||||
@@ -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. Ротацию секретов выполняйте только как плановое обслуживание.
|
||||
@@ -0,0 +1 @@
|
||||
Single-database configuration for Flask.
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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"}
|
||||
@@ -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 ###
|
||||
@@ -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')
|
||||
@@ -0,0 +1,4 @@
|
||||
-r requirements.txt
|
||||
pytest==9.1.1
|
||||
bandit==1.9.4
|
||||
pip-audit==2.10.1
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
@@ -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"<script>alert(1)</script>"), "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
|
||||
Reference in New Issue
Block a user