Update: admin panel, SMTP/EWS/Outlook mail modes, QR fix, event folders, security improvements
This commit is contained in:
@@ -3,12 +3,17 @@ import logging
|
|||||||
|
|
||||||
from flask import Flask
|
from flask import Flask
|
||||||
from flask_sqlalchemy import SQLAlchemy
|
from flask_sqlalchemy import SQLAlchemy
|
||||||
|
from flask_limiter import Limiter
|
||||||
|
from flask_limiter.util import get_remote_address
|
||||||
|
from flask_wtf.csrf import CSRFProtect
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
|
|
||||||
from config import Config
|
from config import Config
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
db = SQLAlchemy()
|
db = SQLAlchemy()
|
||||||
|
limiter = Limiter(key_func=get_remote_address)
|
||||||
|
csrf = CSRFProtect()
|
||||||
|
|
||||||
|
|
||||||
def create_app(config_class=Config):
|
def create_app(config_class=Config):
|
||||||
@@ -22,6 +27,8 @@ def create_app(config_class=Config):
|
|||||||
os.makedirs(os.path.dirname(app.config["SQLALCHEMY_DATABASE_URI"].replace("sqlite:///", "")), exist_ok=True)
|
os.makedirs(os.path.dirname(app.config["SQLALCHEMY_DATABASE_URI"].replace("sqlite:///", "")), exist_ok=True)
|
||||||
|
|
||||||
db.init_app(app)
|
db.init_app(app)
|
||||||
|
limiter.init_app(app)
|
||||||
|
csrf.init_app(app)
|
||||||
|
|
||||||
from app.routes import main_bp
|
from app.routes import main_bp
|
||||||
app.register_blueprint(main_bp)
|
app.register_blueprint(main_bp)
|
||||||
@@ -30,10 +37,20 @@ def create_app(config_class=Config):
|
|||||||
db.create_all()
|
db.create_all()
|
||||||
_migrate_existing_data(app)
|
_migrate_existing_data(app)
|
||||||
_create_default_users(app)
|
_create_default_users(app)
|
||||||
|
_load_smtp_settings(app)
|
||||||
|
|
||||||
return app
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
def _load_smtp_settings(app):
|
||||||
|
"""Загружает SMTP-настройки из базы в конфиг приложения."""
|
||||||
|
try:
|
||||||
|
from app.models import SmtpSettings
|
||||||
|
SmtpSettings.apply_to_app(app)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"SMTP settings load skipped: {e}")
|
||||||
|
|
||||||
|
|
||||||
def _column_exists(table_name, column_name):
|
def _column_exists(table_name, column_name):
|
||||||
return db.session.execute(
|
return db.session.execute(
|
||||||
text(f"SELECT COUNT(*) FROM pragma_table_info('{table_name}') WHERE name='{column_name}'")
|
text(f"SELECT COUNT(*) FROM pragma_table_info('{table_name}') WHERE name='{column_name}'")
|
||||||
@@ -45,6 +62,65 @@ def _add_column_if_missing(table_name, column_name, column_def):
|
|||||||
db.session.execute(text(f"ALTER TABLE {table_name} ADD COLUMN {column_name} {column_def}"))
|
db.session.execute(text(f"ALTER TABLE {table_name} ADD COLUMN {column_name} {column_def}"))
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_move(src, dst):
|
||||||
|
"""Перемещает файл, игнорируя ошибки."""
|
||||||
|
try:
|
||||||
|
import shutil
|
||||||
|
dst.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
shutil.move(str(src), str(dst))
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Не удалось переместить {src} -> {dst}: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _migrate_files_to_event_folders():
|
||||||
|
"""Переносит существующие файлы в папки по мероприятиям."""
|
||||||
|
from pathlib import Path
|
||||||
|
from flask import current_app
|
||||||
|
from app.models import Event, Guest
|
||||||
|
|
||||||
|
photo_base = Path(current_app.config["PHOTO_FOLDER"])
|
||||||
|
qr_base = Path(current_app.config["QR_FOLDER"])
|
||||||
|
logo_base = Path(current_app.config["LOGO_FOLDER"])
|
||||||
|
tickets_base = Path(current_app.config["UPLOAD_FOLDER"]) / "tickets"
|
||||||
|
|
||||||
|
# Фото гостей
|
||||||
|
for guest in Guest.query.filter(Guest.photo_filename.isnot(None)).all():
|
||||||
|
if not guest.photo_filename:
|
||||||
|
continue
|
||||||
|
src = photo_base / guest.photo_filename
|
||||||
|
if src.exists() and src.is_file():
|
||||||
|
dst = photo_base / f"event_{guest.event_id}" / guest.photo_filename
|
||||||
|
_safe_move(src, dst)
|
||||||
|
|
||||||
|
# QR-коды гостей
|
||||||
|
for guest in Guest.query.filter(Guest.qr_filename.isnot(None)).all():
|
||||||
|
if not guest.qr_filename:
|
||||||
|
continue
|
||||||
|
src = qr_base / guest.qr_filename
|
||||||
|
if src.exists() and src.is_file():
|
||||||
|
dst = qr_base / f"event_{guest.event_id}" / guest.qr_filename
|
||||||
|
_safe_move(src, dst)
|
||||||
|
|
||||||
|
# Логотипы мероприятий
|
||||||
|
for event in Event.query.filter(Event.logo_filename.isnot(None)).all():
|
||||||
|
if not event.logo_filename:
|
||||||
|
continue
|
||||||
|
src = logo_base / event.logo_filename
|
||||||
|
if src.exists() and src.is_file():
|
||||||
|
dst = logo_base / f"event_{event.id}" / event.logo_filename
|
||||||
|
_safe_move(src, dst)
|
||||||
|
|
||||||
|
# Сгенерированные билеты
|
||||||
|
if tickets_base.exists():
|
||||||
|
for event in Event.query.all():
|
||||||
|
event_tickets_dir = tickets_base / f"event_{event.id}"
|
||||||
|
event_tickets_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
for ticket_file in tickets_base.glob(f"ticket_{event.id}_*.png"):
|
||||||
|
_safe_move(ticket_file, event_tickets_dir / ticket_file.name)
|
||||||
|
|
||||||
|
|
||||||
def _migrate_existing_data(app):
|
def _migrate_existing_data(app):
|
||||||
"""Миграция существующих гостей в дефолтное мероприятие и добавление новых колонок."""
|
"""Миграция существующих гостей в дефолтное мероприятие и добавление новых колонок."""
|
||||||
from app.models import Event, Guest
|
from app.models import Event, Guest
|
||||||
@@ -68,6 +144,18 @@ def _migrate_existing_data(app):
|
|||||||
_add_column_if_missing("guests", "middle_name", "VARCHAR(255)")
|
_add_column_if_missing("guests", "middle_name", "VARCHAR(255)")
|
||||||
_add_column_if_missing("guests", "phone", "VARCHAR(50)")
|
_add_column_if_missing("guests", "phone", "VARCHAR(50)")
|
||||||
|
|
||||||
|
# Версия сессии пользователя для инвалидации после смены пароля
|
||||||
|
_add_column_if_missing("users", "session_version", "INTEGER DEFAULT 0")
|
||||||
|
|
||||||
|
# Флаг отправки через локальный Outlook (MAPI)
|
||||||
|
_add_column_if_missing("smtp_settings", "use_local_outlook", "BOOLEAN DEFAULT 0")
|
||||||
|
# Флаг отправки через Exchange Web Services (EWS)
|
||||||
|
_add_column_if_missing("smtp_settings", "use_ews", "BOOLEAN DEFAULT 0")
|
||||||
|
_add_column_if_missing("smtp_settings", "ews_url", "VARCHAR(500)")
|
||||||
|
|
||||||
|
# Переносим файлы в папки по мероприятиям
|
||||||
|
_migrate_files_to_event_folders()
|
||||||
|
|
||||||
# Проверяем, есть ли колонка event_id в таблице guests
|
# Проверяем, есть ли колонка event_id в таблице guests
|
||||||
if _column_exists("guests", "event_id"):
|
if _column_exists("guests", "event_id"):
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|||||||
+109
@@ -14,12 +14,14 @@ class User(db.Model):
|
|||||||
password_hash = db.Column(db.String(255), nullable=False)
|
password_hash = db.Column(db.String(255), nullable=False)
|
||||||
role = db.Column(db.String(20), nullable=False, default="staff")
|
role = db.Column(db.String(20), nullable=False, default="staff")
|
||||||
is_active = db.Column(db.Boolean, default=True, nullable=False)
|
is_active = db.Column(db.Boolean, default=True, nullable=False)
|
||||||
|
session_version = db.Column(db.Integer, default=0, nullable=False)
|
||||||
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
|
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
|
||||||
|
|
||||||
created_events = db.relationship("Event", backref="creator", lazy=True)
|
created_events = db.relationship("Event", backref="creator", lazy=True)
|
||||||
|
|
||||||
def set_password(self, password):
|
def set_password(self, password):
|
||||||
self.password_hash = generate_password_hash(password)
|
self.password_hash = generate_password_hash(password)
|
||||||
|
self.session_version = (self.session_version or 0) + 1
|
||||||
|
|
||||||
def check_password(self, password):
|
def check_password(self, password):
|
||||||
return check_password_hash(self.password_hash, password)
|
return check_password_hash(self.password_hash, password)
|
||||||
@@ -115,6 +117,37 @@ class Guest(db.Model):
|
|||||||
parts = [p for p in [self.last_name, self.first_name, self.middle_name] if p]
|
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
|
return " ".join(parts) if parts else self.full_name
|
||||||
|
|
||||||
|
def _photo_paths(self):
|
||||||
|
"""Возвращает возможные пути к фото: сначала папка мероприятия, потом корень."""
|
||||||
|
if not self.photo_filename:
|
||||||
|
return []
|
||||||
|
from pathlib import Path
|
||||||
|
from flask import current_app
|
||||||
|
base = Path(current_app.config["PHOTO_FOLDER"])
|
||||||
|
paths = [base / self.photo_filename]
|
||||||
|
if self.event_id:
|
||||||
|
paths.insert(0, base / f"event_{self.event_id}" / self.photo_filename)
|
||||||
|
return paths
|
||||||
|
|
||||||
|
@property
|
||||||
|
def has_photo_file(self):
|
||||||
|
try:
|
||||||
|
return any(p.exists() for p in self._photo_paths())
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
@property
|
||||||
|
def photo_url(self):
|
||||||
|
if not self.has_photo_file:
|
||||||
|
return ""
|
||||||
|
from pathlib import Path
|
||||||
|
from flask import current_app
|
||||||
|
for p in self._photo_paths():
|
||||||
|
if p.exists():
|
||||||
|
rel = p.relative_to(Path(current_app.config["UPLOAD_FOLDER"]).parent)
|
||||||
|
return f"/static/{str(rel).replace('\\', '/')}"
|
||||||
|
return ""
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return f"<Guest {self.full_name} ({self.email})>"
|
return f"<Guest {self.full_name} ({self.email})>"
|
||||||
|
|
||||||
@@ -130,3 +163,79 @@ class CheckIn(db.Model):
|
|||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return f"<CheckIn guest_id={self.guest_id} at={self.checked_in_at}>"
|
return f"<CheckIn guest_id={self.guest_id} at={self.checked_in_at}>"
|
||||||
|
|
||||||
|
|
||||||
|
class AuditLog(db.Model):
|
||||||
|
__tablename__ = "audit_logs"
|
||||||
|
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=True)
|
||||||
|
username = db.Column(db.String(100), nullable=True)
|
||||||
|
action = db.Column(db.String(50), nullable=False)
|
||||||
|
target_type = db.Column(db.String(50), nullable=True)
|
||||||
|
target_id = db.Column(db.String(50), nullable=True)
|
||||||
|
details = db.Column(db.Text, nullable=True)
|
||||||
|
ip_address = db.Column(db.String(45), nullable=True)
|
||||||
|
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
|
||||||
|
|
||||||
|
user = db.relationship("User", backref="audit_logs", lazy=True)
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<AuditLog {self.action} by {self.username} at {self.created_at}>"
|
||||||
|
|
||||||
|
|
||||||
|
class SmtpSettings(db.Model):
|
||||||
|
"""Глобальные настройки SMTP для рассылки приглашений."""
|
||||||
|
__tablename__ = "smtp_settings"
|
||||||
|
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
mail_server = db.Column(db.String(255), nullable=False, default="smtp.yandex.ru")
|
||||||
|
mail_port = db.Column(db.Integer, nullable=False, default=465)
|
||||||
|
mail_use_ssl = db.Column(db.Boolean, nullable=False, default=True)
|
||||||
|
mail_username = db.Column(db.String(255), nullable=False, default="")
|
||||||
|
mail_password = db.Column(db.String(255), nullable=False, default="")
|
||||||
|
mail_default_sender = db.Column(db.String(255), nullable=True)
|
||||||
|
use_local_outlook = db.Column(db.Boolean, nullable=False, default=False)
|
||||||
|
use_ews = db.Column(db.Boolean, nullable=False, default=False)
|
||||||
|
ews_url = db.Column(db.String(500), nullable=True)
|
||||||
|
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def mail_mode(self):
|
||||||
|
if self.use_local_outlook:
|
||||||
|
return "outlook"
|
||||||
|
if self.use_ews:
|
||||||
|
return "ews"
|
||||||
|
return "smtp"
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_settings(cls):
|
||||||
|
settings = cls.query.first()
|
||||||
|
if not settings:
|
||||||
|
settings = cls()
|
||||||
|
db.session.add(settings)
|
||||||
|
db.session.commit()
|
||||||
|
return settings
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def apply_to_app(cls, app):
|
||||||
|
"""Применяет сохранённые настройки к конфигурации Flask-приложения."""
|
||||||
|
try:
|
||||||
|
settings = cls.query.first()
|
||||||
|
if not settings:
|
||||||
|
return
|
||||||
|
app.config["MAIL_SERVER"] = settings.mail_server or app.config.get("MAIL_SERVER", "")
|
||||||
|
app.config["MAIL_PORT"] = settings.mail_port or app.config.get("MAIL_PORT", 465)
|
||||||
|
app.config["MAIL_USE_SSL"] = settings.mail_use_ssl
|
||||||
|
app.config["MAIL_USERNAME"] = settings.mail_username or ""
|
||||||
|
app.config["MAIL_PASSWORD"] = settings.mail_password or ""
|
||||||
|
app.config["MAIL_DEFAULT_SENDER"] = settings.mail_default_sender or settings.mail_username or app.config.get("MAIL_DEFAULT_SENDER", "")
|
||||||
|
app.config["MAIL_USE_LOCAL_OUTLOOK"] = settings.use_local_outlook
|
||||||
|
app.config["MAIL_USE_EWS"] = settings.use_ews
|
||||||
|
app.config["EWS_URL"] = settings.ews_url or ""
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<SmtpSettings {self.mail_server}:{self.mail_port} {self.mail_username}>"
|
||||||
|
|
||||||
|
|||||||
+530
-21
@@ -10,16 +10,33 @@ from flask import (
|
|||||||
)
|
)
|
||||||
from werkzeug.utils import secure_filename
|
from werkzeug.utils import secure_filename
|
||||||
|
|
||||||
from app import db
|
from app import db, csrf, limiter
|
||||||
from app.models import User, Event, Guest, CheckIn
|
from app.models import User, Event, Guest, CheckIn, AuditLog, SmtpSettings
|
||||||
from app.services.qr_service import generate_qr_for_guest
|
from app.services.qr_service import generate_qr_for_guest
|
||||||
from app.services.email_service import send_invitation
|
from app.services.email_service import send_invitation, send_test_email
|
||||||
from app.services.excel_service import import_guests_from_excel, export_report_to_excel
|
from app.services.excel_service import import_guests_from_excel, export_report_to_excel
|
||||||
from app.services.ticket_service import generate_ticket_image, generate_preview_ticket
|
from app.services.ticket_service import generate_ticket_image, generate_preview_ticket
|
||||||
|
from app.services.audit_service import log_action
|
||||||
|
from app.utils.timezone import utc_to_local, local_to_utc
|
||||||
|
from app.utils.paths import event_photo_folder, event_qr_folder, event_logo_folder, event_tickets_folder, ensure_event_folders
|
||||||
|
|
||||||
main_bp = Blueprint("main", __name__)
|
main_bp = Blueprint("main", __name__)
|
||||||
|
|
||||||
|
|
||||||
|
@main_bp.app_template_filter("localtime")
|
||||||
|
def _localtime_filter(dt, fmt="%d.%m.%Y %H:%M"):
|
||||||
|
if not dt:
|
||||||
|
return ""
|
||||||
|
return utc_to_local(dt).strftime(fmt)
|
||||||
|
|
||||||
|
|
||||||
|
@main_bp.app_template_filter("localdate")
|
||||||
|
def _localdate_filter(dt, fmt="%d.%m.%Y"):
|
||||||
|
if not dt:
|
||||||
|
return ""
|
||||||
|
return utc_to_local(dt).strftime(fmt)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------
|
# ---------------------------
|
||||||
# Helpers
|
# Helpers
|
||||||
# ---------------------------
|
# ---------------------------
|
||||||
@@ -30,20 +47,27 @@ def allowed_file(filename, extensions):
|
|||||||
|
|
||||||
def current_user():
|
def current_user():
|
||||||
user_id = session.get("user_id")
|
user_id = session.get("user_id")
|
||||||
|
session_version = session.get("session_version")
|
||||||
if user_id:
|
if user_id:
|
||||||
return User.query.get(user_id)
|
user = User.query.get(user_id)
|
||||||
|
if user and user.is_active and user.session_version == session_version:
|
||||||
|
return user
|
||||||
|
# Сессия устарела или пользователь заблокирован
|
||||||
|
logout_user()
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def login_user(user):
|
def login_user(user):
|
||||||
session["user_id"] = user.id
|
session["user_id"] = user.id
|
||||||
session["role"] = user.role
|
session["role"] = user.role
|
||||||
|
session["session_version"] = user.session_version
|
||||||
session.permanent = True
|
session.permanent = True
|
||||||
|
|
||||||
|
|
||||||
def logout_user():
|
def logout_user():
|
||||||
session.pop("user_id", None)
|
session.pop("user_id", None)
|
||||||
session.pop("role", None)
|
session.pop("role", None)
|
||||||
|
session.pop("session_version", None)
|
||||||
session.pop("current_event_id", None)
|
session.pop("current_event_id", None)
|
||||||
session.pop("checkin_event_id", None)
|
session.pop("checkin_event_id", None)
|
||||||
|
|
||||||
@@ -103,11 +127,28 @@ def available_events_for_user(user):
|
|||||||
return Event.query.filter_by(created_by_id=user.id).order_by(Event.created_at.desc())
|
return Event.query.filter_by(created_by_id=user.id).order_by(Event.created_at.desc())
|
||||||
|
|
||||||
|
|
||||||
|
def _guest_matches_search(guest, query):
|
||||||
|
"""Регистронезависимый поиск по ФИО, отделу, должности, email и телефону."""
|
||||||
|
q = query.casefold()
|
||||||
|
fields = [
|
||||||
|
guest.full_name,
|
||||||
|
guest.last_name,
|
||||||
|
guest.first_name,
|
||||||
|
guest.middle_name,
|
||||||
|
guest.department,
|
||||||
|
guest.position,
|
||||||
|
guest.email,
|
||||||
|
guest.phone,
|
||||||
|
]
|
||||||
|
return any(f and q in f.casefold() for f in fields)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------
|
# ---------------------------
|
||||||
# Auth
|
# Auth
|
||||||
# ---------------------------
|
# ---------------------------
|
||||||
|
|
||||||
@main_bp.route("/login", methods=["GET", "POST"])
|
@main_bp.route("/login", methods=["GET", "POST"])
|
||||||
|
@limiter.limit("10 per minute", methods=["POST"])
|
||||||
def login():
|
def login():
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
username = request.form.get("username", "").strip()
|
username = request.form.get("username", "").strip()
|
||||||
@@ -116,9 +157,11 @@ def login():
|
|||||||
user = User.query.filter_by(username=username, is_active=True).first()
|
user = User.query.filter_by(username=username, is_active=True).first()
|
||||||
if user and user.check_password(password):
|
if user and user.check_password(password):
|
||||||
login_user(user)
|
login_user(user)
|
||||||
|
log_action("login", target_type="user", target_id=user.id, details="Успешный вход")
|
||||||
|
db.session.commit()
|
||||||
if user.is_staff:
|
if user.is_staff:
|
||||||
return redirect(url_for("main.check_in"))
|
return redirect(url_for("main.check_in"))
|
||||||
return redirect(url_for("main.admin_events"))
|
return redirect(url_for("main.admin_panel"))
|
||||||
else:
|
else:
|
||||||
flash("Неверный логин или пароль", "danger")
|
flash("Неверный логин или пароль", "danger")
|
||||||
|
|
||||||
@@ -127,6 +170,8 @@ def login():
|
|||||||
|
|
||||||
@main_bp.route("/logout")
|
@main_bp.route("/logout")
|
||||||
def logout():
|
def logout():
|
||||||
|
log_action("logout")
|
||||||
|
db.session.commit()
|
||||||
logout_user()
|
logout_user()
|
||||||
flash("Вы вышли из системы", "info")
|
flash("Вы вышли из системы", "info")
|
||||||
return redirect(url_for("main.login"))
|
return redirect(url_for("main.login"))
|
||||||
@@ -163,7 +208,7 @@ def admin_event_add():
|
|||||||
event_date = None
|
event_date = None
|
||||||
if event_date_str:
|
if event_date_str:
|
||||||
try:
|
try:
|
||||||
event_date = datetime.fromisoformat(event_date_str)
|
event_date = local_to_utc(datetime.fromisoformat(event_date_str))
|
||||||
except ValueError:
|
except ValueError:
|
||||||
flash("Неверный формат даты", "danger")
|
flash("Неверный формат даты", "danger")
|
||||||
return redirect(url_for("main.admin_event_add"))
|
return redirect(url_for("main.admin_event_add"))
|
||||||
@@ -176,6 +221,7 @@ def admin_event_add():
|
|||||||
created_by_id=current_user().id,
|
created_by_id=current_user().id,
|
||||||
)
|
)
|
||||||
db.session.add(event)
|
db.session.add(event)
|
||||||
|
log_action("event_create", target_type="event", target_id=event.id, details=f"Создано мероприятие '{event.name}'")
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
flash(f"Мероприятие «{event.name}» создано", "success")
|
flash(f"Мероприятие «{event.name}» создано", "success")
|
||||||
return redirect(url_for("main.admin_events"))
|
return redirect(url_for("main.admin_events"))
|
||||||
@@ -204,7 +250,7 @@ def admin_event_edit(event_id):
|
|||||||
|
|
||||||
if event_date_str:
|
if event_date_str:
|
||||||
try:
|
try:
|
||||||
event.event_date = datetime.fromisoformat(event_date_str)
|
event.event_date = local_to_utc(datetime.fromisoformat(event_date_str))
|
||||||
except ValueError:
|
except ValueError:
|
||||||
flash("Неверный формат даты", "danger")
|
flash("Неверный формат даты", "danger")
|
||||||
return redirect(url_for("main.admin_event_edit", event_id=event.id))
|
return redirect(url_for("main.admin_event_edit", event_id=event.id))
|
||||||
@@ -212,6 +258,7 @@ def admin_event_edit(event_id):
|
|||||||
event.event_date = None
|
event.event_date = None
|
||||||
|
|
||||||
event.is_active = bool(request.form.get("is_active"))
|
event.is_active = bool(request.form.get("is_active"))
|
||||||
|
log_action("event_update", target_type="event", target_id=event.id, details=f"Обновлено мероприятие '{event.name}'")
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
flash("Мероприятие обновлено", "success")
|
flash("Мероприятие обновлено", "success")
|
||||||
return redirect(url_for("main.admin_events"))
|
return redirect(url_for("main.admin_events"))
|
||||||
@@ -232,11 +279,57 @@ def admin_event_delete(event_id):
|
|||||||
session.pop("current_event_id", None)
|
session.pop("current_event_id", None)
|
||||||
|
|
||||||
event.is_active = False
|
event.is_active = False
|
||||||
|
log_action("event_deactivate", target_type="event", target_id=event.id, details=f"Деактивировано мероприятие '{event.name}'")
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
flash("Мероприятие деактивировано", "success")
|
flash("Мероприятие деактивировано", "success")
|
||||||
return redirect(url_for("main.admin_events"))
|
return redirect(url_for("main.admin_events"))
|
||||||
|
|
||||||
|
|
||||||
|
@main_bp.route("/admin/events/<int:event_id>/delete-hard", methods=["POST"])
|
||||||
|
@require_role("admin")
|
||||||
|
def admin_event_delete_hard(event_id):
|
||||||
|
"""Полное удаление мероприятия вместе со связанными файлами и данными."""
|
||||||
|
event = Event.query.get_or_404(event_id)
|
||||||
|
reason = request.form.get("reason", "").strip()
|
||||||
|
|
||||||
|
if not reason:
|
||||||
|
flash("Для удаления необходимо указать причину", "danger")
|
||||||
|
return redirect(url_for("main.admin_events"))
|
||||||
|
|
||||||
|
if session.get("current_event_id") == event.id:
|
||||||
|
session.pop("current_event_id", None)
|
||||||
|
|
||||||
|
# Удаляем связанные файлы
|
||||||
|
import shutil
|
||||||
|
|
||||||
|
def _safe_rmtree(path):
|
||||||
|
try:
|
||||||
|
if path.exists():
|
||||||
|
shutil.rmtree(str(path))
|
||||||
|
except Exception as e:
|
||||||
|
current_app.logger.warning(f"Не удалось удалить папку {path}: {e}")
|
||||||
|
|
||||||
|
# Удаляем папки мероприятия целиком
|
||||||
|
_safe_rmtree(event_photo_folder(event.id))
|
||||||
|
_safe_rmtree(event_qr_folder(event.id))
|
||||||
|
_safe_rmtree(event_logo_folder(event.id))
|
||||||
|
_safe_rmtree(event_tickets_folder(event.id))
|
||||||
|
|
||||||
|
event_name = event.name
|
||||||
|
log_action(
|
||||||
|
"event_delete_hard",
|
||||||
|
target_type="event",
|
||||||
|
target_id=event.id,
|
||||||
|
details=f"Удалено мероприятие '{event_name}'. Причина: {reason}",
|
||||||
|
)
|
||||||
|
|
||||||
|
db.session.delete(event)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
flash(f"Мероприятие «{event_name}» и все связанные данные удалены", "success")
|
||||||
|
return redirect(url_for("main.admin_events"))
|
||||||
|
|
||||||
|
|
||||||
@main_bp.route("/admin/events/<int:event_id>/switch", methods=["POST"])
|
@main_bp.route("/admin/events/<int:event_id>/switch", methods=["POST"])
|
||||||
@require_role("admin", "organizer")
|
@require_role("admin", "organizer")
|
||||||
def admin_event_switch(event_id):
|
def admin_event_switch(event_id):
|
||||||
@@ -276,7 +369,7 @@ def admin_event_ticket(event_id):
|
|||||||
if logo_file and logo_file.filename:
|
if logo_file and logo_file.filename:
|
||||||
if allowed_file(logo_file.filename, {"png", "jpg", "jpeg", "gif", "webp", "bmp", "svg"}):
|
if allowed_file(logo_file.filename, {"png", "jpg", "jpeg", "gif", "webp", "bmp", "svg"}):
|
||||||
filename = secure_filename(f"event_{event.id}_{logo_file.filename}")
|
filename = secure_filename(f"event_{event.id}_{logo_file.filename}")
|
||||||
logo_folder = Path(current_app.config["LOGO_FOLDER"])
|
logo_folder = event_logo_folder(event.id)
|
||||||
logo_folder.mkdir(parents=True, exist_ok=True)
|
logo_folder.mkdir(parents=True, exist_ok=True)
|
||||||
logo_path = logo_folder / filename
|
logo_path = logo_folder / filename
|
||||||
logo_file.save(str(logo_path))
|
logo_file.save(str(logo_path))
|
||||||
@@ -286,11 +379,12 @@ def admin_event_ticket(event_id):
|
|||||||
|
|
||||||
# Logo deletion
|
# Logo deletion
|
||||||
if request.form.get("delete_logo") and event.logo_filename:
|
if request.form.get("delete_logo") and event.logo_filename:
|
||||||
old_logo = Path(current_app.config["LOGO_FOLDER"]) / event.logo_filename
|
old_logo = event_logo_folder(event.id) / event.logo_filename
|
||||||
if old_logo.exists():
|
if old_logo.exists():
|
||||||
old_logo.unlink()
|
old_logo.unlink()
|
||||||
event.logo_filename = None
|
event.logo_filename = None
|
||||||
|
|
||||||
|
log_action("event_ticket_update", target_type="event", target_id=event.id, details=f"Обновлён дизайн билета мероприятия '{event.name}'")
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
flash("Дизайн билета сохранён", "success")
|
flash("Дизайн билета сохранён", "success")
|
||||||
return redirect(url_for("main.admin_event_ticket", event_id=event.id))
|
return redirect(url_for("main.admin_event_ticket", event_id=event.id))
|
||||||
@@ -320,6 +414,12 @@ def admin_event_ticket_preview(event_id):
|
|||||||
# ---------------------------
|
# ---------------------------
|
||||||
|
|
||||||
@main_bp.route("/admin")
|
@main_bp.route("/admin")
|
||||||
|
@require_role("admin", "organizer", "staff")
|
||||||
|
def admin_index():
|
||||||
|
return redirect(url_for("main.admin_panel"))
|
||||||
|
|
||||||
|
|
||||||
|
@main_bp.route("/admin/dashboard")
|
||||||
@require_role("admin", "organizer")
|
@require_role("admin", "organizer")
|
||||||
@require_event
|
@require_event
|
||||||
def admin_dashboard():
|
def admin_dashboard():
|
||||||
@@ -377,6 +477,12 @@ def admin_upload():
|
|||||||
guests = Guest.query.filter_by(event_id=event.id).filter(Guest.qr_filename.is_(None)).all()
|
guests = Guest.query.filter_by(event_id=event.id).filter(Guest.qr_filename.is_(None)).all()
|
||||||
for guest in guests:
|
for guest in guests:
|
||||||
generate_qr_for_guest(guest, base_url=base_url)
|
generate_qr_for_guest(guest, base_url=base_url)
|
||||||
|
log_action(
|
||||||
|
"guests_import",
|
||||||
|
target_type="event",
|
||||||
|
target_id=event.id,
|
||||||
|
details=f"Импорт гостей из Excel: добавлено {result['imported']}, обновлено {result['updated']}",
|
||||||
|
)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
flash(f"Сгенерировано QR-кодов: {len(guests)}", "success")
|
flash(f"Сгенерировано QR-кодов: {len(guests)}", "success")
|
||||||
@@ -389,7 +495,8 @@ def admin_upload():
|
|||||||
photo_files = request.files.getlist("photo_files")
|
photo_files = request.files.getlist("photo_files")
|
||||||
zip_file = request.files.get("zip_file")
|
zip_file = request.files.get("zip_file")
|
||||||
|
|
||||||
photo_folder = Path(current_app.config["PHOTO_FOLDER"])
|
photo_folder = event_photo_folder(event.id)
|
||||||
|
photo_folder.mkdir(parents=True, exist_ok=True)
|
||||||
uploaded_count = 0
|
uploaded_count = 0
|
||||||
|
|
||||||
if zip_file and zip_file.filename:
|
if zip_file and zip_file.filename:
|
||||||
@@ -399,8 +506,21 @@ def admin_upload():
|
|||||||
zip_file.save(str(zip_path))
|
zip_file.save(str(zip_path))
|
||||||
try:
|
try:
|
||||||
with zipfile.ZipFile(zip_path, "r") as z:
|
with zipfile.ZipFile(zip_path, "r") as z:
|
||||||
z.extractall(str(photo_folder))
|
extracted = 0
|
||||||
uploaded_count = len([name for name in z.namelist() if not name.endswith("/")])
|
for member in z.infolist():
|
||||||
|
if member.is_dir():
|
||||||
|
continue
|
||||||
|
# Защита от path traversal
|
||||||
|
target = (photo_folder / member.filename).resolve()
|
||||||
|
if photo_folder.resolve() not in target.parents and target != photo_folder.resolve():
|
||||||
|
current_app.logger.warning(f"Пропущен небезопасный путь в ZIP: {member.filename}")
|
||||||
|
continue
|
||||||
|
ext = target.suffix.lower().lstrip(".")
|
||||||
|
if ext not in {"png", "jpg", "jpeg", "gif", "webp", "bmp", "svg"}:
|
||||||
|
continue
|
||||||
|
z.extract(member, str(photo_folder))
|
||||||
|
extracted += 1
|
||||||
|
uploaded_count = extracted
|
||||||
flash(f"Из ZIP распаковано файлов: {uploaded_count}", "success")
|
flash(f"Из ZIP распаковано файлов: {uploaded_count}", "success")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
flash(f"Ошибка распаковки ZIP: {str(e)}", "danger")
|
flash(f"Ошибка распаковки ZIP: {str(e)}", "danger")
|
||||||
@@ -418,6 +538,13 @@ def admin_upload():
|
|||||||
|
|
||||||
if uploaded_count:
|
if uploaded_count:
|
||||||
flash(f"Загружено фото: {uploaded_count}", "success")
|
flash(f"Загружено фото: {uploaded_count}", "success")
|
||||||
|
log_action(
|
||||||
|
"photos_upload",
|
||||||
|
target_type="event",
|
||||||
|
target_id=event.id,
|
||||||
|
details=f"Загружено фото гостей: {uploaded_count}",
|
||||||
|
)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
return redirect(url_for("main.admin_upload"))
|
return redirect(url_for("main.admin_upload"))
|
||||||
|
|
||||||
@@ -434,10 +561,9 @@ def admin_upload():
|
|||||||
def admin_guests():
|
def admin_guests():
|
||||||
event = get_current_event()
|
event = get_current_event()
|
||||||
search = request.args.get("q", "").strip()
|
search = request.args.get("q", "").strip()
|
||||||
query = Guest.query.filter_by(event_id=event.id)
|
guests = Guest.query.filter_by(event_id=event.id).order_by(Guest.full_name).all()
|
||||||
if search:
|
if search:
|
||||||
query = query.filter(Guest.full_name.ilike(f"%{search}%"))
|
guests = [g for g in guests if _guest_matches_search(g, search)]
|
||||||
guests = query.order_by(Guest.full_name).all()
|
|
||||||
return render_template("admin/guests.html", event=event, guests=guests, search=search)
|
return render_template("admin/guests.html", event=event, guests=guests, search=search)
|
||||||
|
|
||||||
|
|
||||||
@@ -474,6 +600,12 @@ def admin_guest_add():
|
|||||||
|
|
||||||
base_url = request.form.get("base_url", "").strip() or None
|
base_url = request.form.get("base_url", "").strip() or None
|
||||||
generate_qr_for_guest(guest, base_url=base_url)
|
generate_qr_for_guest(guest, base_url=base_url)
|
||||||
|
log_action(
|
||||||
|
"guest_create",
|
||||||
|
target_type="guest",
|
||||||
|
target_id=guest.id,
|
||||||
|
details=f"Добавлен гость '{guest.build_full_name()}' ({guest.email})",
|
||||||
|
)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
if request.form.get("send_invitation"):
|
if request.form.get("send_invitation"):
|
||||||
@@ -509,6 +641,12 @@ def admin_guest_edit(guest_id):
|
|||||||
guest.email = request.form.get("email", "").strip()
|
guest.email = request.form.get("email", "").strip()
|
||||||
guest.phone = request.form.get("phone", "").strip()
|
guest.phone = request.form.get("phone", "").strip()
|
||||||
guest.photo_filename = request.form.get("photo_filename", "").strip()
|
guest.photo_filename = request.form.get("photo_filename", "").strip()
|
||||||
|
log_action(
|
||||||
|
"guest_update",
|
||||||
|
target_type="guest",
|
||||||
|
target_id=guest.id,
|
||||||
|
details=f"Обновлены данные гостя '{guest.build_full_name()}'",
|
||||||
|
)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
flash("Данные гостя обновлены", "success")
|
flash("Данные гостя обновлены", "success")
|
||||||
return redirect(url_for("main.admin_guests"))
|
return redirect(url_for("main.admin_guests"))
|
||||||
@@ -523,6 +661,12 @@ def admin_guest_delete(guest_id):
|
|||||||
event = get_current_event()
|
event = get_current_event()
|
||||||
guest = Guest.query.filter_by(id=guest_id, event_id=event.id).first_or_404()
|
guest = Guest.query.filter_by(id=guest_id, event_id=event.id).first_or_404()
|
||||||
guest.is_active = False
|
guest.is_active = False
|
||||||
|
log_action(
|
||||||
|
"guest_deactivate",
|
||||||
|
target_type="guest",
|
||||||
|
target_id=guest.id,
|
||||||
|
details=f"Гость '{guest.build_full_name()}' заблокирован",
|
||||||
|
)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
flash("Гость заблокирован (QR-код аннулирован)", "success")
|
flash("Гость заблокирован (QR-код аннулирован)", "success")
|
||||||
return redirect(url_for("main.admin_guests"))
|
return redirect(url_for("main.admin_guests"))
|
||||||
@@ -539,12 +683,74 @@ def admin_guest_resend(guest_id):
|
|||||||
db.session.commit()
|
db.session.commit()
|
||||||
try:
|
try:
|
||||||
send_invitation(guest)
|
send_invitation(guest)
|
||||||
|
log_action(
|
||||||
|
"guest_resend",
|
||||||
|
target_type="guest",
|
||||||
|
target_id=guest.id,
|
||||||
|
details=f"Приглашение повторно отправлено гостю '{guest.build_full_name()}'",
|
||||||
|
)
|
||||||
|
db.session.commit()
|
||||||
flash("Приглашение повторно отправлено", "success")
|
flash("Приглашение повторно отправлено", "success")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
log_action(
|
||||||
|
"guest_resend",
|
||||||
|
target_type="guest",
|
||||||
|
target_id=guest.id,
|
||||||
|
details=f"Ошибка повторной отправки приглашения гостю '{guest.build_full_name()}': {e}",
|
||||||
|
)
|
||||||
|
db.session.commit()
|
||||||
flash(f"Ошибка отправки: {str(e)}", "danger")
|
flash(f"Ошибка отправки: {str(e)}", "danger")
|
||||||
return redirect(url_for("main.admin_guests"))
|
return redirect(url_for("main.admin_guests"))
|
||||||
|
|
||||||
|
|
||||||
|
@main_bp.route("/admin/guests/<int:guest_id>/ticket")
|
||||||
|
@require_role("admin", "organizer")
|
||||||
|
@require_event
|
||||||
|
def admin_guest_ticket(guest_id):
|
||||||
|
"""Генерирует и отдаёт PNG-билет для конкретного гостя."""
|
||||||
|
event = get_current_event()
|
||||||
|
guest = Guest.query.filter_by(id=guest_id, event_id=event.id).first_or_404()
|
||||||
|
try:
|
||||||
|
ticket_path = generate_ticket_image(guest, event)
|
||||||
|
log_action(
|
||||||
|
"guest_ticket_view",
|
||||||
|
target_type="guest",
|
||||||
|
target_id=guest.id,
|
||||||
|
details=f"Просмотр билета гостя '{guest.build_full_name()}'",
|
||||||
|
)
|
||||||
|
db.session.commit()
|
||||||
|
return send_file(str(ticket_path), mimetype="image/png")
|
||||||
|
except Exception as e:
|
||||||
|
flash(f"Ошибка генерации билета: {str(e)}", "danger")
|
||||||
|
return redirect(url_for("main.admin_guests"))
|
||||||
|
|
||||||
|
|
||||||
|
@main_bp.route("/admin/guests/<int:guest_id>/qr")
|
||||||
|
@require_role("admin", "organizer")
|
||||||
|
@require_event
|
||||||
|
def admin_guest_qr(guest_id):
|
||||||
|
"""Отдаёт QR-код гостя без отправки письма."""
|
||||||
|
event = get_current_event()
|
||||||
|
guest = Guest.query.filter_by(id=guest_id, event_id=event.id).first_or_404()
|
||||||
|
if not guest.qr_filename:
|
||||||
|
generate_qr_for_guest(guest)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
qr_path = event_qr_folder(guest.event_id) / guest.qr_filename
|
||||||
|
if not qr_path.exists():
|
||||||
|
flash("QR-код не найден", "danger")
|
||||||
|
return redirect(url_for("main.admin_guests"))
|
||||||
|
|
||||||
|
log_action(
|
||||||
|
"guest_qr_view",
|
||||||
|
target_type="guest",
|
||||||
|
target_id=guest.id,
|
||||||
|
details=f"Просмотр QR-кода гостя '{guest.build_full_name()}'",
|
||||||
|
)
|
||||||
|
db.session.commit()
|
||||||
|
return send_file(str(qr_path), mimetype="image/png")
|
||||||
|
|
||||||
|
|
||||||
@main_bp.route("/admin/send-invitations", methods=["POST"])
|
@main_bp.route("/admin/send-invitations", methods=["POST"])
|
||||||
@require_role("admin", "organizer")
|
@require_role("admin", "organizer")
|
||||||
@require_event
|
@require_event
|
||||||
@@ -561,6 +767,12 @@ def admin_send_invitations():
|
|||||||
sent += 1
|
sent += 1
|
||||||
except Exception:
|
except Exception:
|
||||||
failed += 1
|
failed += 1
|
||||||
|
log_action(
|
||||||
|
"event_send_invitations",
|
||||||
|
target_type="event",
|
||||||
|
target_id=event.id,
|
||||||
|
details=f"Рассылка приглашений: отправлено {sent}, ошибок {failed}",
|
||||||
|
)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
flash(f"Отправлено приглашений: {sent}, ошибок: {failed}", "success" if failed == 0 else "warning")
|
flash(f"Отправлено приглашений: {sent}, ошибок: {failed}", "success" if failed == 0 else "warning")
|
||||||
return redirect(url_for("main.admin_dashboard"))
|
return redirect(url_for("main.admin_dashboard"))
|
||||||
@@ -582,6 +794,13 @@ def admin_report():
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
export_report_to_excel(str(output_path), event_id=event.id, event_name=event.name)
|
export_report_to_excel(str(output_path), event_id=event.id, event_name=event.name)
|
||||||
|
log_action(
|
||||||
|
"event_report",
|
||||||
|
target_type="event",
|
||||||
|
target_id=event.id,
|
||||||
|
details=f"Сформирован отчёт по мероприятию '{event.name}'",
|
||||||
|
)
|
||||||
|
db.session.commit()
|
||||||
return send_file(str(output_path), as_attachment=True, download_name=filename)
|
return send_file(str(output_path), as_attachment=True, download_name=filename)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
flash(f"Ошибка формирования отчёта: {str(e)}", "danger")
|
flash(f"Ошибка формирования отчёта: {str(e)}", "danger")
|
||||||
@@ -622,6 +841,7 @@ def admin_user_add():
|
|||||||
user = User(username=username, role=role, is_active=True)
|
user = User(username=username, role=role, is_active=True)
|
||||||
user.set_password(password)
|
user.set_password(password)
|
||||||
db.session.add(user)
|
db.session.add(user)
|
||||||
|
log_action("user_create", target_type="user", target_id=user.id, details=f"Создан пользователь '{username}' ({role})")
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
flash(f"Пользователь {username} создан", "success")
|
flash(f"Пользователь {username} создан", "success")
|
||||||
return redirect(url_for("main.admin_users"))
|
return redirect(url_for("main.admin_users"))
|
||||||
@@ -657,6 +877,7 @@ def admin_user_edit(user_id):
|
|||||||
user.is_active = is_active
|
user.is_active = is_active
|
||||||
if password:
|
if password:
|
||||||
user.set_password(password)
|
user.set_password(password)
|
||||||
|
log_action("user_update", target_type="user", target_id=user.id, details=f"Обновлён пользователь '{username}' ({role})")
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
flash("Пользователь обновлён", "success")
|
flash("Пользователь обновлён", "success")
|
||||||
return redirect(url_for("main.admin_users"))
|
return redirect(url_for("main.admin_users"))
|
||||||
@@ -673,11 +894,275 @@ def admin_user_delete(user_id):
|
|||||||
return redirect(url_for("main.admin_users"))
|
return redirect(url_for("main.admin_users"))
|
||||||
|
|
||||||
user.is_active = False
|
user.is_active = False
|
||||||
|
log_action("user_deactivate", target_type="user", target_id=user.id, details=f"Пользователь '{user.username}' заблокирован")
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
flash("Пользователь заблокирован", "success")
|
flash("Пользователь заблокирован", "success")
|
||||||
return redirect(url_for("main.admin_users"))
|
return redirect(url_for("main.admin_users"))
|
||||||
|
|
||||||
|
|
||||||
|
@main_bp.route("/admin/logs")
|
||||||
|
@require_role("admin")
|
||||||
|
def admin_logs():
|
||||||
|
page = request.args.get("page", 1, type=int)
|
||||||
|
per_page = 50
|
||||||
|
action_filter = request.args.get("action", "").strip()
|
||||||
|
user_filter = request.args.get("user", "").strip()
|
||||||
|
|
||||||
|
query = AuditLog.query
|
||||||
|
if action_filter:
|
||||||
|
query = query.filter(AuditLog.action == action_filter)
|
||||||
|
if user_filter:
|
||||||
|
query = query.filter(AuditLog.username.ilike(f"%{user_filter}%"))
|
||||||
|
|
||||||
|
logs = query.order_by(AuditLog.created_at.desc()).paginate(
|
||||||
|
page=page, per_page=per_page, error_out=False
|
||||||
|
)
|
||||||
|
actions = db.session.query(AuditLog.action).distinct().order_by(AuditLog.action).all()
|
||||||
|
actions = [a[0] for a in actions]
|
||||||
|
|
||||||
|
return render_template(
|
||||||
|
"admin/logs.html",
|
||||||
|
logs=logs,
|
||||||
|
actions=actions,
|
||||||
|
action_filter=action_filter,
|
||||||
|
user_filter=user_filter,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------
|
||||||
|
# Admin panel
|
||||||
|
# ---------------------------
|
||||||
|
|
||||||
|
@main_bp.route("/admin/panel")
|
||||||
|
@require_role("admin", "organizer", "staff")
|
||||||
|
def admin_panel():
|
||||||
|
"""Единая админ-панель со ссылками на все разделы."""
|
||||||
|
user = current_user()
|
||||||
|
event = get_current_event() if user and (user.is_admin or user.is_organizer) else None
|
||||||
|
|
||||||
|
cards = []
|
||||||
|
if user and user.role in ("admin", "organizer"):
|
||||||
|
cards.extend([
|
||||||
|
{
|
||||||
|
"title": "Мероприятия",
|
||||||
|
"description": "Создание и управление мероприятиями",
|
||||||
|
"url": url_for("main.admin_events"),
|
||||||
|
"icon": "📅",
|
||||||
|
"btn": "Перейти",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Гости",
|
||||||
|
"description": "Список гостей, поиск, редактирование",
|
||||||
|
"url": url_for("main.admin_guests") if event else url_for("main.admin_events"),
|
||||||
|
"icon": "👥",
|
||||||
|
"btn": "Перейти",
|
||||||
|
"disabled": not event,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Загрузка данных",
|
||||||
|
"description": "Импорт гостей из Excel и загрузка фото",
|
||||||
|
"url": url_for("main.admin_upload") if event else url_for("main.admin_events"),
|
||||||
|
"icon": "📤",
|
||||||
|
"btn": "Перейти",
|
||||||
|
"disabled": not event,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Рассылка приглашений",
|
||||||
|
"description": "Настройка почты и массовая отправка",
|
||||||
|
"url": url_for("main.admin_email_send"),
|
||||||
|
"icon": "✉️",
|
||||||
|
"btn": "Перейти",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Конструктор билета",
|
||||||
|
"description": "Дизайн, логотип и предпросмотр билета",
|
||||||
|
"url": url_for("main.admin_event_ticket", event_id=event.id) if event else url_for("main.admin_events"),
|
||||||
|
"icon": "🎫",
|
||||||
|
"btn": "Перейти",
|
||||||
|
"disabled": not event,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Аналитика",
|
||||||
|
"description": "Посещаемость и недавние входы",
|
||||||
|
"url": url_for("main.admin_dashboard") if event else url_for("main.admin_events"),
|
||||||
|
"icon": "📊",
|
||||||
|
"btn": "Перейти",
|
||||||
|
"disabled": not event,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
if user and user.is_admin:
|
||||||
|
cards.extend([
|
||||||
|
{
|
||||||
|
"title": "Пользователи",
|
||||||
|
"description": "Управление администраторами и стаффом",
|
||||||
|
"url": url_for("main.admin_users"),
|
||||||
|
"icon": "🔐",
|
||||||
|
"btn": "Перейти",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Логи",
|
||||||
|
"description": "История действий пользователей",
|
||||||
|
"url": url_for("main.admin_logs"),
|
||||||
|
"icon": "📝",
|
||||||
|
"btn": "Перейти",
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
cards.append({
|
||||||
|
"title": "Проверка приглашения",
|
||||||
|
"description": "Сканирование QR-кода и отметка входа",
|
||||||
|
"url": url_for("main.check_in"),
|
||||||
|
"icon": "📷",
|
||||||
|
"btn": "Перейти",
|
||||||
|
})
|
||||||
|
|
||||||
|
return render_template("admin/panel.html", cards=cards)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------
|
||||||
|
# Email / invitations
|
||||||
|
# ---------------------------
|
||||||
|
|
||||||
|
@main_bp.route("/admin/email/settings", methods=["GET", "POST"])
|
||||||
|
@require_role("admin")
|
||||||
|
def admin_email_settings():
|
||||||
|
settings = SmtpSettings.get_settings()
|
||||||
|
|
||||||
|
if request.method == "POST":
|
||||||
|
mode = request.form.get("mail_mode", "smtp").strip()
|
||||||
|
settings.use_local_outlook = mode == "outlook"
|
||||||
|
settings.use_ews = mode == "ews"
|
||||||
|
|
||||||
|
if mode == "smtp":
|
||||||
|
settings.mail_server = request.form.get("mail_server", "smtp.yandex.ru").strip()
|
||||||
|
try:
|
||||||
|
settings.mail_port = int(request.form.get("mail_port", "465").strip())
|
||||||
|
except ValueError:
|
||||||
|
settings.mail_port = 465
|
||||||
|
settings.mail_use_ssl = bool(request.form.get("mail_use_ssl"))
|
||||||
|
settings.mail_username = request.form.get("mail_username", "").strip()
|
||||||
|
settings.mail_default_sender = request.form.get("mail_default_sender", "").strip()
|
||||||
|
|
||||||
|
password = request.form.get("mail_password", "").strip()
|
||||||
|
if password:
|
||||||
|
settings.mail_password = password
|
||||||
|
|
||||||
|
elif mode == "ews":
|
||||||
|
settings.ews_url = request.form.get("ews_url", "").strip()
|
||||||
|
settings.mail_username = request.form.get("mail_username", "").strip()
|
||||||
|
settings.mail_default_sender = request.form.get("mail_default_sender", "").strip()
|
||||||
|
|
||||||
|
password = request.form.get("mail_password", "").strip()
|
||||||
|
if password:
|
||||||
|
settings.mail_password = password
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
SmtpSettings.apply_to_app(current_app)
|
||||||
|
|
||||||
|
mode_label = {"smtp": "SMTP", "ews": "EWS", "outlook": "Outlook"}.get(mode, mode)
|
||||||
|
log_action(
|
||||||
|
"email_settings_update",
|
||||||
|
target_type="smtp_settings",
|
||||||
|
target_id=settings.id,
|
||||||
|
details=f"Обновлены почтовые настройки ({mode_label})",
|
||||||
|
)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
flash("Настройки почты сохранены", "success")
|
||||||
|
return redirect(url_for("main.admin_email_settings"))
|
||||||
|
|
||||||
|
return render_template("admin/email_settings.html", settings=settings)
|
||||||
|
|
||||||
|
|
||||||
|
@main_bp.route("/admin/email/test", methods=["POST"])
|
||||||
|
@require_role("admin")
|
||||||
|
def admin_email_test():
|
||||||
|
test_address = request.form.get("test_address", "").strip()
|
||||||
|
if not test_address:
|
||||||
|
flash("Укажите адрес для тестового письма", "danger")
|
||||||
|
return redirect(url_for("main.admin_email_settings"))
|
||||||
|
|
||||||
|
try:
|
||||||
|
send_test_email(test_address)
|
||||||
|
log_action(
|
||||||
|
"email_test_send",
|
||||||
|
target_type="smtp_settings",
|
||||||
|
details=f"Тестовое письмо отправлено на {test_address}",
|
||||||
|
)
|
||||||
|
db.session.commit()
|
||||||
|
flash(f"Тестовое письмо отправлено на {test_address}", "success")
|
||||||
|
except Exception as e:
|
||||||
|
log_action(
|
||||||
|
"email_test_send",
|
||||||
|
target_type="smtp_settings",
|
||||||
|
details=f"Ошибка отправки тестового письма на {test_address}: {e}",
|
||||||
|
)
|
||||||
|
db.session.commit()
|
||||||
|
flash(f"Ошибка отправки: {str(e)}", "danger")
|
||||||
|
|
||||||
|
return redirect(url_for("main.admin_email_settings"))
|
||||||
|
|
||||||
|
|
||||||
|
@main_bp.route("/admin/email/send", methods=["GET", "POST"])
|
||||||
|
@require_role("admin", "organizer")
|
||||||
|
def admin_email_send():
|
||||||
|
user = current_user()
|
||||||
|
events = available_events_for_user(user).all()
|
||||||
|
|
||||||
|
if request.method == "POST":
|
||||||
|
event_id = request.form.get("event_id", "").strip()
|
||||||
|
if not event_id:
|
||||||
|
flash("Выберите мероприятие", "danger")
|
||||||
|
return redirect(url_for("main.admin_email_send"))
|
||||||
|
|
||||||
|
event = Event.query.get_or_404(int(event_id))
|
||||||
|
if not user.can_manage_event(event):
|
||||||
|
flash("Нет доступа к этому мероприятию", "danger")
|
||||||
|
return redirect(url_for("main.admin_email_send"))
|
||||||
|
|
||||||
|
guests = Guest.query.filter_by(
|
||||||
|
event_id=event.id, is_active=True, invitation_sent=False
|
||||||
|
).all()
|
||||||
|
|
||||||
|
if not guests:
|
||||||
|
flash("Нет неотправленных приглашений для выбранного мероприятия", "info")
|
||||||
|
return redirect(url_for("main.admin_email_send"))
|
||||||
|
|
||||||
|
sent = 0
|
||||||
|
failed = 0
|
||||||
|
for guest in guests:
|
||||||
|
if not guest.qr_filename:
|
||||||
|
generate_qr_for_guest(guest)
|
||||||
|
try:
|
||||||
|
send_invitation(guest)
|
||||||
|
sent += 1
|
||||||
|
db.session.commit()
|
||||||
|
except Exception:
|
||||||
|
failed += 1
|
||||||
|
db.session.rollback()
|
||||||
|
|
||||||
|
log_action(
|
||||||
|
"event_send_invitations",
|
||||||
|
target_type="event",
|
||||||
|
target_id=event.id,
|
||||||
|
details=f"Рассылка приглашений из панели: отправлено {sent}, ошибок {failed}",
|
||||||
|
)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
flash(f"Отправлено приглашений: {sent}, ошибок: {failed}", "success" if failed == 0 else "warning")
|
||||||
|
return redirect(url_for("main.admin_email_send"))
|
||||||
|
|
||||||
|
event_counts = []
|
||||||
|
for event in events:
|
||||||
|
unsent = Guest.query.filter_by(
|
||||||
|
event_id=event.id, is_active=True, invitation_sent=False
|
||||||
|
).count()
|
||||||
|
event_counts.append({"event": event, "unsent": unsent})
|
||||||
|
|
||||||
|
return render_template("admin/email_send.html", event_counts=event_counts)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------
|
# ---------------------------
|
||||||
# Check-in
|
# Check-in
|
||||||
# ---------------------------
|
# ---------------------------
|
||||||
@@ -730,6 +1215,7 @@ def check_in():
|
|||||||
|
|
||||||
@main_bp.route("/api/guests/search")
|
@main_bp.route("/api/guests/search")
|
||||||
@require_role("admin", "organizer", "staff")
|
@require_role("admin", "organizer", "staff")
|
||||||
|
@csrf.exempt
|
||||||
def api_guests_search():
|
def api_guests_search():
|
||||||
q = request.args.get("q", "").strip()
|
q = request.args.get("q", "").strip()
|
||||||
event_id = request.args.get("event_id") or session.get("checkin_event_id")
|
event_id = request.args.get("event_id") or session.get("checkin_event_id")
|
||||||
@@ -744,13 +1230,28 @@ def api_guests_search():
|
|||||||
except ValueError:
|
except ValueError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
guests = (
|
# SQL-фильтр (регистронезависимый для латиницы) + fallback:
|
||||||
|
# если SQLite не нашёл по кириллице, берём всех гостей мероприятия
|
||||||
|
# и фильтруем в Python через casefold().
|
||||||
|
sql_like = f"%{q}%"
|
||||||
|
candidates = (
|
||||||
query
|
query
|
||||||
.filter(Guest.full_name.ilike(f"%{q}%"))
|
.filter(
|
||||||
|
(Guest.full_name.ilike(sql_like)) |
|
||||||
|
(Guest.department.ilike(sql_like)) |
|
||||||
|
(Guest.email.ilike(sql_like))
|
||||||
|
)
|
||||||
.order_by(Guest.full_name)
|
.order_by(Guest.full_name)
|
||||||
.limit(20)
|
.limit(100)
|
||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
|
guests = [g for g in candidates if _guest_matches_search(g, q)]
|
||||||
|
|
||||||
|
if not guests and event_id:
|
||||||
|
all_event_guests = Guest.query.filter_by(
|
||||||
|
is_active=True, event_id=int(event_id)
|
||||||
|
).order_by(Guest.full_name).all()
|
||||||
|
guests = [g for g in all_event_guests if _guest_matches_search(g, q)][:20]
|
||||||
|
|
||||||
return jsonify([
|
return jsonify([
|
||||||
{
|
{
|
||||||
@@ -760,7 +1261,7 @@ def api_guests_search():
|
|||||||
"position": g.position,
|
"position": g.position,
|
||||||
"email": g.email,
|
"email": g.email,
|
||||||
"phone": g.phone,
|
"phone": g.phone,
|
||||||
"photo_url": url_for("static", filename=f"uploads/photos/{g.photo_filename}") if g.photo_filename else "",
|
"photo_url": g.photo_url,
|
||||||
"token": g.token,
|
"token": g.token,
|
||||||
"checked_in": g.is_checked_in,
|
"checked_in": g.is_checked_in,
|
||||||
"event_id": g.event_id,
|
"event_id": g.event_id,
|
||||||
@@ -772,6 +1273,7 @@ def api_guests_search():
|
|||||||
|
|
||||||
@main_bp.route("/api/check-in/<token>", methods=["POST"])
|
@main_bp.route("/api/check-in/<token>", methods=["POST"])
|
||||||
@require_role("admin", "organizer", "staff")
|
@require_role("admin", "organizer", "staff")
|
||||||
|
@csrf.exempt
|
||||||
def api_check_in(token):
|
def api_check_in(token):
|
||||||
guest = Guest.query.filter_by(token=token, is_active=True).first()
|
guest = Guest.query.filter_by(token=token, is_active=True).first()
|
||||||
if not guest:
|
if not guest:
|
||||||
@@ -791,6 +1293,12 @@ def api_check_in(token):
|
|||||||
device_info=request.headers.get("User-Agent", "")[:500],
|
device_info=request.headers.get("User-Agent", "")[:500],
|
||||||
)
|
)
|
||||||
db.session.add(check_in_record)
|
db.session.add(check_in_record)
|
||||||
|
log_action(
|
||||||
|
"check_in",
|
||||||
|
target_type="guest",
|
||||||
|
target_id=guest.id,
|
||||||
|
details=f"Отмечен вход гостя '{guest.build_full_name()}' на мероприятие '{guest.event.name if guest.event else ''}'",
|
||||||
|
)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
@@ -808,6 +1316,7 @@ def api_check_in(token):
|
|||||||
|
|
||||||
@main_bp.route("/api/guest/<token>")
|
@main_bp.route("/api/guest/<token>")
|
||||||
@require_role("admin", "organizer", "staff")
|
@require_role("admin", "organizer", "staff")
|
||||||
|
@csrf.exempt
|
||||||
def api_guest_detail(token):
|
def api_guest_detail(token):
|
||||||
guest = Guest.query.filter_by(token=token, is_active=True).first_or_404()
|
guest = Guest.query.filter_by(token=token, is_active=True).first_or_404()
|
||||||
return jsonify({
|
return jsonify({
|
||||||
@@ -817,7 +1326,7 @@ def api_guest_detail(token):
|
|||||||
"position": guest.position,
|
"position": guest.position,
|
||||||
"email": guest.email,
|
"email": guest.email,
|
||||||
"phone": guest.phone,
|
"phone": guest.phone,
|
||||||
"photo_url": url_for("static", filename=f"uploads/photos/{guest.photo_filename}") if guest.photo_filename else "",
|
"photo_url": guest.photo_url,
|
||||||
"token": guest.token,
|
"token": guest.token,
|
||||||
"checked_in": guest.is_checked_in,
|
"checked_in": guest.is_checked_in,
|
||||||
"checked_in_at": guest.last_check_in.isoformat() if guest.last_check_in else None,
|
"checked_in_at": guest.last_check_in.isoformat() if guest.last_check_in else None,
|
||||||
@@ -837,7 +1346,7 @@ def index():
|
|||||||
return redirect(url_for("main.login"))
|
return redirect(url_for("main.login"))
|
||||||
if user.is_staff:
|
if user.is_staff:
|
||||||
return redirect(url_for("main.check_in"))
|
return redirect(url_for("main.check_in"))
|
||||||
return redirect(url_for("main.admin_events"))
|
return redirect(url_for("main.admin_panel"))
|
||||||
|
|
||||||
|
|
||||||
@main_bp.app_context_processor
|
@main_bp.app_context_processor
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
from flask import request
|
||||||
|
|
||||||
|
from app import db
|
||||||
|
from app.models import AuditLog
|
||||||
|
|
||||||
|
|
||||||
|
def log_action(action, target_type=None, target_id=None, details=None):
|
||||||
|
"""Записывает действие пользователя в аудит-лог.
|
||||||
|
|
||||||
|
Аргументы:
|
||||||
|
action: короткий код действия, например "guest_create", "event_delete".
|
||||||
|
target_type: тип объекта (event, guest, user, ...).
|
||||||
|
target_id: идентификатор объекта (может быть строкой).
|
||||||
|
details: произвольное текстовое описание или причина.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from app.routes import current_user
|
||||||
|
|
||||||
|
user = current_user()
|
||||||
|
user_id = user.id if user else None
|
||||||
|
username = user.username if user else None
|
||||||
|
except Exception:
|
||||||
|
user_id = None
|
||||||
|
username = None
|
||||||
|
|
||||||
|
ip_address = None
|
||||||
|
try:
|
||||||
|
if request:
|
||||||
|
# Учитываем прокси
|
||||||
|
ip_address = request.headers.get("X-Forwarded-For", request.remote_addr)
|
||||||
|
if ip_address:
|
||||||
|
ip_address = ip_address.split(",")[0].strip()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
record = AuditLog(
|
||||||
|
user_id=user_id,
|
||||||
|
username=username,
|
||||||
|
action=action,
|
||||||
|
target_type=target_type,
|
||||||
|
target_id=str(target_id) if target_id is not None else None,
|
||||||
|
details=details,
|
||||||
|
ip_address=ip_address,
|
||||||
|
)
|
||||||
|
db.session.add(record)
|
||||||
|
# Не делаем commit здесь, чтобы не ломать транзакции вызывающего кода.
|
||||||
|
# Вызывающий код сам делает db.session.commit() после основных изменений.
|
||||||
+175
-51
@@ -7,65 +7,44 @@ from pathlib import Path
|
|||||||
from flask import current_app, render_template
|
from flask import current_app, render_template
|
||||||
|
|
||||||
from app.services.ticket_service import generate_ticket_image
|
from app.services.ticket_service import generate_ticket_image
|
||||||
|
from app.utils.paths import event_qr_folder
|
||||||
|
|
||||||
|
|
||||||
def send_invitation(guest, event_name=None, additional_text=""):
|
OUTLOOK_CONTENT_ID_PROP = "http://schemas.microsoft.com/mapi/proptag/0x3712001F"
|
||||||
"""Отправляет приглашение гостю на email с QR-кодом и билетом во вложении."""
|
|
||||||
|
|
||||||
|
def _send_smtp_email(to_address, subject, html_body, sender_name, attachments):
|
||||||
|
"""Отправляет письмо через SMTP с inline-вложениями."""
|
||||||
config = current_app.config
|
config = current_app.config
|
||||||
|
|
||||||
if event_name is None:
|
server = config.get("MAIL_SERVER", "")
|
||||||
event_name = guest.event.name if guest.event else "Мероприятие"
|
port = config.get("MAIL_PORT", 465)
|
||||||
|
username = config.get("MAIL_USERNAME", "")
|
||||||
msg = MIMEMultipart("related")
|
password = config.get("MAIL_PASSWORD", "")
|
||||||
msg["Subject"] = f"Приглашение на мероприятие: {event_name}"
|
|
||||||
msg["From"] = formataddr(("InviteMe", config.get("MAIL_DEFAULT_SENDER", config["MAIL_USERNAME"])))
|
|
||||||
msg["To"] = guest.email
|
|
||||||
|
|
||||||
# Генерируем билет
|
|
||||||
try:
|
|
||||||
ticket_path = generate_ticket_image(guest, guest.event)
|
|
||||||
except Exception as e:
|
|
||||||
ticket_path = None
|
|
||||||
current_app.logger.warning(f"Не удалось сгенерировать билет для {guest.email}: {e}")
|
|
||||||
|
|
||||||
# HTML-шаблон письма
|
|
||||||
html_body = render_template(
|
|
||||||
"email/invitation.html",
|
|
||||||
guest=guest,
|
|
||||||
event_name=event_name,
|
|
||||||
additional_text=additional_text,
|
|
||||||
qr_cid="qr_code_image",
|
|
||||||
ticket_cid="ticket_image" if ticket_path else None,
|
|
||||||
)
|
|
||||||
|
|
||||||
msg.attach(MIMEText(html_body, "html", "utf-8"))
|
|
||||||
|
|
||||||
# Прикрепляем QR-код
|
|
||||||
qr_path = Path(config["QR_FOLDER"]) / guest.qr_filename
|
|
||||||
if qr_path.exists():
|
|
||||||
with open(qr_path, "rb") as img_file:
|
|
||||||
img = MIMEImage(img_file.read())
|
|
||||||
img.add_header("Content-ID", "<qr_code_image>")
|
|
||||||
img.add_header("Content-Disposition", "inline", filename=guest.qr_filename)
|
|
||||||
msg.attach(img)
|
|
||||||
|
|
||||||
# Прикрепляем билет
|
|
||||||
if ticket_path and ticket_path.exists():
|
|
||||||
with open(ticket_path, "rb") as img_file:
|
|
||||||
img = MIMEImage(img_file.read())
|
|
||||||
img.add_header("Content-ID", "<ticket_image>")
|
|
||||||
img.add_header("Content-Disposition", "inline", filename=f"ticket_{guest.token}.png")
|
|
||||||
msg.attach(img)
|
|
||||||
|
|
||||||
# Отправка через SMTP
|
|
||||||
server = config["MAIL_SERVER"]
|
|
||||||
port = config["MAIL_PORT"]
|
|
||||||
username = config["MAIL_USERNAME"]
|
|
||||||
password = config["MAIL_PASSWORD"]
|
|
||||||
use_ssl = config.get("MAIL_USE_SSL", True)
|
use_ssl = config.get("MAIL_USE_SSL", True)
|
||||||
|
sender = config.get("MAIL_DEFAULT_SENDER") or username
|
||||||
|
|
||||||
if not username or not password:
|
if not username or not password:
|
||||||
raise ValueError("MAIL_USERNAME и MAIL_PASSWORD должны быть указаны в конфигурации")
|
raise ValueError("MAIL_USERNAME и MAIL_PASSWORD должны быть указаны в конфигурации")
|
||||||
|
if not server or not port:
|
||||||
|
raise ValueError("MAIL_SERVER и MAIL_PORT должны быть указаны в конфигурации")
|
||||||
|
|
||||||
|
msg = MIMEMultipart("related")
|
||||||
|
msg["Subject"] = subject
|
||||||
|
msg["From"] = formataddr((sender_name, sender))
|
||||||
|
msg["To"] = to_address
|
||||||
|
msg.attach(MIMEText(html_body, "html", "utf-8"))
|
||||||
|
|
||||||
|
for att in attachments:
|
||||||
|
path = att["path"]
|
||||||
|
cid = att.get("cid")
|
||||||
|
filename = att.get("filename", path.name)
|
||||||
|
with open(path, "rb") as img_file:
|
||||||
|
img = MIMEImage(img_file.read())
|
||||||
|
if cid:
|
||||||
|
img.add_header("Content-ID", f"<{cid}>")
|
||||||
|
img.add_header("Content-Disposition", "inline", filename=filename)
|
||||||
|
msg.attach(img)
|
||||||
|
|
||||||
if use_ssl:
|
if use_ssl:
|
||||||
with smtplib.SMTP_SSL(server, port) as smtp:
|
with smtplib.SMTP_SSL(server, port) as smtp:
|
||||||
@@ -77,5 +56,150 @@ def send_invitation(guest, event_name=None, additional_text=""):
|
|||||||
smtp.login(username, password)
|
smtp.login(username, password)
|
||||||
smtp.send_message(msg)
|
smtp.send_message(msg)
|
||||||
|
|
||||||
|
|
||||||
|
def _send_outlook_email(to_address, subject, html_body, sender_name, attachments):
|
||||||
|
"""Отправляет письмо через локальный Outlook (MAPI/COM)."""
|
||||||
|
try:
|
||||||
|
import win32com.client
|
||||||
|
except ImportError as e:
|
||||||
|
raise RuntimeError("Для отправки через Outlook необходима библиотека pywin32") from e
|
||||||
|
|
||||||
|
try:
|
||||||
|
outlook = win32com.client.Dispatch("Outlook.Application")
|
||||||
|
except Exception as e:
|
||||||
|
raise RuntimeError("Не удалось подключиться к Outlook. Убедитесь, что Outlook установлен и запущен.") from e
|
||||||
|
|
||||||
|
mail = outlook.CreateItem(0) # olMailItem
|
||||||
|
mail.To = to_address
|
||||||
|
mail.Subject = subject
|
||||||
|
mail.HTMLBody = html_body
|
||||||
|
|
||||||
|
for att in attachments:
|
||||||
|
path = att["path"]
|
||||||
|
cid = att.get("cid")
|
||||||
|
filename = att.get("filename", path.name)
|
||||||
|
# 1 = olByValue, 0 = позиция (inline)
|
||||||
|
attachment = mail.Attachments.Add(str(path), 1, 0, filename)
|
||||||
|
if cid:
|
||||||
|
attachment.PropertyAccessor.SetProperty(OUTLOOK_CONTENT_ID_PROP, cid)
|
||||||
|
|
||||||
|
mail.Send()
|
||||||
|
|
||||||
|
|
||||||
|
def _send_ews_email(to_address, subject, html_body, sender_name, attachments):
|
||||||
|
"""Отправляет письмо через Exchange Web Services (EWS)."""
|
||||||
|
try:
|
||||||
|
from exchangelib import Credentials, Configuration, Account, Message, Mailbox, HTMLBody, FileAttachment
|
||||||
|
except ImportError as e:
|
||||||
|
raise RuntimeError("Для отправки через EWS необходима библиотека exchangelib") from e
|
||||||
|
|
||||||
|
config = current_app.config
|
||||||
|
ews_url = config.get("EWS_URL", "")
|
||||||
|
username = config.get("MAIL_USERNAME", "")
|
||||||
|
password = config.get("MAIL_PASSWORD", "")
|
||||||
|
|
||||||
|
if not ews_url or not username or not password:
|
||||||
|
raise ValueError("Для EWS должны быть указаны EWS URL, логин и пароль")
|
||||||
|
|
||||||
|
try:
|
||||||
|
credentials = Credentials(username=username, password=password)
|
||||||
|
ews_config = Configuration(service_endpoint=ews_url, credentials=credentials)
|
||||||
|
account = Account(
|
||||||
|
primary_smtp_address=username,
|
||||||
|
config=ews_config,
|
||||||
|
autodiscover=False,
|
||||||
|
access_type="delegate",
|
||||||
|
)
|
||||||
|
|
||||||
|
msg = Message(
|
||||||
|
account=account,
|
||||||
|
subject=subject,
|
||||||
|
body=HTMLBody(html_body),
|
||||||
|
to_recipients=[Mailbox(email_address=to_address)],
|
||||||
|
)
|
||||||
|
|
||||||
|
for att in attachments:
|
||||||
|
path = att["path"]
|
||||||
|
filename = att.get("filename", path.name)
|
||||||
|
cid = att.get("cid")
|
||||||
|
with open(path, "rb") as f:
|
||||||
|
content = f.read()
|
||||||
|
file_att = FileAttachment(name=filename, content=content, content_id=cid)
|
||||||
|
msg.attach(file_att)
|
||||||
|
|
||||||
|
msg.send()
|
||||||
|
except Exception as e:
|
||||||
|
raise RuntimeError(f"Ошибка отправки через EWS: {e}") from e
|
||||||
|
|
||||||
|
|
||||||
|
def _send_email(to_address, subject, html_body, sender_name="InviteMe", attachments=None):
|
||||||
|
"""Маршрутизирует отправку через SMTP, EWS или локальный Outlook."""
|
||||||
|
attachments = attachments or []
|
||||||
|
if current_app.config.get("MAIL_USE_LOCAL_OUTLOOK"):
|
||||||
|
_send_outlook_email(to_address, subject, html_body, sender_name, attachments)
|
||||||
|
elif current_app.config.get("MAIL_USE_EWS"):
|
||||||
|
_send_ews_email(to_address, subject, html_body, sender_name, attachments)
|
||||||
|
else:
|
||||||
|
_send_smtp_email(to_address, subject, html_body, sender_name, attachments)
|
||||||
|
|
||||||
|
|
||||||
|
def send_invitation(guest, event_name=None, additional_text=""):
|
||||||
|
"""Отправляет приглашение гостю на email с QR-кодом и билетом во вложении."""
|
||||||
|
config = current_app.config
|
||||||
|
|
||||||
|
if event_name is None:
|
||||||
|
event_name = guest.event.name if guest.event else "Мероприятие"
|
||||||
|
|
||||||
|
# Генерируем билет
|
||||||
|
try:
|
||||||
|
ticket_path = generate_ticket_image(guest, guest.event)
|
||||||
|
except Exception as e:
|
||||||
|
ticket_path = None
|
||||||
|
current_app.logger.warning(f"Не удалось сгенерировать билет для {guest.email}: {e}")
|
||||||
|
|
||||||
|
html_body = render_template(
|
||||||
|
"email/invitation.html",
|
||||||
|
guest=guest,
|
||||||
|
event_name=event_name,
|
||||||
|
additional_text=additional_text,
|
||||||
|
qr_cid="qr_code_image",
|
||||||
|
ticket_cid="ticket_image" if ticket_path else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
attachments = []
|
||||||
|
|
||||||
|
# QR-код
|
||||||
|
qr_path = event_qr_folder(guest.event_id) / guest.qr_filename
|
||||||
|
if not qr_path.exists():
|
||||||
|
qr_path = Path(config["QR_FOLDER"]) / guest.qr_filename
|
||||||
|
if qr_path.exists():
|
||||||
|
attachments.append({"path": qr_path, "cid": "qr_code_image", "filename": guest.qr_filename})
|
||||||
|
|
||||||
|
# Билет
|
||||||
|
if ticket_path and ticket_path.exists():
|
||||||
|
attachments.append({
|
||||||
|
"path": ticket_path,
|
||||||
|
"cid": "ticket_image",
|
||||||
|
"filename": f"ticket_{guest.token}.png",
|
||||||
|
})
|
||||||
|
|
||||||
|
_send_email(
|
||||||
|
to_address=guest.email,
|
||||||
|
subject=f"Приглашение на мероприятие: {event_name}",
|
||||||
|
html_body=html_body,
|
||||||
|
attachments=attachments,
|
||||||
|
)
|
||||||
|
|
||||||
guest.invitation_sent = True
|
guest.invitation_sent = True
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def send_test_email(to_address):
|
||||||
|
"""Отправляет тестовое письмо для проверки настроек."""
|
||||||
|
html_body = render_template("email/test_email.html")
|
||||||
|
_send_email(
|
||||||
|
to_address=to_address,
|
||||||
|
subject="Тестовое письмо InviteMe",
|
||||||
|
html_body=html_body,
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|||||||
@@ -2,11 +2,13 @@ import qrcode
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from flask import current_app, url_for
|
from flask import current_app, url_for
|
||||||
|
|
||||||
|
from app.utils.paths import event_qr_folder
|
||||||
|
|
||||||
|
|
||||||
def generate_qr_for_guest(guest, base_url=None):
|
def generate_qr_for_guest(guest, base_url=None):
|
||||||
"""Генерирует QR-код для гостя и сохраняет его в uploads/qrcodes/."""
|
"""Генерирует QR-код для гостя и сохраняет его в папку мероприятия."""
|
||||||
token = guest.token
|
token = guest.token
|
||||||
qr_folder = Path(current_app.config["QR_FOLDER"])
|
qr_folder = event_qr_folder(guest.event_id)
|
||||||
qr_folder.mkdir(parents=True, exist_ok=True)
|
qr_folder.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
qr_filename = f"{token}.png"
|
qr_filename = f"{token}.png"
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ from PIL import Image, ImageDraw, ImageFont
|
|||||||
from flask import current_app
|
from flask import current_app
|
||||||
|
|
||||||
from app.services.qr_service import generate_qr_for_guest
|
from app.services.qr_service import generate_qr_for_guest
|
||||||
|
from app.utils.timezone import utc_to_local
|
||||||
|
from app.utils.paths import event_logo_folder, event_tickets_folder, event_photo_folder, event_qr_folder
|
||||||
|
|
||||||
|
|
||||||
TICKET_SIZE = (650, 1150)
|
TICKET_SIZE = (650, 1150)
|
||||||
@@ -49,7 +51,7 @@ def _draw_logo_centered(img, event, y, max_width):
|
|||||||
if not event.logo_filename:
|
if not event.logo_filename:
|
||||||
return y
|
return y
|
||||||
|
|
||||||
logo_path = Path(current_app.config["LOGO_FOLDER"]) / event.logo_filename
|
logo_path = event_logo_folder(event.id) / event.logo_filename
|
||||||
if not logo_path.exists():
|
if not logo_path.exists():
|
||||||
current_app.logger.warning(f"Логотип не найден: {logo_path}")
|
current_app.logger.warning(f"Логотип не найден: {logo_path}")
|
||||||
return y
|
return y
|
||||||
@@ -292,8 +294,9 @@ def generate_ticket_image(guest, event, output_path=None):
|
|||||||
_draw_ornament(draw, y, width, accent_color)
|
_draw_ornament(draw, y, width, accent_color)
|
||||||
y += 25
|
y += 25
|
||||||
if event.event_date:
|
if event.event_date:
|
||||||
date_value = event.event_date.strftime("%d.%m.%Y")
|
local_event_date = utc_to_local(event.event_date)
|
||||||
time_value = event.event_date.strftime("%H:%M")
|
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, "Дата", 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)
|
y = _draw_info_row(draw, "Время", time_value, x_content, y, content_width, label_font, body_font, text_color)
|
||||||
if event.location:
|
if event.location:
|
||||||
@@ -302,6 +305,9 @@ def generate_ticket_image(guest, event, output_path=None):
|
|||||||
# QR-код
|
# QR-код
|
||||||
if event.ticket_show_qr:
|
if event.ticket_show_qr:
|
||||||
y += 15
|
y += 15
|
||||||
|
qr_path = event_qr_folder(guest.event_id) / guest.qr_filename
|
||||||
|
if not qr_path.exists():
|
||||||
|
# fallback на старую корневую папку
|
||||||
qr_path = Path(current_app.config["QR_FOLDER"]) / guest.qr_filename
|
qr_path = Path(current_app.config["QR_FOLDER"]) / guest.qr_filename
|
||||||
if qr_path.exists():
|
if qr_path.exists():
|
||||||
qr_size = 200
|
qr_size = 200
|
||||||
@@ -314,6 +320,9 @@ def generate_ticket_image(guest, event, output_path=None):
|
|||||||
|
|
||||||
# Фото гостя
|
# Фото гостя
|
||||||
if event.ticket_show_photo and guest.photo_filename:
|
if event.ticket_show_photo and guest.photo_filename:
|
||||||
|
photo_path = event_photo_folder(guest.event_id) / guest.photo_filename
|
||||||
|
if not photo_path.exists():
|
||||||
|
# fallback на старую корневую папку
|
||||||
photo_path = Path(current_app.config["PHOTO_FOLDER"]) / guest.photo_filename
|
photo_path = Path(current_app.config["PHOTO_FOLDER"]) / guest.photo_filename
|
||||||
if photo_path.exists():
|
if photo_path.exists():
|
||||||
photo_img = Image.open(str(photo_path)).convert("RGB")
|
photo_img = Image.open(str(photo_path)).convert("RGB")
|
||||||
@@ -339,7 +348,7 @@ def generate_ticket_image(guest, event, output_path=None):
|
|||||||
|
|
||||||
# Сохранение
|
# Сохранение
|
||||||
if output_path is None:
|
if output_path is None:
|
||||||
tickets_folder = Path(current_app.config["UPLOAD_FOLDER"]) / "tickets"
|
tickets_folder = event_tickets_folder(event.id)
|
||||||
tickets_folder.mkdir(parents=True, exist_ok=True)
|
tickets_folder.mkdir(parents=True, exist_ok=True)
|
||||||
output_path = tickets_folder / f"ticket_{event.id}_{guest.token}.png"
|
output_path = tickets_folder / f"ticket_{event.id}_{guest.token}.png"
|
||||||
|
|
||||||
@@ -352,6 +361,7 @@ def generate_preview_ticket(event, sample_guest=None):
|
|||||||
if sample_guest is None:
|
if sample_guest is None:
|
||||||
from app.models import Guest
|
from app.models import Guest
|
||||||
sample_guest = Guest(
|
sample_guest = Guest(
|
||||||
|
event_id=event.id,
|
||||||
last_name="Иванов",
|
last_name="Иванов",
|
||||||
first_name="Иван",
|
first_name="Иван",
|
||||||
middle_name="Иванович",
|
middle_name="Иванович",
|
||||||
|
|||||||
@@ -220,3 +220,18 @@ div[style*="max-width: 600px"] {
|
|||||||
background: var(--surface-color) !important;
|
background: var(--surface-color) !important;
|
||||||
border: 1px solid var(--surface-border) !important;
|
border: 1px solid var(--surface-border) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.app-logo {
|
||||||
|
height: 36px;
|
||||||
|
width: auto;
|
||||||
|
max-width: 140px;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-logo {
|
||||||
|
height: 80px;
|
||||||
|
width: auto;
|
||||||
|
max-width: 220px;
|
||||||
|
object-fit: contain;
|
||||||
|
filter: drop-shadow(0 0 12px var(--accent-glow));
|
||||||
|
}
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
@@ -58,6 +58,7 @@
|
|||||||
<h5 class="card-title">Действия</h5>
|
<h5 class="card-title">Действия</h5>
|
||||||
<a href="{{ url_for('main.admin_report') }}" class="btn btn-success">Выгрузить отчёт Excel</a>
|
<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">
|
<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>
|
<button type="submit" class="btn btn-outline-primary">Разослать приглашения</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
@@ -77,7 +78,7 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
{% for check_in in recent_check_ins %}
|
{% for check_in in recent_check_ins %}
|
||||||
<tr>
|
<tr>
|
||||||
<td>{{ check_in.checked_in_at.strftime('%d.%m.%Y %H:%M:%S') }}</td>
|
<td>{{ check_in.checked_in_at | localtime('%d.%m.%Y %H:%M:%S') }}</td>
|
||||||
<td>{{ check_in.guest.full_name }}</td>
|
<td>{{ check_in.guest.full_name }}</td>
|
||||||
<td>{{ check_in.guest.department }}</td>
|
<td>{{ check_in.guest.department }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Рассылка приглашений — InviteMe{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-lg-8">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<h4 class="card-title mb-0">Рассылка приглашений</h4>
|
||||||
|
<a href="{{ url_for('main.admin_email_settings') }}" class="btn btn-outline-secondary btn-sm">Настройки почты</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if event_counts %}
|
||||||
|
<form method="POST" action="{{ url_for('main.admin_email_send') }}">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
|
||||||
|
<div class="mb-4">
|
||||||
|
<label for="event_id" class="form-label">Мероприятие</label>
|
||||||
|
<select class="form-select" id="event_id" name="event_id" required>
|
||||||
|
<option value="">Выберите мероприятие</option>
|
||||||
|
{% for item in event_counts %}
|
||||||
|
<option value="{{ item.event.id }}">
|
||||||
|
{{ item.event.name }} — неотправлено: {{ item.unsent }}
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="alert alert-info">
|
||||||
|
Перед рассылкой убедитесь, что в <a href="{{ url_for('main.admin_email_settings') }}">настройках почты</a> указаны корректные SMTP-данные.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="btn btn-primary" onclick="return confirm('Начать рассылку приглашений?')">
|
||||||
|
Разослать приглашения
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{% else %}
|
||||||
|
<div class="alert alert-warning">У вас пока нет мероприятий для рассылки.</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Настройки почты — InviteMe{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-lg-8">
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-body">
|
||||||
|
<h4 class="card-title mb-4">Настройки почты для рассылки приглашений</h4>
|
||||||
|
<form method="POST" action="{{ url_for('main.admin_email_settings') }}">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="form-label d-block">Способ отправки</label>
|
||||||
|
<div class="form-check form-check-inline">
|
||||||
|
<input class="form-check-input" type="radio" name="mail_mode" id="mode_smtp" value="smtp" {% if settings.mail_mode == 'smtp' %}checked{% endif %}>
|
||||||
|
<label class="form-check-label" for="mode_smtp">SMTP</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-check form-check-inline">
|
||||||
|
<input class="form-check-input" type="radio" name="mail_mode" id="mode_ews" value="ews" {% if settings.mail_mode == 'ews' %}checked{% endif %}>
|
||||||
|
<label class="form-check-label" for="mode_ews">EWS (Exchange)</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-check form-check-inline">
|
||||||
|
<input class="form-check-input" type="radio" name="mail_mode" id="mode_outlook" value="outlook" {% if settings.mail_mode == 'outlook' %}checked{% endif %}>
|
||||||
|
<label class="form-check-label" for="mode_outlook">Локальный Outlook</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-text text-muted">
|
||||||
|
SMTP/EWS — для серверной установки. Outlook — только если InviteMe запущен на ПК с Outlook.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="common-fields">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="mail_username" class="form-label">Логин / Email</label>
|
||||||
|
<input type="text" class="form-control" id="mail_username" name="mail_username" value="{{ settings.mail_username }}">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="mail_password" class="form-label">Пароль</label>
|
||||||
|
<input type="password" class="form-control" id="mail_password" name="mail_password" placeholder="Оставьте пустым, чтобы не менять">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-4">
|
||||||
|
<label for="mail_default_sender" class="form-label">Имя отправителя (необязательно)</label>
|
||||||
|
<input type="text" class="form-control" id="mail_default_sender" name="mail_default_sender" value="{{ settings.mail_default_sender or '' }}">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="smtp-fields" {% if settings.mail_mode != 'smtp' %}style="display:none;"{% endif %}>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-8 mb-3">
|
||||||
|
<label for="mail_server" class="form-label">SMTP-сервер</label>
|
||||||
|
<input type="text" class="form-control" id="mail_server" name="mail_server" value="{{ settings.mail_server }}">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4 mb-3">
|
||||||
|
<label for="mail_port" class="form-label">Порт</label>
|
||||||
|
<input type="number" class="form-control" id="mail_port" name="mail_port" value="{{ settings.mail_port }}">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-4 form-check">
|
||||||
|
<input type="checkbox" class="form-check-input" id="mail_use_ssl" name="mail_use_ssl" value="1" {% if settings.mail_use_ssl %}checked{% endif %}>
|
||||||
|
<label class="form-check-label" for="mail_use_ssl">Использовать SSL</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="ews-fields" {% if settings.mail_mode != 'ews' %}style="display:none;"{% endif %}>
|
||||||
|
<div class="mb-4">
|
||||||
|
<label for="ews_url" class="form-label">EWS URL</label>
|
||||||
|
<input type="text" class="form-control" id="ews_url" name="ews_url" value="{{ settings.ews_url or '' }}" placeholder="https://mail.company.com/EWS/Exchange.asmx">
|
||||||
|
<div class="form-text text-muted">URL вида <code>https://mail.integrita.ru/EWS/Exchange.asmx</code></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="outlook-info" {% if settings.mail_mode != 'outlook' %}style="display:none;"{% endif %} class="alert alert-info">
|
||||||
|
Outlook должен быть установлен и запущен на сервере, где работает приложение. Браузер не может запустить Outlook на твоём компьютере.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<button type="submit" class="btn btn-primary">Сохранить настройки</button>
|
||||||
|
<a href="{{ url_for('main.admin_email_send') }}" class="btn btn-outline-primary">Перейти к рассылке</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
<h5 class="card-title mb-3">Отправить тестовое письмо</h5>
|
||||||
|
<form method="POST" action="{{ url_for('main.admin_email_test') }}">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<div class="input-group">
|
||||||
|
<input type="email" class="form-control" name="test_address" placeholder="address@example.com" required>
|
||||||
|
<button class="btn btn-success" type="submit">Отправить тест</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script>
|
||||||
|
const radios = document.querySelectorAll('input[name="mail_mode"]');
|
||||||
|
const smtpFields = document.getElementById('smtp-fields');
|
||||||
|
const ewsFields = document.getElementById('ews-fields');
|
||||||
|
const outlookInfo = document.getElementById('outlook-info');
|
||||||
|
const commonInputs = document.querySelectorAll('#common-fields input, #smtp-fields input, #ews-fields input');
|
||||||
|
|
||||||
|
function toggleFields() {
|
||||||
|
const mode = document.querySelector('input[name="mail_mode"]:checked').value;
|
||||||
|
smtpFields.style.display = mode === 'smtp' ? 'block' : 'none';
|
||||||
|
ewsFields.style.display = mode === 'ews' ? 'block' : 'none';
|
||||||
|
outlookInfo.style.display = mode === 'outlook' ? 'block' : 'none';
|
||||||
|
|
||||||
|
// disable hidden inputs to avoid sending stale data
|
||||||
|
document.querySelectorAll('#smtp-fields input, #ews-fields input').forEach(i => {
|
||||||
|
i.disabled = (mode === 'outlook') || (i.closest('#smtp-fields') && mode !== 'smtp') || (i.closest('#ews-fields') && mode !== 'ews');
|
||||||
|
});
|
||||||
|
document.querySelectorAll('#common-fields input').forEach(i => i.disabled = mode === 'outlook');
|
||||||
|
}
|
||||||
|
|
||||||
|
radios.forEach(r => r.addEventListener('change', toggleFields));
|
||||||
|
toggleFields();
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -8,6 +8,7 @@
|
|||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<form method="POST">
|
<form method="POST">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label for="name" class="form-label">Название *</label>
|
<label for="name" class="form-label">Название *</label>
|
||||||
<input type="text" class="form-control" id="name" name="name" value="{{ event.name if event else '' }}" required>
|
<input type="text" class="form-control" id="name" name="name" value="{{ event.name if event else '' }}" required>
|
||||||
@@ -23,7 +24,7 @@
|
|||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label for="event_date" class="form-label">Дата и время</label>
|
<label for="event_date" class="form-label">Дата и время</label>
|
||||||
<input type="datetime-local" class="form-control" id="event_date" name="event_date"
|
<input type="datetime-local" class="form-control" id="event_date" name="event_date"
|
||||||
value="{{ event.event_date.strftime('%Y-%m-%dT%H:%M') if event and event.event_date else '' }}">
|
value="{{ event.event_date | localtime('%Y-%m-%dT%H:%M') if event and event.event_date else '' }}">
|
||||||
</div>
|
</div>
|
||||||
{% if event %}
|
{% if event %}
|
||||||
<div class="mb-3 form-check">
|
<div class="mb-3 form-check">
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
<h5 class="card-title">{{ event.name }}</h5>
|
<h5 class="card-title">{{ event.name }}</h5>
|
||||||
<p class="card-text text-muted">
|
<p class="card-text text-muted">
|
||||||
{% if event.event_date %}
|
{% if event.event_date %}
|
||||||
{{ event.event_date.strftime('%d.%m.%Y %H:%M') }}<br>
|
{{ event.event_date | localtime('%d.%m.%Y %H:%M') }}<br>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if event.location %}
|
{% if event.location %}
|
||||||
{{ event.location }}<br>
|
{{ event.location }}<br>
|
||||||
@@ -28,20 +28,54 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
<div class="d-flex flex-wrap gap-2">
|
<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">
|
<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>
|
<button type="submit" class="btn btn-success btn-sm">Выбрать</button>
|
||||||
</form>
|
</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_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>
|
<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 %}
|
{% if not event.is_active %}
|
||||||
<span class="badge bg-secondary">Неактивно</span>
|
<span class="badge bg-secondary">Неактивно</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-footer text-muted">
|
<div class="card-footer text-muted">
|
||||||
Создано: {{ event.created_at.strftime('%d.%m.%Y') }}
|
Создано: {{ event.created_at | localdate('%d.%m.%Y') }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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 %}
|
{% else %}
|
||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
<div class="alert alert-info">Пока нет ни одного мероприятия. <a href="{{ url_for('main.admin_event_add') }}">Создайте первое</a>.</div>
|
<div class="alert alert-info">Пока нет ни одного мероприятия. <a href="{{ url_for('main.admin_event_add') }}">Создайте первое</a>.</div>
|
||||||
|
|||||||
@@ -8,7 +8,14 @@
|
|||||||
|
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-md-6">
|
<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">
|
<form method="POST">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-md-6 mb-3">
|
<div class="col-md-6 mb-3">
|
||||||
<label for="last_name" class="form-label">Фамилия</label>
|
<label for="last_name" class="form-label">Фамилия</label>
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
|
|
||||||
<form method="GET" class="row g-3 mb-4">
|
<form method="GET" class="row g-3 mb-4">
|
||||||
<div class="col-auto">
|
<div class="col-auto">
|
||||||
<input type="text" name="q" class="form-control" placeholder="Поиск по ФИО" value="{{ search }}">
|
<input type="text" name="q" class="form-control" placeholder="Поиск по ФИО, отделу, email..." value="{{ search }}">
|
||||||
</div>
|
</div>
|
||||||
<div class="col-auto">
|
<div class="col-auto">
|
||||||
<button type="submit" class="btn btn-outline-primary">Найти</button>
|
<button type="submit" class="btn btn-outline-primary">Найти</button>
|
||||||
@@ -40,17 +40,17 @@
|
|||||||
<td>{{ guest.email }}</td>
|
<td>{{ guest.email }}</td>
|
||||||
<td>{{ guest.phone or '-' }}</td>
|
<td>{{ guest.phone or '-' }}</td>
|
||||||
<td>
|
<td>
|
||||||
{% if guest.photo_filename %}
|
{% if guest.has_photo_file %}
|
||||||
<span class="badge bg-success">Есть</span>
|
<a href="{{ guest.photo_url }}" target="_blank" class="badge bg-success text-decoration-none">Есть</a>
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="badge bg-warning text-dark">Нет</span>
|
<span class="badge bg-warning text-dark" title="{% if guest.photo_filename %}Имя файла указано, но файл не загружен{% else %}Не указано{% endif %}">Нет</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{% if guest.qr_filename %}
|
{% if guest.qr_filename %}
|
||||||
<span class="badge bg-success">Да</span>
|
<a href="{{ url_for('main.admin_guest_qr', guest_id=guest.id) }}" target="_blank" class="badge bg-success text-decoration-none">Да</a>
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="badge bg-secondary">Нет</span>
|
<a href="{{ url_for('main.admin_guest_qr', guest_id=guest.id) }}" target="_blank" class="badge bg-secondary text-decoration-none">Нет</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
@@ -62,10 +62,14 @@
|
|||||||
</td>
|
</td>
|
||||||
<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_edit', guest_id=guest.id) }}" class="btn btn-sm btn-outline-primary">Изменить</a>
|
||||||
<form action="{{ url_for('main.admin_guest_resend', guest_id=guest.id) }}" method="POST" class="d-inline">
|
<a href="{{ url_for('main.admin_guest_ticket', guest_id=guest.id) }}" target="_blank" class="btn btn-sm btn-outline-info">Билет</a>
|
||||||
<button type="submit" class="btn btn-sm btn-outline-success">QR</button>
|
<a href="{{ url_for('main.admin_guest_qr', guest_id=guest.id) }}" target="_blank" class="btn btn-sm btn-outline-success">QR</a>
|
||||||
|
<form action="{{ url_for('main.admin_guest_resend', guest_id=guest.id) }}" method="POST" class="d-inline" onsubmit="return confirm('Отправить приглашение повторно?')">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<button type="submit" class="btn btn-sm btn-outline-warning">Пригласить</button>
|
||||||
</form>
|
</form>
|
||||||
<form action="{{ url_for('main.admin_guest_delete', guest_id=guest.id) }}" method="POST" class="d-inline" onsubmit="return confirm('Заблокировать гостя?')">
|
<form action="{{ url_for('main.admin_guest_delete', guest_id=guest.id) }}" method="POST" class="d-inline" onsubmit="return confirm('Заблокировать гостя?')">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
<button type="submit" class="btn btn-sm btn-outline-danger">Блок</button>
|
<button type="submit" class="btn btn-sm btn-outline-danger">Блок</button>
|
||||||
</form>
|
</form>
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -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,33 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Админ-панель — InviteMe{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="row mb-4">
|
||||||
|
<div class="col">
|
||||||
|
<h2>Админ-панель</h2>
|
||||||
|
<p class="text-muted">Все разделы управления в одном месте</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row row-cols-1 row-cols-md-2 row-cols-lg-3 g-4">
|
||||||
|
{% for card in cards %}
|
||||||
|
<div class="col">
|
||||||
|
<div class="card h-100 {% if card.disabled %}opacity-75{% endif %}">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex align-items-center mb-3">
|
||||||
|
<span style="font-size: 2rem; margin-right: 0.75rem;">{{ card.icon }}</span>
|
||||||
|
<h5 class="card-title mb-0">{{ card.title }}</h5>
|
||||||
|
</div>
|
||||||
|
<p class="card-text text-muted">{{ card.description }}</p>
|
||||||
|
{% if card.disabled %}
|
||||||
|
<a href="{{ card.url }}" class="btn btn-outline-secondary disabled">Сначала выберите мероприятие</a>
|
||||||
|
{% else %}
|
||||||
|
<a href="{{ card.url }}" class="btn btn-primary">{{ card.btn }}</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -14,12 +14,13 @@
|
|||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<h5 class="card-title">Настройки дизайна</h5>
|
<h5 class="card-title">Настройки дизайна</h5>
|
||||||
<form method="POST" id="ticket-form" enctype="multipart/form-data">
|
<form method="POST" id="ticket-form" enctype="multipart/form-data">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label for="logo_file" class="form-label">Логотип компании</label>
|
<label for="logo_file" class="form-label">Логотип компании</label>
|
||||||
<input type="file" class="form-control" id="logo_file" name="logo_file" accept="image/*">
|
<input type="file" class="form-control" id="logo_file" name="logo_file" accept="image/*">
|
||||||
{% if event.logo_filename %}
|
{% if event.logo_filename %}
|
||||||
<div class="mt-2 d-flex align-items-center gap-2">
|
<div class="mt-2 d-flex align-items-center gap-2">
|
||||||
<img src="{{ url_for('static', filename='uploads/logos/' + event.logo_filename) }}" alt="Логотип" style="max-height: 60px; max-width: 200px;">
|
<img src="{{ url_for('static', filename='uploads/logos/event_' + event.id|string + '/' + event.logo_filename) }}" alt="Логотип" style="max-height: 60px; max-width: 200px;">
|
||||||
<div class="form-check">
|
<div class="form-check">
|
||||||
<input class="form-check-input" type="checkbox" id="delete_logo" name="delete_logo" value="1">
|
<input class="form-check-input" type="checkbox" id="delete_logo" name="delete_logo" value="1">
|
||||||
<label class="form-check-label" for="delete_logo">Удалить</label>
|
<label class="form-check-label" for="delete_logo">Удалить</label>
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
<h5 class="card-title">1. Загрузить Excel со списком гостей</h5>
|
<h5 class="card-title">1. Загрузить Excel со списком гостей</h5>
|
||||||
<p class="text-muted">Столбцы: Отдел, Фамилия, Имя, Отчество, Должность, Почта, Телефон, Название фото</p>
|
<p class="text-muted">Столбцы: Отдел, Фамилия, Имя, Отчество, Должность, Почта, Телефон, Название фото</p>
|
||||||
<form method="POST" enctype="multipart/form-data">
|
<form method="POST" enctype="multipart/form-data">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label for="excel_file" class="form-label">Excel-файл</label>
|
<label for="excel_file" class="form-label">Excel-файл</label>
|
||||||
<input type="file" class="form-control" id="excel_file" name="excel_file" accept=".xlsx,.xls" required>
|
<input type="file" class="form-control" id="excel_file" name="excel_file" accept=".xlsx,.xls" required>
|
||||||
@@ -33,6 +34,7 @@
|
|||||||
<h5 class="card-title">2. Загрузить фото гостей</h5>
|
<h5 class="card-title">2. Загрузить фото гостей</h5>
|
||||||
<p class="text-muted">Фото должны называться точно так же, как указано в столбце «Название фото»</p>
|
<p class="text-muted">Фото должны называться точно так же, как указано в столбце «Название фото»</p>
|
||||||
<form method="POST" enctype="multipart/form-data">
|
<form method="POST" enctype="multipart/form-data">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label for="photo_files" class="form-label">Фото (можно несколько)</label>
|
<label for="photo_files" class="form-label">Фото (можно несколько)</label>
|
||||||
<input type="file" class="form-control" id="photo_files" name="photo_files" accept="image/*" multiple>
|
<input type="file" class="form-control" id="photo_files" name="photo_files" accept="image/*" multiple>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<form method="POST">
|
<form method="POST">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label for="username" class="form-label">Логин *</label>
|
<label for="username" class="form-label">Логин *</label>
|
||||||
<input type="text" class="form-control" id="username" name="username" value="{{ user.username if user else '' }}" required>
|
<input type="text" class="form-control" id="username" name="username" value="{{ user.username if user else '' }}" required>
|
||||||
|
|||||||
@@ -38,11 +38,12 @@
|
|||||||
<span class="badge bg-secondary">Заблокирован</span>
|
<span class="badge bg-secondary">Заблокирован</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td>{{ user.created_at.strftime('%d.%m.%Y %H:%M') }}</td>
|
<td>{{ user.created_at | localtime('%d.%m.%Y %H:%M') }}</td>
|
||||||
<td>
|
<td>
|
||||||
<a href="{{ url_for('main.admin_user_edit', user_id=user.id) }}" class="btn btn-sm btn-outline-primary">Изменить</a>
|
<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 %}
|
{% if current_user.id != user.id %}
|
||||||
<form action="{{ url_for('main.admin_user_delete', user_id=user.id) }}" method="POST" class="d-inline" onsubmit="return confirm('Заблокировать пользователя?')">
|
<form action="{{ url_for('main.admin_user_delete', user_id=user.id) }}" method="POST" class="d-inline" onsubmit="return confirm('Заблокировать пользователя?')">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
<button type="submit" class="btn btn-sm btn-outline-danger">Блок</button>
|
<button type="submit" class="btn btn-sm btn-outline-danger">Блок</button>
|
||||||
</form>
|
</form>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
+8
-22
@@ -6,38 +6,23 @@
|
|||||||
<title>{% block title %}InviteMe{% endblock %}</title>
|
<title>{% block title %}InviteMe{% endblock %}</title>
|
||||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
|
<link rel="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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<nav class="navbar navbar-expand-lg navbar-dark">
|
<nav class="navbar navbar-expand-lg navbar-dark">
|
||||||
<div class="container-fluid">
|
<div class="container-fluid">
|
||||||
<a class="navbar-brand" href="{{ url_for('main.index') }}">InviteMe</a>
|
<a class="navbar-brand d-flex align-items-center" href="{{ url_for('main.index') }}">
|
||||||
|
<img src="{{ url_for('static', filename='images/inviteme_logo.png') }}" alt="InviteMe" class="app-logo">
|
||||||
|
</a>
|
||||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
|
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
|
||||||
<span class="navbar-toggler-icon"></span>
|
<span class="navbar-toggler-icon"></span>
|
||||||
</button>
|
</button>
|
||||||
<div class="collapse navbar-collapse" id="navbarNav">
|
<div class="collapse navbar-collapse" id="navbarNav">
|
||||||
<ul class="navbar-nav me-auto">
|
<ul class="navbar-nav me-auto">
|
||||||
{% if current_user and current_user.role in ('admin', 'organizer') %}
|
|
||||||
<li class="nav-item">
|
|
||||||
<a class="nav-link" href="{{ url_for('main.admin_events') }}">Мероприятия</a>
|
|
||||||
</li>
|
|
||||||
{% if current_event %}
|
|
||||||
<li class="nav-item">
|
|
||||||
<a class="nav-link" href="{{ url_for('main.admin_dashboard') }}">Аналитика</a>
|
|
||||||
</li>
|
|
||||||
<li class="nav-item">
|
|
||||||
<a class="nav-link" href="{{ url_for('main.admin_upload') }}">Загрузка</a>
|
|
||||||
</li>
|
|
||||||
<li class="nav-item">
|
|
||||||
<a class="nav-link" href="{{ url_for('main.admin_guests') }}">Гости</a>
|
|
||||||
</li>
|
|
||||||
{% endif %}
|
|
||||||
{% endif %}
|
|
||||||
{% if current_user and current_user.is_admin %}
|
|
||||||
<li class="nav-item">
|
|
||||||
<a class="nav-link" href="{{ url_for('main.admin_users') }}">Пользователи</a>
|
|
||||||
</li>
|
|
||||||
{% endif %}
|
|
||||||
{% if current_user and current_user.role in ('admin', 'organizer', 'staff') %}
|
{% if current_user and current_user.role in ('admin', 'organizer', 'staff') %}
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="{{ url_for('main.admin_panel') }}">Админ-панель</a>
|
||||||
|
</li>
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link" href="{{ url_for('main.check_in') }}">Проверка приглашения</a>
|
<a class="nav-link" href="{{ url_for('main.check_in') }}">Проверка приглашения</a>
|
||||||
</li>
|
</li>
|
||||||
@@ -55,6 +40,7 @@
|
|||||||
{% for event in available_events %}
|
{% for event in available_events %}
|
||||||
<li>
|
<li>
|
||||||
<form action="{{ url_for('main.admin_event_switch', event_id=event.id) }}" method="POST" class="d-inline">
|
<form action="{{ url_for('main.admin_event_switch', event_id=event.id) }}" method="POST" class="d-inline">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
<button type="submit" class="dropdown-item {% if current_event and current_event.id == event.id %}active{% endif %}">
|
<button type="submit" class="dropdown-item {% if current_event and current_event.id == event.id %}active{% endif %}">
|
||||||
{{ event.name }}
|
{{ event.name }}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<h5 class="card-title">Выберите мероприятие</h5>
|
<h5 class="card-title">Выберите мероприятие</h5>
|
||||||
<form method="POST" action="{{ url_for('main.check_in', token=token) }}">
|
<form method="POST" action="{{ url_for('main.check_in', token=token) }}">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<select id="event-select" name="event_id" class="form-select" onchange="this.form.submit()">
|
<select id="event-select" name="event_id" class="form-select" onchange="this.form.submit()">
|
||||||
<option value="">-- Мероприятие --</option>
|
<option value="">-- Мероприятие --</option>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -7,9 +7,13 @@
|
|||||||
<div class="col-md-4">
|
<div class="col-md-4">
|
||||||
<div class="card mt-5">
|
<div class="card mt-5">
|
||||||
<div class="card-body p-4">
|
<div class="card-body p-4">
|
||||||
|
<div class="text-center mb-3">
|
||||||
|
<img src="{{ url_for('static', filename='images/inviteme_logo.png') }}" alt="InviteMe" class="login-logo">
|
||||||
|
</div>
|
||||||
<h3 class="card-title text-center mb-4" style="color: var(--accent-color); text-shadow: 0 0 12px var(--accent-glow);">InviteMe</h3>
|
<h3 class="card-title text-center mb-4" style="color: var(--accent-color); text-shadow: 0 0 12px var(--accent-glow);">InviteMe</h3>
|
||||||
<p class="text-muted text-center mb-4">Система регистрации гостей</p>
|
<p class="text-muted text-center mb-4">Система регистрации гостей</p>
|
||||||
<form method="POST" action="{{ url_for('main.login') }}">
|
<form method="POST" action="{{ url_for('main.login') }}">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label for="username" class="form-label">Логин</label>
|
<label for="username" class="form-label">Логин</label>
|
||||||
<input type="text" class="form-control" id="username" name="username" required autofocus>
|
<input type="text" class="form-control" id="username" name="username" required autofocus>
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
from flask import current_app
|
||||||
|
|
||||||
|
|
||||||
|
def _base_upload_folder():
|
||||||
|
return Path(current_app.config["UPLOAD_FOLDER"])
|
||||||
|
|
||||||
|
|
||||||
|
def event_photo_folder(event_id):
|
||||||
|
return Path(current_app.config["PHOTO_FOLDER"]) / f"event_{event_id}"
|
||||||
|
|
||||||
|
|
||||||
|
def event_qr_folder(event_id):
|
||||||
|
return Path(current_app.config["QR_FOLDER"]) / f"event_{event_id}"
|
||||||
|
|
||||||
|
|
||||||
|
def event_logo_folder(event_id):
|
||||||
|
return Path(current_app.config["LOGO_FOLDER"]) / f"event_{event_id}"
|
||||||
|
|
||||||
|
|
||||||
|
def event_tickets_folder(event_id):
|
||||||
|
return _base_upload_folder() / "tickets" / f"event_{event_id}"
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_event_folders(event_id):
|
||||||
|
"""Создаёт все папки для конкретного мероприятия."""
|
||||||
|
for folder in [event_photo_folder, event_qr_folder, event_logo_folder, event_tickets_folder]:
|
||||||
|
folder(event_id).mkdir(parents=True, exist_ok=True)
|
||||||
@@ -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)
|
||||||
@@ -39,3 +39,20 @@ class Config:
|
|||||||
|
|
||||||
# Default event name for migrating existing guests
|
# Default event name for migrating existing guests
|
||||||
DEFAULT_EVENT_NAME = os.environ.get("DEFAULT_EVENT_NAME") or "Архивное мероприятие"
|
DEFAULT_EVENT_NAME = os.environ.get("DEFAULT_EVENT_NAME") or "Архивное мероприятие"
|
||||||
|
|
||||||
|
# Local timezone for display (e.g. Europe/Moscow)
|
||||||
|
TIMEZONE = os.environ.get("TIMEZONE") or "Europe/Moscow"
|
||||||
|
|
||||||
|
# Session security (HTTPS is required for these flags)
|
||||||
|
SESSION_COOKIE_SECURE = os.environ.get("SESSION_COOKIE_SECURE", "true").lower() in ["true", "1", "yes"]
|
||||||
|
SESSION_COOKIE_HTTPONLY = True
|
||||||
|
SESSION_COOKIE_SAMESITE = "Lax"
|
||||||
|
PERMANENT_SESSION_LIFETIME = int(os.environ.get("PERMANENT_SESSION_LIFETIME", "28800")) # 8 часов
|
||||||
|
|
||||||
|
# CSRF protection
|
||||||
|
WTF_CSRF_ENABLED = True
|
||||||
|
WTF_CSRF_TIME_LIMIT = 3600
|
||||||
|
|
||||||
|
# Rate limiting
|
||||||
|
RATELIMIT_STORAGE_URI = os.environ.get("RATELIMIT_STORAGE_URI") or "memory://"
|
||||||
|
RATELIMIT_STRATEGY = "fixed-window"
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
Flask==3.0.0
|
Flask==3.0.0
|
||||||
Flask-SQLAlchemy==3.1.1
|
Flask-SQLAlchemy==3.1.1
|
||||||
|
Flask-Limiter==4.1.1
|
||||||
|
Flask-WTF==1.3.0
|
||||||
|
WTForms==3.2.2
|
||||||
|
waitress==3.2.2
|
||||||
Werkzeug==3.0.1
|
Werkzeug==3.0.1
|
||||||
openpyxl==3.1.2
|
openpyxl==3.1.2
|
||||||
qrcode==7.4.2
|
qrcode==7.4.2
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
from waitress import serve
|
||||||
|
from app import create_app
|
||||||
|
|
||||||
|
app = create_app()
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
# host=0.0.0.0 — слушаем все интерфейсы
|
||||||
|
# port=8888 — порт, на который Nginx проксирует запросы
|
||||||
|
serve(app, host="0.0.0.0", port=8888, threads=4)
|
||||||
Reference in New Issue
Block a user