Auto-commit before push to git.mycloud24.ru
This commit is contained in:
@@ -4,7 +4,9 @@
|
||||
|
||||
## Возможности
|
||||
|
||||
- Загрузка списка гостей из Excel
|
||||
- Управление пользователями (админ, организатор, сотрудник на входе)
|
||||
- Создание и управление несколькими мероприятиями
|
||||
- Загрузка списка гостей из Excel в рамках мероприятия
|
||||
- Хранение фото гостей и привязка по названию файла
|
||||
- Генерация персональных QR-кодов
|
||||
- Автоматическая рассылка приглашений на email
|
||||
@@ -39,9 +41,13 @@ flask --app run.py run --host=0.0.0.0 --port=5000
|
||||
|
||||
## Учётные записи по умолчанию
|
||||
|
||||
При первом запуске автоматически создаются:
|
||||
|
||||
- Админ: `admin` / `admin`
|
||||
- Сотрудник на входе: `staff` / `staff`
|
||||
|
||||
После входа администратор может создавать новых пользователей и мероприятия через веб-интерфейс.
|
||||
|
||||
> Обязательно смените пароли в production через переменные окружения или `.env`.
|
||||
|
||||
## Структура проекта
|
||||
@@ -50,7 +56,7 @@ flask --app run.py run --host=0.0.0.0 --port=5000
|
||||
InviteMe/
|
||||
├── app/
|
||||
│ ├── __init__.py
|
||||
│ ├── models.py # Модели Guest и CheckIn
|
||||
│ ├── models.py # Модели User, Event, Guest и CheckIn
|
||||
│ ├── routes.py # Все маршруты приложения
|
||||
│ ├── services/ # QR, email, Excel
|
||||
│ ├── static/
|
||||
|
||||
+127
-7
@@ -1,25 +1,145 @@
|
||||
import os
|
||||
import logging
|
||||
|
||||
from flask import Flask
|
||||
from flask_sqlalchemy import SQLAlchemy
|
||||
from config import Config
|
||||
import os
|
||||
from sqlalchemy import text
|
||||
|
||||
from config import Config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
db = SQLAlchemy()
|
||||
|
||||
|
||||
def create_app(config_class=Config):
|
||||
app = Flask(__name__)
|
||||
app.config.from_object(config_class)
|
||||
|
||||
|
||||
# Ensure upload directories exist
|
||||
os.makedirs(app.config["PHOTO_FOLDER"], exist_ok=True)
|
||||
os.makedirs(app.config["QR_FOLDER"], exist_ok=True)
|
||||
os.makedirs(app.config["LOGO_FOLDER"], exist_ok=True)
|
||||
os.makedirs(os.path.dirname(app.config["SQLALCHEMY_DATABASE_URI"].replace("sqlite:///", "")), exist_ok=True)
|
||||
|
||||
|
||||
db.init_app(app)
|
||||
|
||||
|
||||
from app.routes import main_bp
|
||||
app.register_blueprint(main_bp)
|
||||
|
||||
|
||||
with app.app_context():
|
||||
db.create_all()
|
||||
|
||||
_migrate_existing_data(app)
|
||||
_create_default_users(app)
|
||||
|
||||
return app
|
||||
|
||||
|
||||
def _column_exists(table_name, column_name):
|
||||
return db.session.execute(
|
||||
text(f"SELECT COUNT(*) FROM pragma_table_info('{table_name}') WHERE name='{column_name}'")
|
||||
).scalar()
|
||||
|
||||
|
||||
def _add_column_if_missing(table_name, column_name, column_def):
|
||||
if not _column_exists(table_name, column_name):
|
||||
db.session.execute(text(f"ALTER TABLE {table_name} ADD COLUMN {column_name} {column_def}"))
|
||||
|
||||
|
||||
def _migrate_existing_data(app):
|
||||
"""Миграция существующих гостей в дефолтное мероприятие и добавление новых колонок."""
|
||||
from app.models import Event, Guest
|
||||
|
||||
try:
|
||||
# Добавляем колонки дизайна билета в таблицу events
|
||||
_add_column_if_missing("events", "ticket_title", "VARCHAR(255)")
|
||||
_add_column_if_missing("events", "ticket_subtitle", "VARCHAR(255)")
|
||||
_add_column_if_missing("events", "ticket_body_text", "TEXT")
|
||||
_add_column_if_missing("events", "ticket_background_color", "VARCHAR(7) DEFAULT '#ffffff'")
|
||||
_add_column_if_missing("events", "ticket_text_color", "VARCHAR(7) DEFAULT '#000000'")
|
||||
_add_column_if_missing("events", "ticket_accent_color", "VARCHAR(7) DEFAULT '#0d6efd'")
|
||||
_add_column_if_missing("events", "ticket_show_qr", "BOOLEAN DEFAULT 1")
|
||||
_add_column_if_missing("events", "ticket_show_photo", "BOOLEAN DEFAULT 0")
|
||||
_add_column_if_missing("events", "ticket_layout", "VARCHAR(20) DEFAULT 'portrait'")
|
||||
_add_column_if_missing("events", "logo_filename", "VARCHAR(255)")
|
||||
|
||||
# Добавляем колонки ФИО/телефона в таблицу guests
|
||||
_add_column_if_missing("guests", "last_name", "VARCHAR(255)")
|
||||
_add_column_if_missing("guests", "first_name", "VARCHAR(255)")
|
||||
_add_column_if_missing("guests", "middle_name", "VARCHAR(255)")
|
||||
_add_column_if_missing("guests", "phone", "VARCHAR(50)")
|
||||
|
||||
# Проверяем, есть ли колонка event_id в таблице guests
|
||||
if _column_exists("guests", "event_id"):
|
||||
db.session.commit()
|
||||
return # Миграция гостей уже выполнена
|
||||
|
||||
# Создаём дефолтное мероприятие для существующих гостей
|
||||
default_event = Event(
|
||||
name=app.config.get("DEFAULT_EVENT_NAME", "Архивное мероприятие"),
|
||||
description="Автоматически созданное мероприятие для существующих гостей",
|
||||
is_active=True,
|
||||
)
|
||||
db.session.add(default_event)
|
||||
db.session.flush()
|
||||
|
||||
# Добавляем колонку event_id
|
||||
db.session.execute(text("ALTER TABLE guests ADD COLUMN event_id INTEGER REFERENCES events(id)"))
|
||||
|
||||
# Привязываем всех существующих гостей к дефолтному мероприятию
|
||||
db.session.execute(
|
||||
text("UPDATE guests SET event_id = :event_id"),
|
||||
{"event_id": default_event.id},
|
||||
)
|
||||
|
||||
# Добавляем колонку event_id в check_ins (опционально)
|
||||
if not _column_exists("check_ins", "event_id"):
|
||||
db.session.execute(text("ALTER TABLE check_ins ADD COLUMN event_id INTEGER REFERENCES events(id)"))
|
||||
db.session.execute(
|
||||
text("""
|
||||
UPDATE check_ins
|
||||
SET event_id = (
|
||||
SELECT event_id FROM guests WHERE guests.id = check_ins.guest_id
|
||||
)
|
||||
""")
|
||||
)
|
||||
|
||||
db.session.commit()
|
||||
logger.info(f"Migration completed: existing guests moved to event '{default_event.name}' (id={default_event.id})")
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
logger.warning(f"Migration skipped or failed: {e}")
|
||||
|
||||
|
||||
def _create_default_users(app):
|
||||
"""Создание начальных пользователей, если в базе нет ни одного."""
|
||||
from app.models import User
|
||||
|
||||
try:
|
||||
if User.query.first() is not None:
|
||||
return
|
||||
|
||||
admin = User(
|
||||
username=app.config.get("ADMIN_USERNAME", "admin"),
|
||||
role="admin",
|
||||
is_active=True,
|
||||
)
|
||||
admin.set_password(app.config.get("ADMIN_PASSWORD", "admin"))
|
||||
|
||||
staff = User(
|
||||
username=app.config.get("STAFF_USERNAME", "staff"),
|
||||
role="staff",
|
||||
is_active=True,
|
||||
)
|
||||
staff.set_password(app.config.get("STAFF_PASSWORD", "staff"))
|
||||
|
||||
db.session.add(admin)
|
||||
db.session.add(staff)
|
||||
db.session.commit()
|
||||
|
||||
if app.config.get("ADMIN_PASSWORD") == "admin" or app.config.get("STAFF_PASSWORD") == "staff":
|
||||
logger.warning("Default users created with default passwords. Change them in production!")
|
||||
else:
|
||||
logger.info("Default users 'admin' and 'staff' created from environment variables.")
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
logger.warning(f"Default users creation skipped or failed: {e}")
|
||||
|
||||
@@ -1,16 +1,96 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from werkzeug.security import generate_password_hash, check_password_hash
|
||||
|
||||
from app import db
|
||||
|
||||
|
||||
class User(db.Model):
|
||||
__tablename__ = "users"
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
username = db.Column(db.String(100), unique=True, nullable=False)
|
||||
password_hash = db.Column(db.String(255), nullable=False)
|
||||
role = db.Column(db.String(20), nullable=False, default="staff")
|
||||
is_active = db.Column(db.Boolean, default=True, nullable=False)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
|
||||
|
||||
created_events = db.relationship("Event", backref="creator", lazy=True)
|
||||
|
||||
def set_password(self, password):
|
||||
self.password_hash = generate_password_hash(password)
|
||||
|
||||
def check_password(self, password):
|
||||
return check_password_hash(self.password_hash, password)
|
||||
|
||||
@property
|
||||
def is_admin(self):
|
||||
return self.role == "admin"
|
||||
|
||||
@property
|
||||
def is_organizer(self):
|
||||
return self.role == "organizer"
|
||||
|
||||
@property
|
||||
def is_staff(self):
|
||||
return self.role == "staff"
|
||||
|
||||
def can_manage_event(self, event):
|
||||
if self.is_admin:
|
||||
return True
|
||||
if self.is_organizer and event and event.created_by_id == self.id:
|
||||
return True
|
||||
return False
|
||||
|
||||
def __repr__(self):
|
||||
return f"<User {self.username} ({self.role})>"
|
||||
|
||||
|
||||
class Event(db.Model):
|
||||
__tablename__ = "events"
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
name = db.Column(db.String(255), nullable=False)
|
||||
description = db.Column(db.Text, nullable=True)
|
||||
event_date = db.Column(db.DateTime, nullable=True)
|
||||
location = db.Column(db.String(255), nullable=True)
|
||||
is_active = db.Column(db.Boolean, default=True, nullable=False)
|
||||
created_by_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=True)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
|
||||
|
||||
# Ticket design fields
|
||||
ticket_title = db.Column(db.String(255), nullable=True)
|
||||
ticket_subtitle = db.Column(db.String(255), nullable=True)
|
||||
ticket_body_text = db.Column(db.Text, nullable=True)
|
||||
ticket_background_color = db.Column(db.String(7), nullable=True, default="#ffffff")
|
||||
ticket_text_color = db.Column(db.String(7), nullable=True, default="#000000")
|
||||
ticket_accent_color = db.Column(db.String(7), nullable=True, default="#0d6efd")
|
||||
ticket_show_qr = db.Column(db.Boolean, default=True, nullable=False)
|
||||
ticket_show_photo = db.Column(db.Boolean, default=False, nullable=False)
|
||||
ticket_layout = db.Column(db.String(20), nullable=True, default="portrait")
|
||||
logo_filename = db.Column(db.String(255), nullable=True)
|
||||
|
||||
guests = db.relationship("Guest", backref="event", lazy=True, cascade="all, delete-orphan")
|
||||
check_ins = db.relationship("CheckIn", backref="event", lazy=True, cascade="all, delete-orphan")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Event {self.name}>"
|
||||
|
||||
|
||||
class Guest(db.Model):
|
||||
__tablename__ = "guests"
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
event_id = db.Column(db.Integer, db.ForeignKey("events.id"), nullable=False)
|
||||
department = db.Column(db.String(255), nullable=False)
|
||||
last_name = db.Column(db.String(255), nullable=True)
|
||||
first_name = db.Column(db.String(255), nullable=True)
|
||||
middle_name = db.Column(db.String(255), nullable=True)
|
||||
full_name = db.Column(db.String(255), nullable=False)
|
||||
position = db.Column(db.String(255), nullable=True)
|
||||
email = db.Column(db.String(255), nullable=False)
|
||||
phone = db.Column(db.String(50), nullable=True)
|
||||
photo_filename = db.Column(db.String(255), nullable=True)
|
||||
token = db.Column(db.String(36), unique=True, nullable=False, default=lambda: str(uuid.uuid4()))
|
||||
qr_filename = db.Column(db.String(255), nullable=True)
|
||||
@@ -31,6 +111,10 @@ class Guest(db.Model):
|
||||
return None
|
||||
return max(check_in.checked_in_at for check_in in self.check_ins)
|
||||
|
||||
def build_full_name(self):
|
||||
parts = [p for p in [self.last_name, self.first_name, self.middle_name] if p]
|
||||
return " ".join(parts) if parts else self.full_name
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Guest {self.full_name} ({self.email})>"
|
||||
|
||||
@@ -40,6 +124,7 @@ class CheckIn(db.Model):
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
guest_id = db.Column(db.Integer, db.ForeignKey("guests.id"), nullable=False)
|
||||
event_id = db.Column(db.Integer, db.ForeignKey("events.id"), nullable=True)
|
||||
checked_in_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
|
||||
device_info = db.Column(db.String(500), nullable=True)
|
||||
|
||||
|
||||
+476
-59
@@ -1,3 +1,4 @@
|
||||
import io
|
||||
import os
|
||||
import zipfile
|
||||
from datetime import datetime
|
||||
@@ -10,10 +11,11 @@ from flask import (
|
||||
from werkzeug.utils import secure_filename
|
||||
|
||||
from app import db
|
||||
from app.models import Guest, CheckIn
|
||||
from app.models import User, Event, Guest, CheckIn
|
||||
from app.services.qr_service import generate_qr_for_guest
|
||||
from app.services.email_service import send_invitation
|
||||
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
|
||||
|
||||
main_bp = Blueprint("main", __name__)
|
||||
|
||||
@@ -26,19 +28,31 @@ def allowed_file(filename, extensions):
|
||||
return "." in filename and filename.rsplit(".", 1)[1].lower() in extensions
|
||||
|
||||
|
||||
def login_user(role):
|
||||
session["role"] = role
|
||||
def current_user():
|
||||
user_id = session.get("user_id")
|
||||
if user_id:
|
||||
return User.query.get(user_id)
|
||||
return None
|
||||
|
||||
|
||||
def login_user(user):
|
||||
session["user_id"] = user.id
|
||||
session["role"] = user.role
|
||||
session.permanent = True
|
||||
|
||||
|
||||
def logout_user():
|
||||
session.pop("user_id", None)
|
||||
session.pop("role", None)
|
||||
session.pop("current_event_id", None)
|
||||
session.pop("checkin_event_id", None)
|
||||
|
||||
|
||||
def require_role(*roles):
|
||||
def decorator(f):
|
||||
def wrapped(*args, **kwargs):
|
||||
if session.get("role") not in roles:
|
||||
user = current_user()
|
||||
if not user or not user.is_active or user.role not in roles:
|
||||
flash("Требуется авторизация", "warning")
|
||||
return redirect(url_for("main.login"))
|
||||
return f(*args, **kwargs)
|
||||
@@ -47,6 +61,48 @@ def require_role(*roles):
|
||||
return decorator
|
||||
|
||||
|
||||
def require_login(f):
|
||||
def wrapped(*args, **kwargs):
|
||||
user = current_user()
|
||||
if not user or not user.is_active:
|
||||
flash("Требуется авторизация", "warning")
|
||||
return redirect(url_for("main.login"))
|
||||
return f(*args, **kwargs)
|
||||
wrapped.__name__ = f.__name__
|
||||
return wrapped
|
||||
|
||||
|
||||
def get_current_event():
|
||||
event_id = session.get("current_event_id")
|
||||
if event_id:
|
||||
user = current_user()
|
||||
event = Event.query.get(event_id)
|
||||
if event and user and user.can_manage_event(event):
|
||||
return event
|
||||
return None
|
||||
|
||||
|
||||
def require_event(f):
|
||||
"""Декоратор для маршрутов, требующих выбранного мероприятия (admin/organizer)."""
|
||||
def wrapped(*args, **kwargs):
|
||||
user = current_user()
|
||||
if not user:
|
||||
flash("Требуется авторизация", "warning")
|
||||
return redirect(url_for("main.login"))
|
||||
if not get_current_event():
|
||||
flash("Сначала выберите мероприятие", "warning")
|
||||
return redirect(url_for("main.admin_events"))
|
||||
return f(*args, **kwargs)
|
||||
wrapped.__name__ = f.__name__
|
||||
return wrapped
|
||||
|
||||
|
||||
def available_events_for_user(user):
|
||||
if user.is_admin:
|
||||
return Event.query.order_by(Event.created_at.desc())
|
||||
return Event.query.filter_by(created_by_id=user.id).order_by(Event.created_at.desc())
|
||||
|
||||
|
||||
# ---------------------------
|
||||
# Auth
|
||||
# ---------------------------
|
||||
@@ -56,14 +112,13 @@ def login():
|
||||
if request.method == "POST":
|
||||
username = request.form.get("username", "").strip()
|
||||
password = request.form.get("password", "").strip()
|
||||
config = current_app.config
|
||||
|
||||
if username == config["ADMIN_USERNAME"] and password == config["ADMIN_PASSWORD"]:
|
||||
login_user("admin")
|
||||
return redirect(url_for("main.admin_dashboard"))
|
||||
elif username == config.get("STAFF_USERNAME", "staff") and password == config.get("STAFF_PASSWORD", "staff"):
|
||||
login_user("staff")
|
||||
return redirect(url_for("main.check_in"))
|
||||
user = User.query.filter_by(username=username, is_active=True).first()
|
||||
if user and user.check_password(password):
|
||||
login_user(user)
|
||||
if user.is_staff:
|
||||
return redirect(url_for("main.check_in"))
|
||||
return redirect(url_for("main.admin_events"))
|
||||
else:
|
||||
flash("Неверный логин или пароль", "danger")
|
||||
|
||||
@@ -77,20 +132,207 @@ def logout():
|
||||
return redirect(url_for("main.login"))
|
||||
|
||||
|
||||
# ---------------------------
|
||||
# Events
|
||||
# ---------------------------
|
||||
|
||||
@main_bp.route("/admin/events")
|
||||
@require_role("admin", "organizer")
|
||||
def admin_events():
|
||||
user = current_user()
|
||||
events = available_events_for_user(user).all()
|
||||
for event in events:
|
||||
event.total_guests = Guest.query.filter_by(event_id=event.id, is_active=True).count()
|
||||
event.checked_in = db.session.query(CheckIn).join(Guest).filter(Guest.event_id == event.id).count()
|
||||
return render_template("admin/events.html", events=events)
|
||||
|
||||
|
||||
@main_bp.route("/admin/events/add", methods=["GET", "POST"])
|
||||
@require_role("admin", "organizer")
|
||||
def admin_event_add():
|
||||
if request.method == "POST":
|
||||
name = request.form.get("name", "").strip()
|
||||
description = request.form.get("description", "").strip()
|
||||
location = request.form.get("location", "").strip()
|
||||
event_date_str = request.form.get("event_date", "").strip()
|
||||
|
||||
if not name:
|
||||
flash("Название мероприятия обязательно", "danger")
|
||||
return redirect(url_for("main.admin_event_add"))
|
||||
|
||||
event_date = None
|
||||
if event_date_str:
|
||||
try:
|
||||
event_date = datetime.fromisoformat(event_date_str)
|
||||
except ValueError:
|
||||
flash("Неверный формат даты", "danger")
|
||||
return redirect(url_for("main.admin_event_add"))
|
||||
|
||||
event = Event(
|
||||
name=name,
|
||||
description=description,
|
||||
location=location,
|
||||
event_date=event_date,
|
||||
created_by_id=current_user().id,
|
||||
)
|
||||
db.session.add(event)
|
||||
db.session.commit()
|
||||
flash(f"Мероприятие «{event.name}» создано", "success")
|
||||
return redirect(url_for("main.admin_events"))
|
||||
|
||||
return render_template("admin/event_form.html", event=None)
|
||||
|
||||
|
||||
@main_bp.route("/admin/events/<int:event_id>/edit", methods=["GET", "POST"])
|
||||
@require_role("admin", "organizer")
|
||||
def admin_event_edit(event_id):
|
||||
event = Event.query.get_or_404(event_id)
|
||||
user = current_user()
|
||||
if not user.can_manage_event(event):
|
||||
flash("Нет доступа к этому мероприятию", "danger")
|
||||
return redirect(url_for("main.admin_events"))
|
||||
|
||||
if request.method == "POST":
|
||||
event.name = request.form.get("name", "").strip()
|
||||
event.description = request.form.get("description", "").strip()
|
||||
event.location = request.form.get("location", "").strip()
|
||||
event_date_str = request.form.get("event_date", "").strip()
|
||||
|
||||
if not event.name:
|
||||
flash("Название мероприятия обязательно", "danger")
|
||||
return redirect(url_for("main.admin_event_edit", event_id=event.id))
|
||||
|
||||
if event_date_str:
|
||||
try:
|
||||
event.event_date = datetime.fromisoformat(event_date_str)
|
||||
except ValueError:
|
||||
flash("Неверный формат даты", "danger")
|
||||
return redirect(url_for("main.admin_event_edit", event_id=event.id))
|
||||
else:
|
||||
event.event_date = None
|
||||
|
||||
event.is_active = bool(request.form.get("is_active"))
|
||||
db.session.commit()
|
||||
flash("Мероприятие обновлено", "success")
|
||||
return redirect(url_for("main.admin_events"))
|
||||
|
||||
return render_template("admin/event_form.html", event=event)
|
||||
|
||||
|
||||
@main_bp.route("/admin/events/<int:event_id>/delete", methods=["POST"])
|
||||
@require_role("admin", "organizer")
|
||||
def admin_event_delete(event_id):
|
||||
event = Event.query.get_or_404(event_id)
|
||||
user = current_user()
|
||||
if not user.can_manage_event(event):
|
||||
flash("Нет доступа к этому мероприятию", "danger")
|
||||
return redirect(url_for("main.admin_events"))
|
||||
|
||||
if session.get("current_event_id") == event.id:
|
||||
session.pop("current_event_id", None)
|
||||
|
||||
event.is_active = False
|
||||
db.session.commit()
|
||||
flash("Мероприятие деактивировано", "success")
|
||||
return redirect(url_for("main.admin_events"))
|
||||
|
||||
|
||||
@main_bp.route("/admin/events/<int:event_id>/switch", methods=["POST"])
|
||||
@require_role("admin", "organizer")
|
||||
def admin_event_switch(event_id):
|
||||
event = Event.query.get_or_404(event_id)
|
||||
user = current_user()
|
||||
if not user.can_manage_event(event):
|
||||
flash("Нет доступа к этому мероприятию", "danger")
|
||||
return redirect(url_for("main.admin_events"))
|
||||
|
||||
session["current_event_id"] = event.id
|
||||
flash(f"Выбрано мероприятие: {event.name}", "info")
|
||||
return redirect(url_for("main.admin_dashboard"))
|
||||
|
||||
|
||||
@main_bp.route("/admin/events/<int:event_id>/ticket", methods=["GET", "POST"])
|
||||
@require_role("admin", "organizer")
|
||||
def admin_event_ticket(event_id):
|
||||
event = Event.query.get_or_404(event_id)
|
||||
user = current_user()
|
||||
if not user.can_manage_event(event):
|
||||
flash("Нет доступа к этому мероприятию", "danger")
|
||||
return redirect(url_for("main.admin_events"))
|
||||
|
||||
if request.method == "POST":
|
||||
event.ticket_title = request.form.get("ticket_title", "").strip() or event.name
|
||||
event.ticket_subtitle = request.form.get("ticket_subtitle", "").strip()
|
||||
event.ticket_body_text = request.form.get("ticket_body_text", "").strip()
|
||||
event.ticket_background_color = request.form.get("ticket_background_color", "#ffffff").strip()
|
||||
event.ticket_text_color = request.form.get("ticket_text_color", "#000000").strip()
|
||||
event.ticket_accent_color = request.form.get("ticket_accent_color", "#0d6efd").strip()
|
||||
event.ticket_show_qr = bool(request.form.get("ticket_show_qr"))
|
||||
event.ticket_show_photo = bool(request.form.get("ticket_show_photo"))
|
||||
event.ticket_layout = request.form.get("ticket_layout", "portrait").strip()
|
||||
|
||||
# Logo upload
|
||||
logo_file = request.files.get("logo_file")
|
||||
if logo_file and logo_file.filename:
|
||||
if allowed_file(logo_file.filename, {"png", "jpg", "jpeg", "gif", "webp", "bmp", "svg"}):
|
||||
filename = secure_filename(f"event_{event.id}_{logo_file.filename}")
|
||||
logo_folder = Path(current_app.config["LOGO_FOLDER"])
|
||||
logo_folder.mkdir(parents=True, exist_ok=True)
|
||||
logo_path = logo_folder / filename
|
||||
logo_file.save(str(logo_path))
|
||||
event.logo_filename = filename
|
||||
else:
|
||||
flash("Неверный формат логотипа. Разрешены: png, jpg, jpeg, gif, webp, bmp, svg.", "warning")
|
||||
|
||||
# Logo deletion
|
||||
if request.form.get("delete_logo") and event.logo_filename:
|
||||
old_logo = Path(current_app.config["LOGO_FOLDER"]) / event.logo_filename
|
||||
if old_logo.exists():
|
||||
old_logo.unlink()
|
||||
event.logo_filename = None
|
||||
|
||||
db.session.commit()
|
||||
flash("Дизайн билета сохранён", "success")
|
||||
return redirect(url_for("main.admin_event_ticket", event_id=event.id))
|
||||
|
||||
return render_template("admin/ticket_constructor.html", event=event)
|
||||
|
||||
|
||||
@main_bp.route("/admin/events/<int:event_id>/ticket/preview")
|
||||
@require_role("admin", "organizer")
|
||||
def admin_event_ticket_preview(event_id):
|
||||
event = Event.query.get_or_404(event_id)
|
||||
user = current_user()
|
||||
if not user.can_manage_event(event):
|
||||
flash("Нет доступа к этому мероприятию", "danger")
|
||||
return redirect(url_for("main.admin_events"))
|
||||
|
||||
try:
|
||||
ticket_path = generate_preview_ticket(event)
|
||||
return send_file(str(ticket_path), mimetype="image/png")
|
||||
except Exception as e:
|
||||
flash(f"Ошибка генерации предпросмотра: {str(e)}", "danger")
|
||||
return redirect(url_for("main.admin_event_ticket", event_id=event.id))
|
||||
|
||||
|
||||
# ---------------------------
|
||||
# Admin Dashboard
|
||||
# ---------------------------
|
||||
|
||||
@main_bp.route("/admin")
|
||||
@require_role("admin")
|
||||
@require_role("admin", "organizer")
|
||||
@require_event
|
||||
def admin_dashboard():
|
||||
total = Guest.query.filter_by(is_active=True).count()
|
||||
checked_in = db.session.query(CheckIn).count()
|
||||
event = get_current_event()
|
||||
total = Guest.query.filter_by(event_id=event.id, is_active=True).count()
|
||||
checked_in = db.session.query(CheckIn).join(Guest).filter(Guest.event_id == event.id).count()
|
||||
not_checked_in = total - checked_in
|
||||
attendance_percent = round((checked_in / total) * 100, 1) if total else 0
|
||||
|
||||
recent_check_ins = (
|
||||
CheckIn.query
|
||||
.join(Guest)
|
||||
.filter(Guest.event_id == event.id)
|
||||
.order_by(CheckIn.checked_in_at.desc())
|
||||
.limit(10)
|
||||
.all()
|
||||
@@ -98,6 +340,7 @@ def admin_dashboard():
|
||||
|
||||
return render_template(
|
||||
"admin/dashboard.html",
|
||||
event=event,
|
||||
total=total,
|
||||
checked_in=checked_in,
|
||||
not_checked_in=not_checked_in,
|
||||
@@ -111,20 +354,19 @@ def admin_dashboard():
|
||||
# ---------------------------
|
||||
|
||||
@main_bp.route("/admin/upload", methods=["GET", "POST"])
|
||||
@require_role("admin")
|
||||
@require_role("admin", "organizer")
|
||||
@require_event
|
||||
def admin_upload():
|
||||
event = get_current_event()
|
||||
|
||||
if request.method == "POST":
|
||||
# Excel upload
|
||||
excel_file = request.files.get("excel_file")
|
||||
if excel_file and excel_file.filename:
|
||||
if allowed_file(excel_file.filename, {"xlsx", "xls"}):
|
||||
filename = secure_filename(excel_file.filename)
|
||||
upload_path = Path(current_app.config["UPLOAD_FOLDER"]) / "temp" / filename
|
||||
upload_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
excel_file.save(str(upload_path))
|
||||
|
||||
try:
|
||||
result = import_guests_from_excel(str(upload_path))
|
||||
excel_stream = io.BytesIO(excel_file.read())
|
||||
result = import_guests_from_excel(excel_stream, event_id=event.id)
|
||||
flash(
|
||||
f"Импорт завершён: добавлено {result['imported']}, обновлено {result['updated']} из {result['total']}",
|
||||
"success",
|
||||
@@ -132,7 +374,7 @@ def admin_upload():
|
||||
|
||||
# Generate QR codes for guests without QR
|
||||
base_url = request.form.get("base_url", "").strip() or None
|
||||
guests = Guest.query.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:
|
||||
generate_qr_for_guest(guest, base_url=base_url)
|
||||
db.session.commit()
|
||||
@@ -140,9 +382,6 @@ def admin_upload():
|
||||
flash(f"Сгенерировано QR-кодов: {len(guests)}", "success")
|
||||
except Exception as e:
|
||||
flash(f"Ошибка импорта Excel: {str(e)}", "danger")
|
||||
finally:
|
||||
if upload_path.exists():
|
||||
upload_path.unlink()
|
||||
else:
|
||||
flash("Неверный формат Excel-файла", "danger")
|
||||
|
||||
@@ -182,7 +421,7 @@ def admin_upload():
|
||||
|
||||
return redirect(url_for("main.admin_upload"))
|
||||
|
||||
return render_template("admin/upload.html")
|
||||
return render_template("admin/upload.html", event=event)
|
||||
|
||||
|
||||
# ---------------------------
|
||||
@@ -190,25 +429,44 @@ def admin_upload():
|
||||
# ---------------------------
|
||||
|
||||
@main_bp.route("/admin/guests")
|
||||
@require_role("admin")
|
||||
@require_role("admin", "organizer")
|
||||
@require_event
|
||||
def admin_guests():
|
||||
event = get_current_event()
|
||||
search = request.args.get("q", "").strip()
|
||||
query = Guest.query
|
||||
query = Guest.query.filter_by(event_id=event.id)
|
||||
if search:
|
||||
query = query.filter(Guest.full_name.ilike(f"%{search}%"))
|
||||
guests = query.order_by(Guest.full_name).all()
|
||||
return render_template("admin/guests.html", guests=guests, search=search)
|
||||
return render_template("admin/guests.html", event=event, guests=guests, search=search)
|
||||
|
||||
|
||||
@main_bp.route("/admin/guests/add", methods=["GET", "POST"])
|
||||
@require_role("admin")
|
||||
@require_role("admin", "organizer")
|
||||
@require_event
|
||||
def admin_guest_add():
|
||||
event = get_current_event()
|
||||
|
||||
if request.method == "POST":
|
||||
last_name = request.form.get("last_name", "").strip()
|
||||
first_name = request.form.get("first_name", "").strip()
|
||||
middle_name = request.form.get("middle_name", "").strip()
|
||||
full_name = " ".join([p for p in [last_name, first_name, middle_name] if p])
|
||||
|
||||
if not full_name:
|
||||
flash("Фамилия и имя обязательны", "danger")
|
||||
return redirect(url_for("main.admin_guest_add"))
|
||||
|
||||
guest = Guest(
|
||||
event_id=event.id,
|
||||
department=request.form.get("department", "").strip(),
|
||||
full_name=request.form.get("full_name", "").strip(),
|
||||
last_name=last_name,
|
||||
first_name=first_name,
|
||||
middle_name=middle_name,
|
||||
full_name=full_name,
|
||||
position=request.form.get("position", "").strip(),
|
||||
email=request.form.get("email", "").strip(),
|
||||
phone=request.form.get("phone", "").strip(),
|
||||
photo_filename=request.form.get("photo_filename", "").strip(),
|
||||
)
|
||||
db.session.add(guest)
|
||||
@@ -229,30 +487,41 @@ def admin_guest_add():
|
||||
|
||||
return redirect(url_for("main.admin_guests"))
|
||||
|
||||
return render_template("admin/guest_form.html", guest=None)
|
||||
return render_template("admin/guest_form.html", event=event, guest=None)
|
||||
|
||||
|
||||
@main_bp.route("/admin/guests/<int:guest_id>/edit", methods=["GET", "POST"])
|
||||
@require_role("admin")
|
||||
@require_role("admin", "organizer")
|
||||
@require_event
|
||||
def admin_guest_edit(guest_id):
|
||||
guest = Guest.query.get_or_404(guest_id)
|
||||
event = get_current_event()
|
||||
guest = Guest.query.filter_by(id=guest_id, event_id=event.id).first_or_404()
|
||||
|
||||
if request.method == "POST":
|
||||
guest.department = request.form.get("department", "").strip()
|
||||
guest.full_name = request.form.get("full_name", "").strip()
|
||||
guest.last_name = request.form.get("last_name", "").strip()
|
||||
guest.first_name = request.form.get("first_name", "").strip()
|
||||
guest.middle_name = request.form.get("middle_name", "").strip()
|
||||
guest.full_name = " ".join([
|
||||
p for p in [guest.last_name, guest.first_name, guest.middle_name] if p
|
||||
]) or guest.full_name
|
||||
guest.position = request.form.get("position", "").strip()
|
||||
guest.email = request.form.get("email", "").strip()
|
||||
guest.phone = request.form.get("phone", "").strip()
|
||||
guest.photo_filename = request.form.get("photo_filename", "").strip()
|
||||
db.session.commit()
|
||||
flash("Данные гостя обновлены", "success")
|
||||
return redirect(url_for("main.admin_guests"))
|
||||
|
||||
return render_template("admin/guest_form.html", guest=guest)
|
||||
return render_template("admin/guest_form.html", event=event, guest=guest)
|
||||
|
||||
|
||||
@main_bp.route("/admin/guests/<int:guest_id>/delete", methods=["POST"])
|
||||
@require_role("admin")
|
||||
@require_role("admin", "organizer")
|
||||
@require_event
|
||||
def admin_guest_delete(guest_id):
|
||||
guest = Guest.query.get_or_404(guest_id)
|
||||
event = get_current_event()
|
||||
guest = Guest.query.filter_by(id=guest_id, event_id=event.id).first_or_404()
|
||||
guest.is_active = False
|
||||
db.session.commit()
|
||||
flash("Гость заблокирован (QR-код аннулирован)", "success")
|
||||
@@ -260,9 +529,11 @@ def admin_guest_delete(guest_id):
|
||||
|
||||
|
||||
@main_bp.route("/admin/guests/<int:guest_id>/resend", methods=["POST"])
|
||||
@require_role("admin")
|
||||
@require_role("admin", "organizer")
|
||||
@require_event
|
||||
def admin_guest_resend(guest_id):
|
||||
guest = Guest.query.get_or_404(guest_id)
|
||||
event = get_current_event()
|
||||
guest = Guest.query.filter_by(id=guest_id, event_id=event.id).first_or_404()
|
||||
if not guest.qr_filename:
|
||||
generate_qr_for_guest(guest)
|
||||
db.session.commit()
|
||||
@@ -275,9 +546,11 @@ def admin_guest_resend(guest_id):
|
||||
|
||||
|
||||
@main_bp.route("/admin/send-invitations", methods=["POST"])
|
||||
@require_role("admin")
|
||||
@require_role("admin", "organizer")
|
||||
@require_event
|
||||
def admin_send_invitations():
|
||||
guests = Guest.query.filter_by(is_active=True, invitation_sent=False).all()
|
||||
event = get_current_event()
|
||||
guests = Guest.query.filter_by(event_id=event.id, is_active=True, invitation_sent=False).all()
|
||||
sent = 0
|
||||
failed = 0
|
||||
for guest in guests:
|
||||
@@ -298,42 +571,156 @@ def admin_send_invitations():
|
||||
# ---------------------------
|
||||
|
||||
@main_bp.route("/admin/report")
|
||||
@require_role("admin")
|
||||
@require_role("admin", "organizer")
|
||||
@require_event
|
||||
def admin_report():
|
||||
event = get_current_event()
|
||||
report_path = Path(current_app.config["UPLOAD_FOLDER"]) / "reports"
|
||||
report_path.mkdir(parents=True, exist_ok=True)
|
||||
filename = f"report_{datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx"
|
||||
filename = f"report_{event.id}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx"
|
||||
output_path = report_path / filename
|
||||
|
||||
try:
|
||||
export_report_to_excel(str(output_path))
|
||||
export_report_to_excel(str(output_path), event_id=event.id, event_name=event.name)
|
||||
return send_file(str(output_path), as_attachment=True, download_name=filename)
|
||||
except Exception as e:
|
||||
flash(f"Ошибка формирования отчёта: {str(e)}", "danger")
|
||||
return redirect(url_for("main.admin_dashboard"))
|
||||
|
||||
|
||||
# ---------------------------
|
||||
# User management (admin only)
|
||||
# ---------------------------
|
||||
|
||||
@main_bp.route("/admin/users")
|
||||
@require_role("admin")
|
||||
def admin_users():
|
||||
users = User.query.order_by(User.created_at.desc()).all()
|
||||
return render_template("admin/users.html", users=users)
|
||||
|
||||
|
||||
@main_bp.route("/admin/users/add", methods=["GET", "POST"])
|
||||
@require_role("admin")
|
||||
def admin_user_add():
|
||||
if request.method == "POST":
|
||||
username = request.form.get("username", "").strip()
|
||||
password = request.form.get("password", "").strip()
|
||||
role = request.form.get("role", "staff").strip()
|
||||
|
||||
if not username or not password:
|
||||
flash("Логин и пароль обязательны", "danger")
|
||||
return redirect(url_for("main.admin_user_add"))
|
||||
|
||||
if role not in ("admin", "organizer", "staff"):
|
||||
flash("Неверная роль", "danger")
|
||||
return redirect(url_for("main.admin_user_add"))
|
||||
|
||||
if User.query.filter_by(username=username).first():
|
||||
flash("Пользователь с таким логином уже существует", "danger")
|
||||
return redirect(url_for("main.admin_user_add"))
|
||||
|
||||
user = User(username=username, role=role, is_active=True)
|
||||
user.set_password(password)
|
||||
db.session.add(user)
|
||||
db.session.commit()
|
||||
flash(f"Пользователь {username} создан", "success")
|
||||
return redirect(url_for("main.admin_users"))
|
||||
|
||||
return render_template("admin/user_form.html", user=None)
|
||||
|
||||
|
||||
@main_bp.route("/admin/users/<int:user_id>/edit", methods=["GET", "POST"])
|
||||
@require_role("admin")
|
||||
def admin_user_edit(user_id):
|
||||
user = User.query.get_or_404(user_id)
|
||||
if request.method == "POST":
|
||||
username = request.form.get("username", "").strip()
|
||||
password = request.form.get("password", "").strip()
|
||||
role = request.form.get("role", user.role).strip()
|
||||
is_active = bool(request.form.get("is_active"))
|
||||
|
||||
if not username:
|
||||
flash("Логин обязателен", "danger")
|
||||
return redirect(url_for("main.admin_user_edit", user_id=user.id))
|
||||
|
||||
if role not in ("admin", "organizer", "staff"):
|
||||
flash("Неверная роль", "danger")
|
||||
return redirect(url_for("main.admin_user_edit", user_id=user.id))
|
||||
|
||||
existing = User.query.filter(User.username == username, User.id != user.id).first()
|
||||
if existing:
|
||||
flash("Пользователь с таким логином уже существует", "danger")
|
||||
return redirect(url_for("main.admin_user_edit", user_id=user.id))
|
||||
|
||||
user.username = username
|
||||
user.role = role
|
||||
user.is_active = is_active
|
||||
if password:
|
||||
user.set_password(password)
|
||||
db.session.commit()
|
||||
flash("Пользователь обновлён", "success")
|
||||
return redirect(url_for("main.admin_users"))
|
||||
|
||||
return render_template("admin/user_form.html", user=user)
|
||||
|
||||
|
||||
@main_bp.route("/admin/users/<int:user_id>/delete", methods=["POST"])
|
||||
@require_role("admin")
|
||||
def admin_user_delete(user_id):
|
||||
user = User.query.get_or_404(user_id)
|
||||
if user.id == current_user().id:
|
||||
flash("Нельзя удалить самого себя", "danger")
|
||||
return redirect(url_for("main.admin_users"))
|
||||
|
||||
user.is_active = False
|
||||
db.session.commit()
|
||||
flash("Пользователь заблокирован", "success")
|
||||
return redirect(url_for("main.admin_users"))
|
||||
|
||||
|
||||
# ---------------------------
|
||||
# Check-in
|
||||
# ---------------------------
|
||||
|
||||
@main_bp.route("/check-in")
|
||||
@require_role("admin", "staff")
|
||||
@main_bp.route("/check-in", methods=["GET", "POST"])
|
||||
@require_role("admin", "organizer", "staff")
|
||||
def check_in():
|
||||
user = current_user()
|
||||
token = request.args.get("token", "").strip()
|
||||
guest = None
|
||||
already_checked_in = False
|
||||
|
||||
active_events = Event.query.filter_by(is_active=True).order_by(Event.created_at.desc()).all()
|
||||
|
||||
if request.method == "POST":
|
||||
event_id = request.form.get("event_id", "").strip()
|
||||
if event_id:
|
||||
try:
|
||||
session["checkin_event_id"] = int(event_id)
|
||||
except ValueError:
|
||||
pass
|
||||
return redirect(url_for("main.check_in", token=token))
|
||||
|
||||
checkin_event_id = session.get("checkin_event_id")
|
||||
checkin_event = None
|
||||
if checkin_event_id:
|
||||
checkin_event = Event.query.get(checkin_event_id)
|
||||
|
||||
if token:
|
||||
guest = Guest.query.filter_by(token=token, is_active=True).first()
|
||||
if guest:
|
||||
already_checked_in = guest.is_checked_in
|
||||
if checkin_event is None:
|
||||
checkin_event = guest.event
|
||||
session["checkin_event_id"] = guest.event_id
|
||||
|
||||
return render_template(
|
||||
"checkin/index.html",
|
||||
guest=guest,
|
||||
token=token,
|
||||
already_checked_in=already_checked_in,
|
||||
active_events=active_events,
|
||||
checkin_event=checkin_event,
|
||||
)
|
||||
|
||||
|
||||
@@ -342,15 +729,23 @@ def check_in():
|
||||
# ---------------------------
|
||||
|
||||
@main_bp.route("/api/guests/search")
|
||||
@require_role("admin", "staff")
|
||||
@require_role("admin", "organizer", "staff")
|
||||
def api_guests_search():
|
||||
q = request.args.get("q", "").strip()
|
||||
event_id = request.args.get("event_id") or session.get("checkin_event_id")
|
||||
|
||||
if len(q) < 2:
|
||||
return jsonify([])
|
||||
|
||||
query = Guest.query.filter(Guest.is_active == True)
|
||||
if event_id:
|
||||
try:
|
||||
query = query.filter(Guest.event_id == int(event_id))
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
guests = (
|
||||
Guest.query
|
||||
.filter(Guest.is_active == True)
|
||||
query
|
||||
.filter(Guest.full_name.ilike(f"%{q}%"))
|
||||
.order_by(Guest.full_name)
|
||||
.limit(20)
|
||||
@@ -360,20 +755,23 @@ def api_guests_search():
|
||||
return jsonify([
|
||||
{
|
||||
"id": g.id,
|
||||
"full_name": g.full_name,
|
||||
"full_name": g.build_full_name(),
|
||||
"department": g.department,
|
||||
"position": g.position,
|
||||
"email": g.email,
|
||||
"phone": g.phone,
|
||||
"photo_url": url_for("static", filename=f"uploads/photos/{g.photo_filename}") if g.photo_filename else "",
|
||||
"token": g.token,
|
||||
"checked_in": g.is_checked_in,
|
||||
"event_id": g.event_id,
|
||||
"event_name": g.event.name if g.event else "",
|
||||
}
|
||||
for g in guests
|
||||
])
|
||||
|
||||
|
||||
@main_bp.route("/api/check-in/<token>", methods=["POST"])
|
||||
@require_role("admin", "staff")
|
||||
@require_role("admin", "organizer", "staff")
|
||||
def api_check_in(token):
|
||||
guest = Guest.query.filter_by(token=token, is_active=True).first()
|
||||
if not guest:
|
||||
@@ -389,6 +787,7 @@ def api_check_in(token):
|
||||
|
||||
check_in_record = CheckIn(
|
||||
guest_id=guest.id,
|
||||
event_id=guest.event_id,
|
||||
device_info=request.headers.get("User-Agent", "")[:500],
|
||||
)
|
||||
db.session.add(check_in_record)
|
||||
@@ -399,27 +798,31 @@ def api_check_in(token):
|
||||
"message": "Вход отмечен",
|
||||
"checked_in_at": check_in_record.checked_in_at.isoformat(),
|
||||
"guest": {
|
||||
"full_name": guest.full_name,
|
||||
"full_name": guest.build_full_name(),
|
||||
"department": guest.department,
|
||||
"position": guest.position,
|
||||
"phone": guest.phone,
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@main_bp.route("/api/guest/<token>")
|
||||
@require_role("admin", "staff")
|
||||
@require_role("admin", "organizer", "staff")
|
||||
def api_guest_detail(token):
|
||||
guest = Guest.query.filter_by(token=token, is_active=True).first_or_404()
|
||||
return jsonify({
|
||||
"id": guest.id,
|
||||
"full_name": guest.full_name,
|
||||
"full_name": guest.build_full_name(),
|
||||
"department": guest.department,
|
||||
"position": guest.position,
|
||||
"email": guest.email,
|
||||
"phone": guest.phone,
|
||||
"photo_url": url_for("static", filename=f"uploads/photos/{guest.photo_filename}") if guest.photo_filename else "",
|
||||
"token": guest.token,
|
||||
"checked_in": guest.is_checked_in,
|
||||
"checked_in_at": guest.last_check_in.isoformat() if guest.last_check_in else None,
|
||||
"event_id": guest.event_id,
|
||||
"event_name": guest.event.name if guest.event else "",
|
||||
})
|
||||
|
||||
|
||||
@@ -429,8 +832,22 @@ def api_guest_detail(token):
|
||||
|
||||
@main_bp.route("/")
|
||||
def index():
|
||||
if session.get("role") == "admin":
|
||||
return redirect(url_for("main.admin_dashboard"))
|
||||
elif session.get("role") == "staff":
|
||||
user = current_user()
|
||||
if not user:
|
||||
return redirect(url_for("main.login"))
|
||||
if user.is_staff:
|
||||
return redirect(url_for("main.check_in"))
|
||||
return redirect(url_for("main.login"))
|
||||
return redirect(url_for("main.admin_events"))
|
||||
|
||||
|
||||
@main_bp.app_context_processor
|
||||
def inject_globals():
|
||||
user = current_user()
|
||||
events = []
|
||||
current_event = None
|
||||
if user and (user.is_admin or user.is_organizer):
|
||||
events = available_events_for_user(user).filter_by(is_active=True).all()
|
||||
event_id = session.get("current_event_id")
|
||||
if event_id:
|
||||
current_event = Event.query.get(event_id)
|
||||
return dict(current_user=user, available_events=events, current_event=current_event)
|
||||
|
||||
@@ -6,16 +6,28 @@ from email.utils import formataddr
|
||||
from pathlib import Path
|
||||
from flask import current_app, render_template
|
||||
|
||||
from app.services.ticket_service import generate_ticket_image
|
||||
|
||||
def send_invitation(guest, event_name="Корпоратив", additional_text=""):
|
||||
"""Отправляет приглашение гостю на email с QR-кодом во вложении."""
|
||||
|
||||
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 "Мероприятие"
|
||||
|
||||
msg = MIMEMultipart("related")
|
||||
msg["Subject"] = f"Приглашение на мероприятие: {event_name}"
|
||||
msg["From"] = formataddr(("InviteMe", config.get("MAIL_DEFAULT_SENDER", config["MAIL_USERNAME"])))
|
||||
msg["To"] = guest.email
|
||||
|
||||
# Генерируем билет
|
||||
try:
|
||||
ticket_path = generate_ticket_image(guest, guest.event)
|
||||
except Exception as e:
|
||||
ticket_path = None
|
||||
current_app.logger.warning(f"Не удалось сгенерировать билет для {guest.email}: {e}")
|
||||
|
||||
# HTML-шаблон письма
|
||||
html_body = render_template(
|
||||
"email/invitation.html",
|
||||
@@ -23,6 +35,7 @@ def send_invitation(guest, event_name="Корпоратив", additional_text=""
|
||||
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"))
|
||||
@@ -36,6 +49,14 @@ def send_invitation(guest, event_name="Корпоратив", additional_text=""
|
||||
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"]
|
||||
|
||||
@@ -5,17 +5,27 @@ from app import db
|
||||
from app.models import Guest
|
||||
|
||||
|
||||
REQUIRED_COLUMNS = ["Отдел", "ФИО", "Должность", "e-mail", "название фото"]
|
||||
REQUIRED_COLUMNS = ["Отдел", "Фамилия", "Имя", "Отчество", "Должность", "Почта", "Телефон", "Название фото"]
|
||||
|
||||
|
||||
def _build_full_name(last_name, first_name, middle_name):
|
||||
parts = [p for p in [last_name, first_name, middle_name] if p]
|
||||
return " ".join(parts)
|
||||
|
||||
|
||||
def read_guests_from_excel(file_path):
|
||||
"""Читает список гостей из Excel-файла."""
|
||||
file_path = Path(file_path)
|
||||
"""Читает список гостей из Excel-файла (путь или файловый объект)."""
|
||||
if not hasattr(file_path, "read"):
|
||||
file_path = Path(file_path)
|
||||
wb = load_workbook(file_path, data_only=True)
|
||||
ws = wb.active
|
||||
try:
|
||||
ws = wb.active
|
||||
|
||||
# Определяем заголовки
|
||||
headers = [str(cell.value).strip() if cell.value else "" for cell in ws[1]]
|
||||
# Определяем заголовки и сразу считываем все строки
|
||||
headers = [str(cell.value).strip() if cell.value else "" for cell in ws[1]]
|
||||
rows = list(ws.iter_rows(min_row=2, values_only=True))
|
||||
finally:
|
||||
wb.close()
|
||||
|
||||
missing = [col for col in REQUIRED_COLUMNS if col not in headers]
|
||||
if missing:
|
||||
@@ -24,41 +34,70 @@ def read_guests_from_excel(file_path):
|
||||
col_indices = {col: headers.index(col) for col in REQUIRED_COLUMNS}
|
||||
guests_data = []
|
||||
|
||||
for row in ws.iter_rows(min_row=2, values_only=True):
|
||||
full_name = str(row[col_indices["ФИО"]]).strip() if row[col_indices["ФИО"]] else ""
|
||||
email = str(row[col_indices["e-mail"]]).strip() if row[col_indices["e-mail"]] else ""
|
||||
for row in rows:
|
||||
last_name = str(row[col_indices["Фамилия"]]).strip() if row[col_indices["Фамилия"]] else ""
|
||||
first_name = str(row[col_indices["Имя"]]).strip() if row[col_indices["Имя"]] else ""
|
||||
middle_name = str(row[col_indices["Отчество"]]).strip() if row[col_indices["Отчество"]] else ""
|
||||
email = str(row[col_indices["Почта"]]).strip() if row[col_indices["Почта"]] else ""
|
||||
|
||||
if not full_name or not email:
|
||||
if not email:
|
||||
continue
|
||||
|
||||
full_name = _build_full_name(last_name, first_name, middle_name)
|
||||
if not full_name:
|
||||
# Если ФИО не заполнено, но есть email — пропускаем строку
|
||||
continue
|
||||
|
||||
guests_data.append({
|
||||
"department": str(row[col_indices["Отдел"]]).strip() if row[col_indices["Отдел"]] else "",
|
||||
"last_name": last_name,
|
||||
"first_name": first_name,
|
||||
"middle_name": middle_name,
|
||||
"full_name": full_name,
|
||||
"position": str(row[col_indices["Должность"]]).strip() if row[col_indices["Должность"]] else "",
|
||||
"email": email,
|
||||
"photo_filename": str(row[col_indices["название фото"]]).strip() if row[col_indices["название фото"]] else "",
|
||||
"phone": str(row[col_indices["Телефон"]]).strip() if row[col_indices["Телефон"]] else "",
|
||||
"photo_filename": str(row[col_indices["Название фото"]]).strip() if row[col_indices["Название фото"]] else "",
|
||||
})
|
||||
|
||||
return guests_data
|
||||
|
||||
|
||||
def import_guests_from_excel(file_path):
|
||||
"""Импортирует гостей из Excel в базу данных."""
|
||||
def import_guests_from_excel(file_path, event_id):
|
||||
"""Импортирует гостей из Excel в базу данных в рамках указанного мероприятия."""
|
||||
if not event_id:
|
||||
raise ValueError("Не выбрано мероприятие для импорта")
|
||||
|
||||
guests_data = read_guests_from_excel(file_path)
|
||||
imported = 0
|
||||
updated = 0
|
||||
|
||||
for data in guests_data:
|
||||
guest = Guest.query.filter_by(email=data["email"]).first()
|
||||
guest = Guest.query.filter_by(event_id=event_id, email=data["email"]).first()
|
||||
if guest:
|
||||
guest.department = data["department"]
|
||||
guest.last_name = data["last_name"]
|
||||
guest.first_name = data["first_name"]
|
||||
guest.middle_name = data["middle_name"]
|
||||
guest.full_name = data["full_name"]
|
||||
guest.position = data["position"]
|
||||
guest.phone = data["phone"]
|
||||
guest.photo_filename = data["photo_filename"]
|
||||
guest.is_active = True
|
||||
updated += 1
|
||||
else:
|
||||
guest = Guest(**data)
|
||||
guest = Guest(
|
||||
event_id=event_id,
|
||||
department=data["department"],
|
||||
last_name=data["last_name"],
|
||||
first_name=data["first_name"],
|
||||
middle_name=data["middle_name"],
|
||||
full_name=data["full_name"],
|
||||
position=data["position"],
|
||||
email=data["email"],
|
||||
phone=data["phone"],
|
||||
photo_filename=data["photo_filename"],
|
||||
)
|
||||
db.session.add(guest)
|
||||
imported += 1
|
||||
|
||||
@@ -66,15 +105,21 @@ def import_guests_from_excel(file_path):
|
||||
return {"imported": imported, "updated": updated, "total": len(guests_data)}
|
||||
|
||||
|
||||
def export_report_to_excel(output_path, event_name="Корпоратив"):
|
||||
"""Выгружает отчёт о посещаемости в Excel."""
|
||||
guests = Guest.query.order_by(Guest.department, Guest.full_name).all()
|
||||
def export_report_to_excel(output_path, event_id, event_name=None):
|
||||
"""Выгружает отчёт о посещаемости для мероприятия в Excel."""
|
||||
if not event_id:
|
||||
raise ValueError("Не выбрано мероприятие для отчёта")
|
||||
|
||||
guests = Guest.query.filter_by(event_id=event_id).order_by(Guest.department, Guest.full_name).all()
|
||||
|
||||
wb = Workbook()
|
||||
ws = wb.active
|
||||
ws.title = "Отчёт"
|
||||
|
||||
headers = ["Отдел", "ФИО", "Должность", "Email", "Название фото", "Статус", "Время входа"]
|
||||
headers = [
|
||||
"Мероприятие", "Отдел", "Фамилия", "Имя", "Отчество",
|
||||
"Должность", "Email", "Телефон", "Название фото", "Статус", "Время входа"
|
||||
]
|
||||
ws.append(headers)
|
||||
|
||||
# Стили заголовков
|
||||
@@ -87,10 +132,14 @@ def export_report_to_excel(output_path, event_name="Корпоратив"):
|
||||
status = "Пришёл" if guest.is_checked_in else "Не пришёл"
|
||||
check_in_time = guest.last_check_in.strftime("%d.%m.%Y %H:%M:%S") if guest.last_check_in else ""
|
||||
ws.append([
|
||||
event_name or "",
|
||||
guest.department,
|
||||
guest.full_name,
|
||||
guest.last_name or "",
|
||||
guest.first_name or "",
|
||||
guest.middle_name or "",
|
||||
guest.position or "",
|
||||
guest.email,
|
||||
guest.phone or "",
|
||||
guest.photo_filename or "",
|
||||
status,
|
||||
check_in_time,
|
||||
|
||||
@@ -0,0 +1,369 @@
|
||||
import io
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
from flask import current_app
|
||||
|
||||
from app.services.qr_service import generate_qr_for_guest
|
||||
|
||||
|
||||
TICKET_SIZE = (650, 1150)
|
||||
|
||||
NON_TRANSFERABLE_TEXT = (
|
||||
"Билет действителен только для указанного гостя. "
|
||||
"Передача третьим лицам запрещена."
|
||||
)
|
||||
|
||||
|
||||
def _load_logo_image(logo_path):
|
||||
"""Загружает логотип: PNG/JPG через Pillow, SVG через svglib + reportlab."""
|
||||
if str(logo_path).lower().endswith(".svg"):
|
||||
try:
|
||||
from svglib.svglib import svg2rlg
|
||||
from reportlab.graphics import renderPM
|
||||
|
||||
drawing = svg2rlg(str(logo_path))
|
||||
if drawing is None:
|
||||
raise ValueError("Не удалось распарсить SVG")
|
||||
|
||||
# svglib может оставить нулевые размеры; подстраховываемся
|
||||
orig_width = drawing.width or 0
|
||||
orig_height = drawing.height or 0
|
||||
scale = max(orig_width, orig_height) / 1000
|
||||
if scale > 0:
|
||||
drawing.width /= scale
|
||||
drawing.height /= scale
|
||||
drawing.scale(1 / scale, 1 / scale)
|
||||
|
||||
png_data = renderPM.drawToString(drawing, fmt="PNG", dpi=150)
|
||||
return Image.open(io.BytesIO(png_data)).convert("RGBA")
|
||||
except Exception as e:
|
||||
current_app.logger.warning(f"Не удалось конвертировать SVG {logo_path}: {e}")
|
||||
return None
|
||||
else:
|
||||
return Image.open(str(logo_path)).convert("RGBA")
|
||||
|
||||
|
||||
def _draw_logo_centered(img, event, y, max_width):
|
||||
"""Рисует логотип по центру, возвращает новую y-координату."""
|
||||
if not event.logo_filename:
|
||||
return y
|
||||
|
||||
logo_path = Path(current_app.config["LOGO_FOLDER"]) / event.logo_filename
|
||||
if not logo_path.exists():
|
||||
current_app.logger.warning(f"Логотип не найден: {logo_path}")
|
||||
return y
|
||||
|
||||
try:
|
||||
logo = _load_logo_image(logo_path)
|
||||
if logo is None:
|
||||
return y
|
||||
|
||||
# Масштабируем по ширине
|
||||
if logo.width <= 0 or logo.height <= 0:
|
||||
current_app.logger.warning(f"Логотип имеет некорректные размеры: {logo.width}x{logo.height}")
|
||||
return y
|
||||
|
||||
ratio = logo.width / logo.height
|
||||
new_width = min(max_width, logo.width)
|
||||
new_height = int(new_width / ratio)
|
||||
logo = logo.resize((new_width, new_height), Image.Resampling.LANCZOS)
|
||||
|
||||
x = (TICKET_SIZE[0] - new_width) // 2
|
||||
img.paste(logo, (x, y), logo)
|
||||
return y + new_height + 25
|
||||
except Exception as e:
|
||||
current_app.logger.warning(f"Не удалось отрисовать логотип {event.logo_filename}: {e}")
|
||||
return y
|
||||
|
||||
|
||||
def hex_to_rgb(hex_color):
|
||||
"""Конвертирует HEX-цвет в кортеж RGB."""
|
||||
hex_color = hex_color.lstrip("#")
|
||||
if len(hex_color) != 6:
|
||||
return (255, 255, 255)
|
||||
return tuple(int(hex_color[i:i + 2], 16) for i in (0, 2, 4))
|
||||
|
||||
|
||||
def _adjust_color(color, factor=0.85):
|
||||
"""Затемняет или осветляет RGB-цвет."""
|
||||
rgb = color if isinstance(color, tuple) else hex_to_rgb(color)
|
||||
return tuple(min(255, max(0, int(c * factor))) for c in rgb)
|
||||
|
||||
|
||||
def _get_font(size, style="regular"):
|
||||
"""Пытается загрузить системный шрифт."""
|
||||
candidates = {
|
||||
"regular": ["calibri.ttf", "segoeui.ttf", "arial.ttf"],
|
||||
"bold": ["calibrib.ttf", "segoeuib.ttf", "arialbd.ttf"],
|
||||
"serif": ["times.ttf", "georgia.ttf", "cambria.ttf", "calibri.ttf"],
|
||||
"serif_bold": ["timesbd.ttf", "georgiab.ttf", "cambriab.ttf", "calibrib.ttf"],
|
||||
}
|
||||
|
||||
for font_name in candidates.get(style, candidates["regular"]):
|
||||
font_path = Path("C:/Windows/Fonts") / font_name
|
||||
if font_path.exists():
|
||||
try:
|
||||
return ImageFont.truetype(str(font_path), size)
|
||||
except Exception:
|
||||
pass
|
||||
return ImageFont.load_default()
|
||||
|
||||
|
||||
def _draw_text_centered(draw, text, y, width, font, fill, max_width=None):
|
||||
"""Рисует текст по центру с переносом по словам."""
|
||||
if not text:
|
||||
return y
|
||||
|
||||
max_width = max_width or width - 80
|
||||
words = text.split()
|
||||
lines = []
|
||||
current_line = ""
|
||||
|
||||
for word in words:
|
||||
test_line = f"{current_line} {word}".strip()
|
||||
bbox = draw.textbbox((0, 0), test_line, font=font)
|
||||
if bbox[2] - bbox[0] <= max_width:
|
||||
current_line = test_line
|
||||
else:
|
||||
if current_line:
|
||||
lines.append(current_line)
|
||||
current_line = word
|
||||
if current_line:
|
||||
lines.append(current_line)
|
||||
|
||||
line_height = font.size + 10
|
||||
for line in lines:
|
||||
bbox = draw.textbbox((0, 0), line, font=font)
|
||||
text_width = bbox[2] - bbox[0]
|
||||
x = (width - text_width) // 2
|
||||
draw.text((x, y), line, font=font, fill=fill)
|
||||
y += line_height
|
||||
|
||||
return y
|
||||
|
||||
|
||||
def _draw_text_left(draw, text, x, y, font, fill, max_width):
|
||||
"""Рисует текст слева с переносом по словам."""
|
||||
if not text:
|
||||
return y
|
||||
|
||||
words = text.split()
|
||||
lines = []
|
||||
current_line = ""
|
||||
|
||||
for word in words:
|
||||
test_line = f"{current_line} {word}".strip()
|
||||
bbox = draw.textbbox((0, 0), test_line, font=font)
|
||||
if bbox[2] - bbox[0] <= max_width:
|
||||
current_line = test_line
|
||||
else:
|
||||
if current_line:
|
||||
lines.append(current_line)
|
||||
current_line = word
|
||||
if current_line:
|
||||
lines.append(current_line)
|
||||
|
||||
line_height = font.size + 6
|
||||
for line in lines:
|
||||
draw.text((x, y), line, font=font, fill=fill)
|
||||
y += line_height
|
||||
|
||||
return y
|
||||
|
||||
|
||||
def _draw_info_row(draw, label, value, x, y, width, label_font, value_font, fill):
|
||||
"""Рисует строку с подписью и значением по центру."""
|
||||
if not value:
|
||||
return y
|
||||
|
||||
# Подпись по центру
|
||||
bbox = draw.textbbox((0, 0), label, font=label_font)
|
||||
label_width = bbox[2] - bbox[0]
|
||||
draw.text((x + (width - label_width) // 2, y), label, font=label_font, fill=fill)
|
||||
y += label_font.size + 4
|
||||
|
||||
# Значение по центру
|
||||
words = value.split()
|
||||
lines = []
|
||||
current_line = ""
|
||||
for word in words:
|
||||
test_line = f"{current_line} {word}".strip()
|
||||
bbox = draw.textbbox((0, 0), test_line, font=value_font)
|
||||
if bbox[2] - bbox[0] <= width:
|
||||
current_line = test_line
|
||||
else:
|
||||
if current_line:
|
||||
lines.append(current_line)
|
||||
current_line = word
|
||||
if current_line:
|
||||
lines.append(current_line)
|
||||
|
||||
line_height = value_font.size + 6
|
||||
for line in lines:
|
||||
bbox = draw.textbbox((0, 0), line, font=value_font)
|
||||
text_width = bbox[2] - bbox[0]
|
||||
draw.text((x + (width - text_width) // 2, y), line, font=value_font, fill=fill)
|
||||
y += line_height
|
||||
|
||||
return y + 16
|
||||
|
||||
|
||||
def _draw_ornament(draw, y, width, color):
|
||||
"""Рисует декоративную горизонтальную линию с точками по краям."""
|
||||
margin = 100
|
||||
draw.line([(margin, y), (width - margin, y)], fill=color, width=1)
|
||||
r = 4
|
||||
draw.ellipse([margin - r, y - r, margin + r, y + r], fill=color)
|
||||
draw.ellipse([width - margin - r, y - r, width - margin + r, y + r], fill=color)
|
||||
|
||||
|
||||
def generate_ticket_image(guest, event, output_path=None):
|
||||
"""Генерирует элегантный PNG-пригласительный билет."""
|
||||
if not guest.qr_filename:
|
||||
generate_qr_for_guest(guest)
|
||||
|
||||
width, height = TICKET_SIZE
|
||||
bg_color = hex_to_rgb(event.ticket_background_color or "#ffffff")
|
||||
text_color = hex_to_rgb(event.ticket_text_color or "#1a1a1a")
|
||||
accent_color = hex_to_rgb(event.ticket_accent_color or "#0d6efd")
|
||||
|
||||
img = Image.new("RGB", (width, height), bg_color)
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
# Мягкий фоновый градиент/рамка
|
||||
for i in range(20):
|
||||
alpha = int(255 * (1 - i / 40))
|
||||
c = tuple(min(255, int(bg_color[j] + (accent_color[j] - bg_color[j]) * (i / 60))) for j in range(3))
|
||||
draw.rectangle([i, i, width - 1 - i, height - 1 - i], outline=c, width=1)
|
||||
|
||||
# Декоративная внешняя рамка
|
||||
draw.rectangle([22, 22, width - 22, height - 22], outline=accent_color, width=2)
|
||||
draw.rectangle([28, 28, width - 28, height - 28], outline=_adjust_color(accent_color, 1.3), width=1)
|
||||
|
||||
# Шрифты
|
||||
title_font = _get_font(32, "serif_bold")
|
||||
subtitle_font = _get_font(20, "serif")
|
||||
name_font = _get_font(28, "bold")
|
||||
body_font = _get_font(17, "regular")
|
||||
label_font = _get_font(13, "regular")
|
||||
small_font = _get_font(12, "regular")
|
||||
|
||||
y = 50
|
||||
|
||||
# Логотип по центру сверху
|
||||
y = _draw_logo_centered(img, event, y, max_width=300)
|
||||
|
||||
# Название мероприятия
|
||||
title = event.ticket_title or event.name
|
||||
y = _draw_text_centered(draw, title, y, width, title_font, text_color, max_width=width - 80)
|
||||
|
||||
# Подзаголовок
|
||||
if event.ticket_subtitle:
|
||||
y += 8
|
||||
y = _draw_text_centered(draw, event.ticket_subtitle, y, width, subtitle_font, text_color, max_width=width - 80)
|
||||
|
||||
y += 25
|
||||
_draw_ornament(draw, y, width, accent_color)
|
||||
y += 30
|
||||
|
||||
# Приглашается
|
||||
y = _draw_text_centered(draw, "ПРИГЛАШАЕТСЯ", y, width, label_font, text_color)
|
||||
y += 12
|
||||
y = _draw_text_centered(draw, guest.build_full_name(), y, width, name_font, text_color, max_width=width - 80)
|
||||
y += 30
|
||||
|
||||
# Данные гостя
|
||||
content_width = width - 120
|
||||
x_content = 60
|
||||
|
||||
if guest.department:
|
||||
y = _draw_info_row(draw, "Отдел", guest.department, x_content, y, content_width, label_font, body_font, text_color)
|
||||
if guest.position:
|
||||
y = _draw_info_row(draw, "Должность", guest.position, x_content, y, content_width, label_font, body_font, text_color)
|
||||
if guest.email:
|
||||
y = _draw_info_row(draw, "Email", guest.email, x_content, y, content_width, label_font, body_font, text_color)
|
||||
if guest.phone:
|
||||
y = _draw_info_row(draw, "Телефон", guest.phone, x_content, y, content_width, label_font, body_font, text_color)
|
||||
|
||||
# Инфо о мероприятии
|
||||
if event.event_date or event.location:
|
||||
y += 10
|
||||
_draw_ornament(draw, y, width, accent_color)
|
||||
y += 25
|
||||
if event.event_date:
|
||||
date_value = event.event_date.strftime("%d.%m.%Y")
|
||||
time_value = event.event_date.strftime("%H:%M")
|
||||
y = _draw_info_row(draw, "Дата", date_value, x_content, y, content_width, label_font, body_font, text_color)
|
||||
y = _draw_info_row(draw, "Время", time_value, x_content, y, content_width, label_font, body_font, text_color)
|
||||
if event.location:
|
||||
y = _draw_info_row(draw, "Место проведения", event.location, x_content, y, content_width, label_font, body_font, text_color)
|
||||
|
||||
# QR-код
|
||||
if event.ticket_show_qr:
|
||||
y += 15
|
||||
qr_path = Path(current_app.config["QR_FOLDER"]) / guest.qr_filename
|
||||
if qr_path.exists():
|
||||
qr_size = 200
|
||||
qr_img = Image.open(str(qr_path)).convert("RGB")
|
||||
qr_img = qr_img.resize((qr_size, qr_size))
|
||||
x_qr = (width - qr_size) // 2
|
||||
img.paste(qr_img, (x_qr, y))
|
||||
y += qr_size + 12
|
||||
y = _draw_text_centered(draw, "QR-код для входа", y, width, small_font, text_color)
|
||||
|
||||
# Фото гостя
|
||||
if event.ticket_show_photo and guest.photo_filename:
|
||||
photo_path = Path(current_app.config["PHOTO_FOLDER"]) / guest.photo_filename
|
||||
if photo_path.exists():
|
||||
photo_img = Image.open(str(photo_path)).convert("RGB")
|
||||
photo_img.thumbnail((130, 130))
|
||||
x_photo = (width - photo_img.width) // 2
|
||||
img.paste(photo_img, (x_photo, y))
|
||||
y += photo_img.height + 20
|
||||
|
||||
# Дополнительный текст
|
||||
if event.ticket_body_text:
|
||||
y += 10
|
||||
y = _draw_text_centered(draw, event.ticket_body_text, y, width, small_font, text_color, max_width=width - 80)
|
||||
y += small_font.size + 10
|
||||
|
||||
# Номер билета
|
||||
ticket_id = f"№ {guest.token[:8].upper()}" if not guest.token.startswith("preview") else "№ PREVIEW"
|
||||
y = max(y, height - 105)
|
||||
_draw_text_centered(draw, ticket_id, y, width, small_font, text_color)
|
||||
|
||||
# Нижнее уведомление
|
||||
draw.rectangle([22, height - 65, width - 22, height - 22], fill=accent_color)
|
||||
_draw_text_centered(draw, NON_TRANSFERABLE_TEXT, height - 50, width, small_font, (255, 255, 255), max_width=width - 60)
|
||||
|
||||
# Сохранение
|
||||
if output_path is None:
|
||||
tickets_folder = Path(current_app.config["UPLOAD_FOLDER"]) / "tickets"
|
||||
tickets_folder.mkdir(parents=True, exist_ok=True)
|
||||
output_path = tickets_folder / f"ticket_{event.id}_{guest.token}.png"
|
||||
|
||||
img.save(str(output_path), "PNG")
|
||||
return Path(output_path)
|
||||
|
||||
|
||||
def generate_preview_ticket(event, sample_guest=None):
|
||||
"""Генерирует билет для предпросмотра."""
|
||||
if sample_guest is None:
|
||||
from app.models import Guest
|
||||
sample_guest = Guest(
|
||||
last_name="Иванов",
|
||||
first_name="Иван",
|
||||
middle_name="Иванович",
|
||||
full_name="Иванов Иван Иванович",
|
||||
department="Отдел продаж",
|
||||
position="Менеджер",
|
||||
email="example@company.com",
|
||||
phone="+7 (999) 123-45-67",
|
||||
token="preview-token-12345",
|
||||
qr_filename=None,
|
||||
)
|
||||
from app.services.qr_service import generate_qr_for_guest
|
||||
generate_qr_for_guest(sample_guest, base_url="http://example.com")
|
||||
|
||||
return generate_ticket_image(sample_guest, event)
|
||||
+171
-16
@@ -1,9 +1,144 @@
|
||||
:root {
|
||||
--bg-color: #0f172a;
|
||||
--surface-color: rgba(30, 41, 59, 0.85);
|
||||
--surface-border: rgba(148, 163, 184, 0.15);
|
||||
--text-color: #e2e8f0;
|
||||
--text-muted: #94a3b8;
|
||||
--accent-color: #06b6d4;
|
||||
--accent-glow: rgba(6, 182, 212, 0.35);
|
||||
--success-color: #10b981;
|
||||
--danger-color: #ef4444;
|
||||
--warning-color: #f59e0b;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: #f8f9fa;
|
||||
background-color: var(--bg-color);
|
||||
background-image:
|
||||
radial-gradient(circle at 15% 50%, rgba(6, 182, 212, 0.08) 0%, transparent 25%),
|
||||
radial-gradient(circle at 85% 30%, rgba(139, 92, 246, 0.08) 0%, transparent 25%);
|
||||
color: var(--text-color);
|
||||
font-family: "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.navbar {
|
||||
background: linear-gradient(90deg, rgba(15, 23, 42, 0.95) 0%, rgba(30, 41, 59, 0.95) 100%) !important;
|
||||
backdrop-filter: blur(12px);
|
||||
border-bottom: 1px solid var(--surface-border);
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.navbar-brand {
|
||||
font-weight: bold;
|
||||
font-weight: 700;
|
||||
letter-spacing: 1px;
|
||||
text-transform: uppercase;
|
||||
color: var(--accent-color) !important;
|
||||
text-shadow: 0 0 12px var(--accent-glow);
|
||||
}
|
||||
|
||||
.navbar-nav .nav-link {
|
||||
color: var(--text-color) !important;
|
||||
font-weight: 500;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.navbar-nav .nav-link:hover {
|
||||
color: var(--accent-color) !important;
|
||||
}
|
||||
|
||||
.navbar-text {
|
||||
color: var(--text-muted) !important;
|
||||
}
|
||||
|
||||
.card {
|
||||
background-color: var(--surface-color);
|
||||
border: 1px solid var(--surface-border);
|
||||
border-radius: 16px;
|
||||
backdrop-filter: blur(10px);
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.25);
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.card-title {
|
||||
color: var(--text-color);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.text-muted {
|
||||
color: var(--text-muted) !important;
|
||||
}
|
||||
|
||||
.table {
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.table-striped > tbody > tr:nth-of-type(odd) > * {
|
||||
background-color: rgba(255, 255, 255, 0.03);
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.table-hover > tbody > tr:hover > * {
|
||||
background-color: rgba(6, 182, 212, 0.08);
|
||||
}
|
||||
|
||||
.table-secondary > * {
|
||||
background-color: rgba(148, 163, 184, 0.15) !important;
|
||||
}
|
||||
|
||||
.form-control,
|
||||
.form-select,
|
||||
.form-control-color {
|
||||
background-color: rgba(15, 23, 42, 0.6);
|
||||
border: 1px solid var(--surface-border);
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.form-control:focus,
|
||||
.form-select:focus {
|
||||
background-color: rgba(15, 23, 42, 0.8);
|
||||
border-color: var(--accent-color);
|
||||
box-shadow: 0 0 0 0.25rem var(--accent-glow);
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.form-control::placeholder {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: linear-gradient(135deg, #0891b2 0%, #06b6d4 100%);
|
||||
border: none;
|
||||
box-shadow: 0 4px 14px rgba(6, 182, 212, 0.35);
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: linear-gradient(135deg, #0e7490 0%, #0891b2 100%);
|
||||
box-shadow: 0 6px 20px rgba(6, 182, 212, 0.45);
|
||||
}
|
||||
|
||||
.btn-success {
|
||||
background: linear-gradient(135deg, #059669 0%, #10b981 100%);
|
||||
border: none;
|
||||
box-shadow: 0 4px 14px rgba(16, 185, 129, 0.3);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: linear-gradient(135deg, #dc2626 0%, #ef4444 100%);
|
||||
border: none;
|
||||
}
|
||||
|
||||
.btn-outline-primary {
|
||||
border-color: var(--accent-color);
|
||||
color: var(--accent-color);
|
||||
}
|
||||
|
||||
.btn-outline-primary:hover {
|
||||
background-color: var(--accent-color);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.alert {
|
||||
border: 1px solid var(--surface-border);
|
||||
}
|
||||
|
||||
.checkin-container {
|
||||
@@ -14,12 +149,12 @@ body {
|
||||
.qr-reader {
|
||||
width: 100%;
|
||||
min-height: 300px;
|
||||
border: 2px dashed #dee2e6;
|
||||
border-radius: 8px;
|
||||
border: 2px dashed var(--surface-border);
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: #fff;
|
||||
background-color: rgba(15, 23, 42, 0.5);
|
||||
}
|
||||
|
||||
.guest-photo {
|
||||
@@ -27,9 +162,10 @@ body {
|
||||
max-width: 300px;
|
||||
height: 300px;
|
||||
object-fit: cover;
|
||||
border-radius: 12px;
|
||||
border: 3px solid #dee2e6;
|
||||
background-color: #e9ecef;
|
||||
border-radius: 16px;
|
||||
border: 3px solid var(--surface-border);
|
||||
background-color: rgba(15, 23, 42, 0.6);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.guest-photo-placeholder {
|
||||
@@ -39,18 +175,23 @@ body {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: #e9ecef;
|
||||
border-radius: 12px;
|
||||
color: #6c757d;
|
||||
background: linear-gradient(135deg, rgba(30, 41, 59, 0.8) 0%, rgba(15, 23, 42, 0.8) 100%);
|
||||
border-radius: 16px;
|
||||
color: var(--text-muted);
|
||||
font-size: 1.2rem;
|
||||
border: 2px dashed var(--surface-border);
|
||||
}
|
||||
|
||||
#search-results .list-group-item {
|
||||
cursor: pointer;
|
||||
background-color: rgba(15, 23, 42, 0.6);
|
||||
border-color: var(--surface-border);
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
#search-results .list-group-item:hover {
|
||||
background-color: #f8f9fa;
|
||||
background-color: rgba(6, 182, 212, 0.12);
|
||||
color: var(--accent-color);
|
||||
}
|
||||
|
||||
.btn-lg {
|
||||
@@ -58,10 +199,24 @@ body {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.card {
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.table th {
|
||||
white-space: nowrap;
|
||||
color: var(--text-muted);
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
font-size: 0.85rem;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.display-6 {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
div[style*="max-width: 600px"] {
|
||||
background: var(--surface-color) !important;
|
||||
border: 1px solid var(--surface-border) !important;
|
||||
}
|
||||
|
||||
+79
-27
@@ -1,7 +1,9 @@
|
||||
let html5QrCode = null;
|
||||
let currentToken = null;
|
||||
let availableCameras = [];
|
||||
|
||||
const qrReader = document.getElementById("qr-reader");
|
||||
const cameraSelect = document.getElementById("camera-select");
|
||||
const startScanBtn = document.getElementById("start-scan");
|
||||
const stopScanBtn = document.getElementById("stop-scan");
|
||||
const searchInput = document.getElementById("search-input");
|
||||
@@ -12,6 +14,8 @@ const guestName = document.getElementById("guest-name");
|
||||
const guestDepartment = document.getElementById("guest-department");
|
||||
const guestPosition = document.getElementById("guest-position");
|
||||
const guestEmail = document.getElementById("guest-email");
|
||||
const guestEvent = document.getElementById("guest-event");
|
||||
const eventSelect = document.getElementById("event-select");
|
||||
const checkInStatus = document.getElementById("check-in-status");
|
||||
const checkInBtn = document.getElementById("check-in-btn");
|
||||
const resetBtn = document.getElementById("reset-btn");
|
||||
@@ -24,6 +28,7 @@ function resetGuestCard() {
|
||||
guestDepartment.textContent = "";
|
||||
guestPosition.textContent = "";
|
||||
guestEmail.textContent = "";
|
||||
if (guestEvent) guestEvent.textContent = "";
|
||||
checkInStatus.classList.add("d-none");
|
||||
checkInStatus.className = "alert d-none";
|
||||
checkInBtn.disabled = false;
|
||||
@@ -53,6 +58,9 @@ function showGuest(guest) {
|
||||
guestDepartment.textContent = guest.department;
|
||||
guestPosition.textContent = guest.position || "";
|
||||
guestEmail.textContent = guest.email;
|
||||
if (guestEvent) {
|
||||
guestEvent.textContent = guest.event_name ? `Мероприятие: ${guest.event_name}` : "";
|
||||
}
|
||||
|
||||
if (guest.checked_in) {
|
||||
checkInStatus.classList.remove("d-none", "alert-success", "alert-warning");
|
||||
@@ -79,39 +87,76 @@ function loadGuestByToken(token) {
|
||||
});
|
||||
}
|
||||
|
||||
// QR Scanner
|
||||
startScanBtn.addEventListener("click", () => {
|
||||
html5QrCode = new Html5Qrcode("qr-reader");
|
||||
// Camera selection
|
||||
function populateCameras() {
|
||||
Html5Qrcode.getCameras()
|
||||
.then(cameras => {
|
||||
if (cameras && cameras.length) {
|
||||
const cameraId = cameras[0].id;
|
||||
html5QrCode.start(
|
||||
cameraId,
|
||||
{ fps: 10, qrbox: { width: 250, height: 250 } },
|
||||
(decodedText) => {
|
||||
html5QrCode.stop();
|
||||
stopScanBtn.classList.add("d-none");
|
||||
startScanBtn.classList.remove("d-none");
|
||||
availableCameras = cameras || [];
|
||||
cameraSelect.innerHTML = '<option value="">-- Выберите камеру --</option>';
|
||||
|
||||
// Extract token from URL
|
||||
const url = new URL(decodedText);
|
||||
const token = url.searchParams.get("token");
|
||||
if (token) {
|
||||
loadGuestByToken(token);
|
||||
}
|
||||
},
|
||||
(errorMessage) => {}
|
||||
);
|
||||
startScanBtn.classList.add("d-none");
|
||||
stopScanBtn.classList.remove("d-none");
|
||||
} else {
|
||||
alert("Камеры не найдены");
|
||||
if (availableCameras.length === 0) {
|
||||
cameraSelect.innerHTML += '<option value="" disabled>Камеры не найдены</option>';
|
||||
return;
|
||||
}
|
||||
|
||||
availableCameras.forEach((camera, index) => {
|
||||
const option = document.createElement("option");
|
||||
option.value = camera.id;
|
||||
option.textContent = camera.label || `Камера ${index + 1}`;
|
||||
cameraSelect.appendChild(option);
|
||||
});
|
||||
|
||||
// Try to select back camera by default
|
||||
const backCamera = availableCameras.find(c =>
|
||||
c.label.toLowerCase().includes("back") ||
|
||||
c.label.toLowerCase().includes("задняя") ||
|
||||
c.label.toLowerCase().includes("environment")
|
||||
);
|
||||
if (backCamera) {
|
||||
cameraSelect.value = backCamera.id;
|
||||
} else if (availableCameras.length > 0) {
|
||||
cameraSelect.value = availableCameras[availableCameras.length - 1].id;
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
alert("Ошибка доступа к камере: " + err.message);
|
||||
console.error("Ошибка получения списка камер:", err);
|
||||
cameraSelect.innerHTML = '<option value="">Ошибка доступа к камерам</option>';
|
||||
});
|
||||
}
|
||||
|
||||
// QR Scanner
|
||||
startScanBtn.addEventListener("click", () => {
|
||||
const cameraId = cameraSelect.value;
|
||||
if (!cameraId) {
|
||||
alert("Сначала выберите камеру");
|
||||
return;
|
||||
}
|
||||
|
||||
html5QrCode = new Html5Qrcode("qr-reader");
|
||||
html5QrCode.start(
|
||||
cameraId,
|
||||
{ fps: 10, qrbox: { width: 250, height: 250 } },
|
||||
(decodedText) => {
|
||||
html5QrCode.stop();
|
||||
stopScanBtn.classList.add("d-none");
|
||||
startScanBtn.classList.remove("d-none");
|
||||
|
||||
// Extract token from URL
|
||||
try {
|
||||
const url = new URL(decodedText);
|
||||
const token = url.searchParams.get("token");
|
||||
if (token) {
|
||||
loadGuestByToken(token);
|
||||
}
|
||||
} catch (e) {
|
||||
// If decodedText is not a URL, treat it as token
|
||||
loadGuestByToken(decodedText);
|
||||
}
|
||||
},
|
||||
(errorMessage) => {}
|
||||
);
|
||||
startScanBtn.classList.add("d-none");
|
||||
stopScanBtn.classList.remove("d-none");
|
||||
});
|
||||
|
||||
stopScanBtn.addEventListener("click", () => {
|
||||
@@ -132,7 +177,12 @@ searchInput.addEventListener("input", (e) => {
|
||||
|
||||
clearTimeout(searchTimeout);
|
||||
searchTimeout = setTimeout(() => {
|
||||
fetch(`/api/guests/search?q=${encodeURIComponent(query)}`)
|
||||
const eventId = eventSelect ? eventSelect.value : "";
|
||||
let url = `/api/guests/search?q=${encodeURIComponent(query)}`;
|
||||
if (eventId) {
|
||||
url += `&event_id=${encodeURIComponent(eventId)}`;
|
||||
}
|
||||
fetch(url)
|
||||
.then(response => response.json())
|
||||
.then(guests => {
|
||||
searchResults.innerHTML = "";
|
||||
@@ -190,6 +240,8 @@ resetBtn.addEventListener("click", resetGuestCard);
|
||||
|
||||
// If token is in URL (scanned via external scanner)
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
populateCameras();
|
||||
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const token = urlParams.get("token");
|
||||
if (token) {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
{% block title %}Аналитика — InviteMe{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1 class="mb-4">Аналитика мероприятия</h1>
|
||||
<h1 class="mb-4">Аналитика: {{ event.name }}</h1>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-3">
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ 'Редактировать' if event else 'Создать' }} мероприятие — InviteMe{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1 class="mb-4">{{ 'Редактировать' if event else 'Создать' }} мероприятие</h1>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<form method="POST">
|
||||
<div class="mb-3">
|
||||
<label for="name" class="form-label">Название *</label>
|
||||
<input type="text" class="form-control" id="name" name="name" value="{{ event.name if event else '' }}" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="description" class="form-label">Описание</label>
|
||||
<textarea class="form-control" id="description" name="description" rows="3">{{ event.description if event else '' }}</textarea>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="location" class="form-label">Место проведения</label>
|
||||
<input type="text" class="form-control" id="location" name="location" value="{{ event.location if event else '' }}">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="event_date" class="form-label">Дата и время</label>
|
||||
<input type="datetime-local" class="form-control" id="event_date" name="event_date"
|
||||
value="{{ event.event_date.strftime('%Y-%m-%dT%H:%M') if event and event.event_date else '' }}">
|
||||
</div>
|
||||
{% if event %}
|
||||
<div class="mb-3 form-check">
|
||||
<input type="checkbox" class="form-check-input" id="is_active" name="is_active" {% if event.is_active %}checked{% endif %}>
|
||||
<label class="form-check-label" for="is_active">Активно</label>
|
||||
</div>
|
||||
{% endif %}
|
||||
<button type="submit" class="btn btn-primary">Сохранить</button>
|
||||
{% if event %}
|
||||
<a href="{{ url_for('main.admin_event_ticket', event_id=event.id) }}" class="btn btn-outline-info">Конструктор билета</a>
|
||||
{% endif %}
|
||||
<a href="{{ url_for('main.admin_events') }}" class="btn btn-secondary">Отмена</a>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,51 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Мероприятия — InviteMe{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h1>Мероприятия</h1>
|
||||
<a href="{{ url_for('main.admin_event_add') }}" class="btn btn-primary">Создать мероприятие</a>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
{% for event in events %}
|
||||
<div class="col-md-4 mb-4">
|
||||
<div class="card h-100 {% if not event.is_active %}border-secondary{% endif %}">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">{{ event.name }}</h5>
|
||||
<p class="card-text text-muted">
|
||||
{% if event.event_date %}
|
||||
{{ event.event_date.strftime('%d.%m.%Y %H:%M') }}<br>
|
||||
{% endif %}
|
||||
{% if event.location %}
|
||||
{{ event.location }}<br>
|
||||
{% endif %}
|
||||
Гостей: {{ event.total_guests }} | Пришло: {{ event.checked_in }}
|
||||
</p>
|
||||
{% if event.description %}
|
||||
<p class="card-text">{{ event.description }}</p>
|
||||
{% endif %}
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
<form action="{{ url_for('main.admin_event_switch', event_id=event.id) }}" method="POST" class="d-inline">
|
||||
<button type="submit" class="btn btn-success btn-sm">Выбрать</button>
|
||||
</form>
|
||||
<a href="{{ url_for('main.admin_event_edit', event_id=event.id) }}" class="btn btn-outline-primary btn-sm">Изменить</a>
|
||||
<a href="{{ url_for('main.admin_event_ticket', event_id=event.id) }}" class="btn btn-outline-info btn-sm">Билет</a>
|
||||
{% if not event.is_active %}
|
||||
<span class="badge bg-secondary">Неактивно</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer text-muted">
|
||||
Создано: {{ event.created_at.strftime('%d.%m.%Y') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="col-12">
|
||||
<div class="alert alert-info">Пока нет ни одного мероприятия. <a href="{{ url_for('main.admin_event_add') }}">Создайте первое</a>.</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -4,13 +4,24 @@
|
||||
|
||||
{% block content %}
|
||||
<h1 class="mb-4">{{ 'Редактировать' if guest else 'Добавить' }} гостя</h1>
|
||||
<p class="text-muted">Мероприятие: {{ event.name }}</p>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<form method="POST">
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<label for="last_name" class="form-label">Фамилия</label>
|
||||
<input type="text" class="form-control" id="last_name" name="last_name" value="{{ guest.last_name if guest else '' }}" required>
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label for="first_name" class="form-label">Имя</label>
|
||||
<input type="text" class="form-control" id="first_name" name="first_name" value="{{ guest.first_name if guest else '' }}" required>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="full_name" class="form-label">ФИО</label>
|
||||
<input type="text" class="form-control" id="full_name" name="full_name" value="{{ guest.full_name if guest else '' }}" required>
|
||||
<label for="middle_name" class="form-label">Отчество</label>
|
||||
<input type="text" class="form-control" id="middle_name" name="middle_name" value="{{ guest.middle_name if guest else '' }}">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="department" class="form-label">Отдел</label>
|
||||
@@ -24,6 +35,10 @@
|
||||
<label for="email" class="form-label">Email</label>
|
||||
<input type="email" class="form-control" id="email" name="email" value="{{ guest.email if guest else '' }}" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="phone" class="form-label">Телефон</label>
|
||||
<input type="text" class="form-control" id="phone" name="phone" value="{{ guest.phone if guest else '' }}">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="photo_filename" class="form-label">Название фото</label>
|
||||
<input type="text" class="form-control" id="photo_filename" name="photo_filename" value="{{ guest.photo_filename if guest else '' }}">
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h1>Гости</h1>
|
||||
<h1>Гости: {{ event.name }}</h1>
|
||||
<a href="{{ url_for('main.admin_guest_add') }}" class="btn btn-primary">Добавить гостя</a>
|
||||
</div>
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
<th>Отдел</th>
|
||||
<th>Должность</th>
|
||||
<th>Email</th>
|
||||
<th>Телефон</th>
|
||||
<th>Фото</th>
|
||||
<th>QR</th>
|
||||
<th>Статус</th>
|
||||
@@ -33,10 +34,11 @@
|
||||
<tbody>
|
||||
{% for guest in guests %}
|
||||
<tr class="{{ 'table-secondary' if not guest.is_active }}">
|
||||
<td>{{ guest.full_name }}</td>
|
||||
<td>{{ guest.build_full_name() }}</td>
|
||||
<td>{{ guest.department }}</td>
|
||||
<td>{{ guest.position or '-' }}</td>
|
||||
<td>{{ guest.email }}</td>
|
||||
<td>{{ guest.phone or '-' }}</td>
|
||||
<td>
|
||||
{% if guest.photo_filename %}
|
||||
<span class="badge bg-success">Есть</span>
|
||||
@@ -70,7 +72,7 @@
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="8" class="text-center text-muted">Гости не найдены</td>
|
||||
<td colspan="9" class="text-center text-muted">Гости не найдены</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Конструктор билета — {{ event.name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h1>Конструктор пригласительного билета</h1>
|
||||
<a href="{{ url_for('main.admin_events') }}" class="btn btn-secondary">← К мероприятиям</a>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-lg-5">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Настройки дизайна</h5>
|
||||
<form method="POST" id="ticket-form" enctype="multipart/form-data">
|
||||
<div class="mb-3">
|
||||
<label for="logo_file" class="form-label">Логотип компании</label>
|
||||
<input type="file" class="form-control" id="logo_file" name="logo_file" accept="image/*">
|
||||
{% if event.logo_filename %}
|
||||
<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;">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="delete_logo" name="delete_logo" value="1">
|
||||
<label class="form-check-label" for="delete_logo">Удалить</label>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="ticket_title" class="form-label">Заголовок билета</label>
|
||||
<input type="text" class="form-control" id="ticket_title" name="ticket_title" value="{{ event.ticket_title or event.name }}">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="ticket_subtitle" class="form-label">Подзаголовок</label>
|
||||
<input type="text" class="form-control" id="ticket_subtitle" name="ticket_subtitle" value="{{ event.ticket_subtitle or '' }}">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="ticket_body_text" class="form-label">Дополнительный текст</label>
|
||||
<textarea class="form-control" id="ticket_body_text" name="ticket_body_text" rows="3">{{ event.ticket_body_text or '' }}</textarea>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-4 mb-3">
|
||||
<label for="ticket_background_color" class="form-label">Фон</label>
|
||||
<input type="color" class="form-control form-control-color w-100" id="ticket_background_color" name="ticket_background_color" value="{{ event.ticket_background_color or '#ffffff' }}">
|
||||
</div>
|
||||
<div class="col-md-4 mb-3">
|
||||
<label for="ticket_text_color" class="form-label">Текст</label>
|
||||
<input type="color" class="form-control form-control-color w-100" id="ticket_text_color" name="ticket_text_color" value="{{ event.ticket_text_color or '#000000' }}">
|
||||
</div>
|
||||
<div class="col-md-4 mb-3">
|
||||
<label for="ticket_accent_color" class="form-label">Акцент</label>
|
||||
<input type="color" class="form-control form-control-color w-100" id="ticket_accent_color" name="ticket_accent_color" value="{{ event.ticket_accent_color or '#0d6efd' }}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="ticket_layout" class="form-label">Ориентация</label>
|
||||
<select class="form-select" id="ticket_layout" name="ticket_layout">
|
||||
<option value="portrait" {% if (event.ticket_layout or 'portrait') == 'portrait' %}selected{% endif %}>Портретная (600×900)</option>
|
||||
<option value="landscape" {% if (event.ticket_layout or 'portrait') == 'landscape' %}selected{% endif %}>Альбомная (900×600)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-3 form-check">
|
||||
<input type="checkbox" class="form-check-input" id="ticket_show_qr" name="ticket_show_qr" {% if event.ticket_show_qr %}checked{% endif %}>
|
||||
<label class="form-check-label" for="ticket_show_qr">Показывать QR-код</label>
|
||||
</div>
|
||||
<div class="mb-3 form-check">
|
||||
<input type="checkbox" class="form-check-input" id="ticket_show_photo" name="ticket_show_photo" {% if event.ticket_show_photo %}checked{% endif %}>
|
||||
<label class="form-check-label" for="ticket_show_photo">Показывать фото гостя</label>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<button type="submit" class="btn btn-primary">Сохранить</button>
|
||||
<button type="button" id="refresh-preview" class="btn btn-outline-secondary">Обновить предпросмотр</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-7">
|
||||
<div class="card">
|
||||
<div class="card-body text-center">
|
||||
<h5 class="card-title">Предпросмотр</h5>
|
||||
<p class="text-muted">Пример билета с тестовыми данными</p>
|
||||
<img id="ticket-preview" src="{{ url_for('main.admin_event_ticket_preview', event_id=event.id) }}" alt="Предпросмотр билета" class="img-fluid border shadow-sm" style="max-height: 80vh;">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
document.getElementById('refresh-preview').addEventListener('click', function() {
|
||||
const img = document.getElementById('ticket-preview');
|
||||
img.src = "{{ url_for('main.admin_event_ticket_preview', event_id=event.id) }}?t=" + Date.now();
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -4,13 +4,14 @@
|
||||
|
||||
{% block content %}
|
||||
<h1 class="mb-4">Загрузка данных</h1>
|
||||
<p class="text-muted">Мероприятие: {{ event.name }}</p>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">1. Загрузить Excel со списком гостей</h5>
|
||||
<p class="text-muted">Столбцы: Отдел, ФИО, Должность, e-mail, название фото</p>
|
||||
<p class="text-muted">Столбцы: Отдел, Фамилия, Имя, Отчество, Должность, Почта, Телефон, Название фото</p>
|
||||
<form method="POST" enctype="multipart/form-data">
|
||||
<div class="mb-3">
|
||||
<label for="excel_file" class="form-label">Excel-файл</label>
|
||||
@@ -30,7 +31,7 @@
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">2. Загрузить фото гостей</h5>
|
||||
<p class="text-muted">Фото должны называться точно так же, как указано в столбце «название фото»</p>
|
||||
<p class="text-muted">Фото должны называться точно так же, как указано в столбце «Название фото»</p>
|
||||
<form method="POST" enctype="multipart/form-data">
|
||||
<div class="mb-3">
|
||||
<label for="photo_files" class="form-label">Фото (можно несколько)</label>
|
||||
@@ -46,4 +47,63 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mt-4">
|
||||
<div class="col-12">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Пример заполнения Excel-таблицы</h5>
|
||||
<p class="text-muted">Имена столбцов должны совпадать точно. Значение в столбце «Название фото» должно совпадать с именем файла фото, включая расширение.</p>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm table-bordered">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Отдел</th>
|
||||
<th>Фамилия</th>
|
||||
<th>Имя</th>
|
||||
<th>Отчество</th>
|
||||
<th>Должность</th>
|
||||
<th>Почта</th>
|
||||
<th>Телефон</th>
|
||||
<th>Название фото</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>IT</td>
|
||||
<td>Иванов</td>
|
||||
<td>Иван</td>
|
||||
<td>Иванович</td>
|
||||
<td>Разработчик</td>
|
||||
<td>ivanov@company.com</td>
|
||||
<td>+7 (999) 111-22-33</td>
|
||||
<td>ivanov.jpg</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Маркетинг</td>
|
||||
<td>Петрова</td>
|
||||
<td>Анна</td>
|
||||
<td>Сергеевна</td>
|
||||
<td>Маркетолог</td>
|
||||
<td>petrova@company.com</td>
|
||||
<td>+7 (999) 222-33-44</td>
|
||||
<td>petrova.png</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Продажи</td>
|
||||
<td>Сидоров</td>
|
||||
<td>Алексей</td>
|
||||
<td>Петрович</td>
|
||||
<td>Менеджер</td>
|
||||
<td>sidorov@company.com</td>
|
||||
<td>+7 (999) 333-44-55</td>
|
||||
<td>sidorov.jpg</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ 'Редактировать' if user else 'Добавить' }} пользователя — InviteMe{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1 class="mb-4">{{ 'Редактировать' if user else 'Добавить' }} пользователя</h1>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<form method="POST">
|
||||
<div class="mb-3">
|
||||
<label for="username" class="form-label">Логин *</label>
|
||||
<input type="text" class="form-control" id="username" name="username" value="{{ user.username if user else '' }}" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="password" class="form-label">Пароль {% if user %}(оставьте пустым, чтобы не менять){% else %}*{% endif %}</label>
|
||||
<input type="password" class="form-control" id="password" name="password" {% if not user %}required{% endif %}>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="role" class="form-label">Роль *</label>
|
||||
<select class="form-select" id="role" name="role" required>
|
||||
<option value="admin" {% if user and user.role == 'admin' %}selected{% endif %}>Администратор</option>
|
||||
<option value="organizer" {% if user and user.role == 'organizer' %}selected{% endif %}>Организатор</option>
|
||||
<option value="staff" {% if user and user.role == 'staff' %}selected{% endif %} {% if not user %}selected{% endif %}>Сотрудник на входе</option>
|
||||
</select>
|
||||
</div>
|
||||
{% if user %}
|
||||
<div class="mb-3 form-check">
|
||||
<input type="checkbox" class="form-check-input" id="is_active" name="is_active" {% if user.is_active %}checked{% endif %}>
|
||||
<label class="form-check-label" for="is_active">Активен</label>
|
||||
</div>
|
||||
{% endif %}
|
||||
<button type="submit" class="btn btn-primary">Сохранить</button>
|
||||
<a href="{{ url_for('main.admin_users') }}" class="btn btn-secondary">Отмена</a>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,58 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Пользователи — InviteMe{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h1>Пользователи</h1>
|
||||
<a href="{{ url_for('main.admin_user_add') }}" class="btn btn-primary">Добавить пользователя</a>
|
||||
</div>
|
||||
|
||||
<table class="table table-striped table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Логин</th>
|
||||
<th>Роль</th>
|
||||
<th>Статус</th>
|
||||
<th>Создан</th>
|
||||
<th>Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for user in users %}
|
||||
<tr class="{{ 'table-secondary' if not user.is_active }}">
|
||||
<td>{{ user.username }}</td>
|
||||
<td>
|
||||
{% if user.role == 'admin' %}
|
||||
<span class="badge bg-danger">Админ</span>
|
||||
{% elif user.role == 'organizer' %}
|
||||
<span class="badge bg-warning text-dark">Организатор</span>
|
||||
{% else %}
|
||||
<span class="badge bg-info text-dark">Сотрудник</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if user.is_active %}
|
||||
<span class="badge bg-success">Активен</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary">Заблокирован</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ user.created_at.strftime('%d.%m.%Y %H:%M') }}</td>
|
||||
<td>
|
||||
<a href="{{ url_for('main.admin_user_edit', user_id=user.id) }}" class="btn btn-sm btn-outline-primary">Изменить</a>
|
||||
{% if current_user.id != user.id %}
|
||||
<form action="{{ url_for('main.admin_user_delete', user_id=user.id) }}" method="POST" class="d-inline" onsubmit="return confirm('Заблокировать пользователя?')">
|
||||
<button type="submit" class="btn btn-sm btn-outline-danger">Блок</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="5" class="text-center text-muted">Пользователи не найдены</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endblock %}
|
||||
+38
-6
@@ -8,7 +8,7 @@
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
|
||||
</head>
|
||||
<body>
|
||||
<nav class="navbar navbar-expand-lg navbar-dark bg-primary">
|
||||
<nav class="navbar navbar-expand-lg navbar-dark">
|
||||
<div class="container-fluid">
|
||||
<a class="navbar-brand" href="{{ url_for('main.index') }}">InviteMe</a>
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
|
||||
@@ -16,7 +16,11 @@
|
||||
</button>
|
||||
<div class="collapse navbar-collapse" id="navbarNav">
|
||||
<ul class="navbar-nav me-auto">
|
||||
{% if session.role == 'admin' %}
|
||||
{% 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>
|
||||
@@ -27,14 +31,42 @@
|
||||
<a class="nav-link" href="{{ url_for('main.admin_guests') }}">Гости</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if session.role in ('admin', 'staff') %}
|
||||
{% endif %}
|
||||
{% if current_user and current_user.is_admin %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url_for('main.check_in') }}">Вход</a>
|
||||
<a class="nav-link" href="{{ url_for('main.admin_users') }}">Пользователи</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if current_user and current_user.role in ('admin', 'organizer', 'staff') %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url_for('main.check_in') }}">Проверка приглашения</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
{% if session.role %}
|
||||
<ul class="navbar-nav">
|
||||
|
||||
{% if current_user %}
|
||||
<ul class="navbar-nav align-items-center">
|
||||
{% if current_user.role in ('admin', 'organizer') and available_events %}
|
||||
<li class="nav-item dropdown me-3">
|
||||
<a class="nav-link dropdown-toggle" href="#" id="eventDropdown" role="button" data-bs-toggle="dropdown">
|
||||
{% if current_event %}{{ current_event.name }}{% else %}Выбрать мероприятие{% endif %}
|
||||
</a>
|
||||
<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="eventDropdown">
|
||||
{% for event in available_events %}
|
||||
<li>
|
||||
<form action="{{ url_for('main.admin_event_switch', event_id=event.id) }}" method="POST" class="d-inline">
|
||||
<button type="submit" class="dropdown-item {% if current_event and current_event.id == event.id %}active{% endif %}">
|
||||
{{ event.name }}
|
||||
</button>
|
||||
</form>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</li>
|
||||
{% endif %}
|
||||
<li class="nav-item">
|
||||
<span class="navbar-text me-3">{{ current_user.username }} ({{ current_user.role }})</span>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url_for('main.logout') }}">Выйти</a>
|
||||
</li>
|
||||
|
||||
@@ -1,16 +1,53 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Отметка входа — InviteMe{% endblock %}
|
||||
{% block title %}Проверка приглашения — InviteMe{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="checkin-container">
|
||||
<h1 class="mb-4 text-center">Отметка входа</h1>
|
||||
<h1 class="mb-4 text-center">Проверка приглашения</h1>
|
||||
|
||||
{% if active_events %}
|
||||
<div class="row justify-content-center mb-4">
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Выберите мероприятие</h5>
|
||||
<form method="POST" action="{{ url_for('main.check_in', token=token) }}">
|
||||
<div class="mb-3">
|
||||
<select id="event-select" name="event_id" class="form-select" onchange="this.form.submit()">
|
||||
<option value="">-- Мероприятие --</option>
|
||||
{% for event in active_events %}
|
||||
<option value="{{ event.id }}" {% if checkin_event and checkin_event.id == event.id %}selected{% endif %}>
|
||||
{{ event.name }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<noscript><button type="submit" class="btn btn-primary">Выбрать</button></noscript>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if checkin_event %}
|
||||
<div class="alert alert-info text-center">
|
||||
Текущее мероприятие: <strong>{{ checkin_event.name }}</strong>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Сканировать QR-код</h5>
|
||||
<div class="mb-3">
|
||||
<label for="camera-select" class="form-label">Камера</label>
|
||||
<select id="camera-select" class="form-select">
|
||||
<option value="">-- Выберите камеру --</option>
|
||||
</select>
|
||||
</div>
|
||||
<div id="qr-reader" class="qr-reader"></div>
|
||||
<button id="start-scan" class="btn btn-primary w-100 mt-2">Включить камеру</button>
|
||||
<button id="stop-scan" class="btn btn-secondary w-100 mt-2 d-none">Выключить камеру</button>
|
||||
@@ -36,6 +73,7 @@
|
||||
<p id="guest-department" class="lead"></p>
|
||||
<p id="guest-position" class="text-muted"></p>
|
||||
<p id="guest-email" class="text-muted"></p>
|
||||
<p id="guest-event" class="text-info"></p>
|
||||
|
||||
<div id="check-in-status" class="alert d-none"></div>
|
||||
|
||||
|
||||
@@ -10,17 +10,24 @@
|
||||
<p>Здравствуйте, <strong>{{ guest.full_name }}</strong>!</p>
|
||||
<p>Вы приглашены на мероприятие <strong>{{ event_name }}</strong>.</p>
|
||||
|
||||
{% if ticket_cid %}
|
||||
<p>Ваш персональный пригласительный билет:</p>
|
||||
<div style="text-align: center; margin: 20px 0;">
|
||||
<img src="cid:{{ ticket_cid }}" alt="Пригласительный билет" style="max-width: 100%; border: 1px solid #ddd; border-radius: 8px;">
|
||||
</div>
|
||||
{% else %}
|
||||
<p>Ваш персональный QR-код для входа:</p>
|
||||
<div style="text-align: center; margin: 20px 0;">
|
||||
<img src="cid:qr_code_image" alt="QR-код" style="max-width: 250px;">
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if additional_text %}
|
||||
<p>{{ additional_text }}</p>
|
||||
{% endif %}
|
||||
|
||||
<p style="color: #666; font-size: 14px;">
|
||||
Пожалуйста, предъявите этот QR-код при входе. Не передавайте его другим лицам.
|
||||
Пожалуйста, предъявите этот QR-код или билет при входе. Не передавайте их другим лицам.
|
||||
</p>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
@@ -3,11 +3,12 @@
|
||||
{% block title %}Вход — InviteMe{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row justify-content-center">
|
||||
<div class="row justify-content-center align-items-center" style="min-height: 70vh;">
|
||||
<div class="col-md-4">
|
||||
<div class="card shadow-sm mt-5">
|
||||
<div class="card-body">
|
||||
<h3 class="card-title text-center mb-4">InviteMe</h3>
|
||||
<div class="card mt-5">
|
||||
<div class="card-body p-4">
|
||||
<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>
|
||||
<form method="POST" action="{{ url_for('main.login') }}">
|
||||
<div class="mb-3">
|
||||
<label for="username" class="form-label">Логин</label>
|
||||
|
||||
@@ -18,6 +18,7 @@ class Config:
|
||||
UPLOAD_FOLDER = BASE_DIR / "app" / "static" / "uploads"
|
||||
PHOTO_FOLDER = UPLOAD_FOLDER / "photos"
|
||||
QR_FOLDER = UPLOAD_FOLDER / "qrcodes"
|
||||
LOGO_FOLDER = UPLOAD_FOLDER / "logos"
|
||||
MAX_CONTENT_LENGTH = 16 * 1024 * 1024 # 16 MB
|
||||
|
||||
# Email (SMTP)
|
||||
@@ -35,3 +36,6 @@ class Config:
|
||||
# Staff credentials (for check-in devices)
|
||||
STAFF_USERNAME = os.environ.get("STAFF_USERNAME") or "staff"
|
||||
STAFF_PASSWORD = os.environ.get("STAFF_PASSWORD") or "staff"
|
||||
|
||||
# Default event name for migrating existing guests
|
||||
DEFAULT_EVENT_NAME = os.environ.get("DEFAULT_EVENT_NAME") or "Архивное мероприятие"
|
||||
|
||||
+29
-17
@@ -8,17 +8,19 @@ InviteMe — внутреннее веб-приложение для компа
|
||||
|
||||
### 1. Подготовка до мероприятия
|
||||
|
||||
1. Организатор формирует Excel-файл со столбцами:
|
||||
1. Администратор создаёт учётные записи организаторов и сотрудников на входе.
|
||||
2. Организатор создаёт новое мероприятие в админ-панели.
|
||||
3. Организатор формирует Excel-файл со столбцами:
|
||||
- `Отдел`
|
||||
- `ФИО`
|
||||
- `Должность`
|
||||
- `e-mail`
|
||||
- `название фото`
|
||||
2. Собирает фотографии гостей. Название каждого файла должно совпадать со значением в столбце `название фото`.
|
||||
3. В админ-панели загружает Excel. Система автоматически создаёт записи гостей и генерирует для каждого уникальный QR-код.
|
||||
4. Загружает фото (по одному или ZIP-архивом) в папку `app/static/uploads/photos/`.
|
||||
5. При необходимости вручную добавляет, редактирует или блокирует гостей.
|
||||
6. Запускает массовую рассылку приглашений. Каждый гость получает письмо с QR-кодом.
|
||||
4. Собирает фотографии гостей. Название каждого файла должно совпадать со значением в столбце `название фото`.
|
||||
5. В админ-панели выбирает созданное мероприятие и загружает Excel. Система создаёт записи гостей в рамках этого мероприятия и генерирует для каждого уникальный QR-код.
|
||||
6. Загружает фото (по одному или ZIP-архивом) в папку `app/static/uploads/photos/`.
|
||||
7. При необходимости вручную добавляет, редактирует или блокирует гостей.
|
||||
8. Запускает массовую рассылку приглашений. Каждый гость получает письмо с QR-кодом.
|
||||
|
||||
### 2. Вход на мероприятие
|
||||
|
||||
@@ -43,10 +45,16 @@ InviteMe — внутреннее веб-приложение для компа
|
||||
|
||||
## Роли пользователей
|
||||
|
||||
| Роль | Логин по умолчанию | Возможности |
|
||||
|---|---|---|
|
||||
| Администратор | `admin` / `admin` | Полный доступ: загрузка данных, управление гостями, рассылка, аналитика, отчёты |
|
||||
| Сотрудник на входе | `staff` / `staff` | Только страница `/check-in`: сканирование QR, поиск, отметка входа |
|
||||
| Роль | Возможности |
|
||||
|---|---|
|
||||
| Администратор | Полный доступ: управление пользователями, всеми мероприятиями, гостями, рассылка, аналитика, отчёты |
|
||||
| Организатор | Создание и управление своими мероприятиями, гостями, рассылка, аналитика, отчёты |
|
||||
| Сотрудник на входе | Страница `/check-in`: выбор мероприятия, сканирование QR, поиск, отметка входа |
|
||||
|
||||
Пользователи создаются и управляются через веб-интерфейс администратора. При первом запуске создаются учётные записи по умолчанию:
|
||||
|
||||
- `admin` / `admin`
|
||||
- `staff` / `staff`
|
||||
|
||||
> В production пароли обязательно меняются через переменные окружения или файл `.env`.
|
||||
|
||||
@@ -56,9 +64,9 @@ InviteMe — внутреннее веб-приложение для компа
|
||||
┌─────────────┐ ┌─────────────┐ ┌─────────────────┐
|
||||
│ Браузер │──────│ Flask │──────│ SQLite │
|
||||
│ (Admin/ │ │ (Python) │ │ (inviteme.db) │
|
||||
│ Staff) │ │ │ └─────────────────┘
|
||||
└─────────────┘ └──────┬──────┘
|
||||
│
|
||||
│ Organizer/ │ │ │ └─────────────────┘
|
||||
│ Staff) │ └──────┬──────┘
|
||||
└─────────────┘ │
|
||||
┌────────────────┼────────────────┐
|
||||
│ │ │
|
||||
┌─────▼─────┐ ┌─────▼─────┐ ┌─────▼─────┐
|
||||
@@ -68,7 +76,7 @@ InviteMe — внутреннее веб-приложение для компа
|
||||
```
|
||||
|
||||
- **Flask** обрабатывает HTTP-запросы и рендерит HTML-шаблоны.
|
||||
- **SQLite** хранит данные гостей и историю входов.
|
||||
- **SQLite** хранит пользователей, мероприятия, гостей и историю входов.
|
||||
- **Локальная файловая система** хранит фото и QR-коды.
|
||||
- **SMTP** используется для отправки приглашений.
|
||||
|
||||
@@ -77,11 +85,13 @@ InviteMe — внутреннее веб-приложение для компа
|
||||
### Импорт гостей
|
||||
|
||||
```text
|
||||
Создание мероприятия в админ-панели
|
||||
↓
|
||||
Excel-файл
|
||||
↓
|
||||
excel_service.read_guests_from_excel()
|
||||
↓
|
||||
Создание/обновление записей Guest в SQLite
|
||||
Создание/обновление записей Guest в SQLite (с привязкой к мероприятию)
|
||||
↓
|
||||
qr_service.generate_qr_for_guest() для каждого нового гостя
|
||||
↓
|
||||
@@ -151,13 +161,14 @@ app/static/uploads/
|
||||
|
||||
## Чеклист перед мероприятием
|
||||
|
||||
- [ ] Созданы учётные записи организаторов и сотрудников на входе.
|
||||
- [ ] Создано мероприятие в админ-панели.
|
||||
- [ ] Подготовлен Excel со списком гостей.
|
||||
- [ ] Собраны и загружены фото всех гостей.
|
||||
- [ ] Загружен Excel в админ-панель.
|
||||
- [ ] Загружен Excel в админ-панель в рамках нужного мероприятия.
|
||||
- [ ] Проверено, что QR-коды сгенерированы.
|
||||
- [ ] Настроен SMTP и отправлено тестовое письмо.
|
||||
- [ ] Разосланы приглашения.
|
||||
- [ ] Созданы учётные записи для сотрудников на входе (или используется общая `staff`).
|
||||
- [ ] Протестирована страница `/check-in` на телефоне/планшете.
|
||||
- [ ] Проверена работа камеры и сканирования QR.
|
||||
- [ ] Проверен поиск по ФИО.
|
||||
@@ -185,3 +196,4 @@ app/static/uploads/
|
||||
## История изменений
|
||||
|
||||
- **2026-08-28** — Первая рабочая версия (MVP). Реализованы импорт Excel, QR-коды, рассылка, check-in, аналитика.
|
||||
- **2026-08-28** — Добавлены пользователи, роли и поддержка нескольких мероприятий.
|
||||
|
||||
@@ -27,6 +27,7 @@ ADMIN_USERNAME=admin
|
||||
ADMIN_PASSWORD=your-admin-password
|
||||
STAFF_USERNAME=staff
|
||||
STAFF_PASSWORD=your-staff-password
|
||||
DEFAULT_EVENT_NAME=Архивное мероприятие
|
||||
|
||||
MAIL_SERVER=smtp.yandex.ru
|
||||
MAIL_PORT=465
|
||||
@@ -37,6 +38,7 @@ MAIL_DEFAULT_SENDER=your-email@yandex.ru
|
||||
```
|
||||
|
||||
> Для Yandex Mail используйте пароль приложения, а не основной пароль.
|
||||
> `ADMIN_USERNAME`/`ADMIN_PASSWORD` и `STAFF_USERNAME`/`STAFF_PASSWORD` используются только для создания начальных пользователей при первом запуске. Дальнейшее управление пользователями выполняется через веб-интерфейс администратора.
|
||||
|
||||
### 3. Запуск в режиме разработки
|
||||
|
||||
|
||||
@@ -6,3 +6,6 @@ qrcode==7.4.2
|
||||
Pillow==10.1.0
|
||||
python-dotenv==1.0.0
|
||||
Jinja2==3.1.2
|
||||
svglib==2.2.0
|
||||
reportlab==5.0.1
|
||||
rlPyCairo==0.4.0
|
||||
|
||||
Reference in New Issue
Block a user