Auto-commit before push to git.mycloud24.ru

This commit is contained in:
InviteMe Developer
2026-08-28 15:41:39 +03:00
parent 3bd62cc289
commit a69abb948f
26 changed files with 1908 additions and 171 deletions
+8 -2
View File
@@ -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
View File
@@ -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}")
+85
View File
@@ -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
View File
@@ -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)
+23 -2
View File
@@ -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"]
+69 -20
View File
@@ -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,
+369
View File
@@ -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
View File
@@ -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
View File
@@ -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) {
+1 -1
View File
@@ -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">
+42
View File
@@ -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 %}
+51
View File
@@ -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 %}
+17 -2
View File
@@ -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 '' }}">
+5 -3
View File
@@ -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 %}
+62 -2
View File
@@ -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 %}
+38
View File
@@ -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 %}
+58
View File
@@ -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
View File
@@ -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>
+40 -2
View File
@@ -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>
+8 -1
View File
@@ -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>
+5 -4
View File
@@ -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>
+4
View File
@@ -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
View File
@@ -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** — Добавлены пользователи, роли и поддержка нескольких мероприятий.
+2
View File
@@ -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. Запуск в режиме разработки
+3
View File
@@ -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