diff --git a/README.md b/README.md index 7476c49..83e0ca0 100644 --- a/README.md +++ b/README.md @@ -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/ diff --git a/app/__init__.py b/app/__init__.py index 5c0fc08..cbc14ae 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -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}") diff --git a/app/models.py b/app/models.py index e7948ad..116d021 100644 --- a/app/models.py +++ b/app/models.py @@ -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"" + + +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"" + + 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"" @@ -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) diff --git a/app/routes.py b/app/routes.py index 5c07bc8..5cea87e 100644 --- a/app/routes.py +++ b/app/routes.py @@ -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//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//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//switch", methods=["POST"]) +@require_role("admin", "organizer") +def admin_event_switch(event_id): + event = Event.query.get_or_404(event_id) + user = current_user() + if not user.can_manage_event(event): + flash("Нет доступа к этому мероприятию", "danger") + return redirect(url_for("main.admin_events")) + + session["current_event_id"] = event.id + flash(f"Выбрано мероприятие: {event.name}", "info") + return redirect(url_for("main.admin_dashboard")) + + +@main_bp.route("/admin/events//ticket", methods=["GET", "POST"]) +@require_role("admin", "organizer") +def admin_event_ticket(event_id): + event = Event.query.get_or_404(event_id) + user = current_user() + if not user.can_manage_event(event): + flash("Нет доступа к этому мероприятию", "danger") + return redirect(url_for("main.admin_events")) + + if request.method == "POST": + event.ticket_title = request.form.get("ticket_title", "").strip() or event.name + event.ticket_subtitle = request.form.get("ticket_subtitle", "").strip() + event.ticket_body_text = request.form.get("ticket_body_text", "").strip() + event.ticket_background_color = request.form.get("ticket_background_color", "#ffffff").strip() + event.ticket_text_color = request.form.get("ticket_text_color", "#000000").strip() + event.ticket_accent_color = request.form.get("ticket_accent_color", "#0d6efd").strip() + event.ticket_show_qr = bool(request.form.get("ticket_show_qr")) + event.ticket_show_photo = bool(request.form.get("ticket_show_photo")) + event.ticket_layout = request.form.get("ticket_layout", "portrait").strip() + + # Logo upload + logo_file = request.files.get("logo_file") + if logo_file and logo_file.filename: + if allowed_file(logo_file.filename, {"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//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//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//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//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//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//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/", 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/") -@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) diff --git a/app/services/email_service.py b/app/services/email_service.py index 5ff08b1..9df1f58 100644 --- a/app/services/email_service.py +++ b/app/services/email_service.py @@ -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", "") + img.add_header("Content-Disposition", "inline", filename=f"ticket_{guest.token}.png") + msg.attach(img) + # Отправка через SMTP server = config["MAIL_SERVER"] port = config["MAIL_PORT"] diff --git a/app/services/excel_service.py b/app/services/excel_service.py index 4ed2749..dbb380e 100644 --- a/app/services/excel_service.py +++ b/app/services/excel_service.py @@ -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, diff --git a/app/services/ticket_service.py b/app/services/ticket_service.py new file mode 100644 index 0000000..7b46e68 --- /dev/null +++ b/app/services/ticket_service.py @@ -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) diff --git a/app/static/css/style.css b/app/static/css/style.css index 4829a6e..d356135 100644 --- a/app/static/css/style.css +++ b/app/static/css/style.css @@ -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; } diff --git a/app/static/js/checkin.js b/app/static/js/checkin.js index f38e482..209a460 100644 --- a/app/static/js/checkin.js +++ b/app/static/js/checkin.js @@ -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 = ''; - // 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 += ''; + 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 = ''; }); +} + +// 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) { diff --git a/app/templates/admin/dashboard.html b/app/templates/admin/dashboard.html index beb0b8b..7d744df 100644 --- a/app/templates/admin/dashboard.html +++ b/app/templates/admin/dashboard.html @@ -3,7 +3,7 @@ {% block title %}Аналитика — InviteMe{% endblock %} {% block content %} -

Аналитика мероприятия

+

Аналитика: {{ event.name }}

diff --git a/app/templates/admin/event_form.html b/app/templates/admin/event_form.html new file mode 100644 index 0000000..bce1bf3 --- /dev/null +++ b/app/templates/admin/event_form.html @@ -0,0 +1,42 @@ +{% extends "base.html" %} + +{% block title %}{{ 'Редактировать' if event else 'Создать' }} мероприятие — InviteMe{% endblock %} + +{% block content %} +

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

+ +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ {% if event %} +
+ + +
+ {% endif %} + + {% if event %} + Конструктор билета + {% endif %} + Отмена +
+
+
+{% endblock %} diff --git a/app/templates/admin/events.html b/app/templates/admin/events.html new file mode 100644 index 0000000..7c1240e --- /dev/null +++ b/app/templates/admin/events.html @@ -0,0 +1,51 @@ +{% extends "base.html" %} + +{% block title %}Мероприятия — InviteMe{% endblock %} + +{% block content %} +
+

Мероприятия

+ Создать мероприятие +
+ +
+ {% for event in events %} +
+
+
+
{{ event.name }}
+

+ {% if event.event_date %} + {{ event.event_date.strftime('%d.%m.%Y %H:%M') }}
+ {% endif %} + {% if event.location %} + {{ event.location }}
+ {% endif %} + Гостей: {{ event.total_guests }} | Пришло: {{ event.checked_in }} +

+ {% if event.description %} +

{{ event.description }}

+ {% endif %} +
+
+ +
+ Изменить + Билет + {% if not event.is_active %} + Неактивно + {% endif %} +
+
+ +
+
+ {% else %} +
+
Пока нет ни одного мероприятия. Создайте первое.
+
+ {% endfor %} +
+{% endblock %} diff --git a/app/templates/admin/guest_form.html b/app/templates/admin/guest_form.html index a8f97ee..21eac38 100644 --- a/app/templates/admin/guest_form.html +++ b/app/templates/admin/guest_form.html @@ -4,13 +4,24 @@ {% block content %}

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

+

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

+
+
+ + +
+
+ + +
+
- - + +
@@ -24,6 +35,10 @@
+
+ + +
diff --git a/app/templates/admin/guests.html b/app/templates/admin/guests.html index 4f7ac9b..275339a 100644 --- a/app/templates/admin/guests.html +++ b/app/templates/admin/guests.html @@ -4,7 +4,7 @@ {% block content %}
-

Гости

+

Гости: {{ event.name }}

Добавить гостя
@@ -24,6 +24,7 @@ Отдел Должность Email + Телефон Фото QR Статус @@ -33,10 +34,11 @@ {% for guest in guests %} - {{ guest.full_name }} + {{ guest.build_full_name() }} {{ guest.department }} {{ guest.position or '-' }} {{ guest.email }} + {{ guest.phone or '-' }} {% if guest.photo_filename %} Есть @@ -70,7 +72,7 @@ {% else %} - Гости не найдены + Гости не найдены {% endfor %} diff --git a/app/templates/admin/ticket_constructor.html b/app/templates/admin/ticket_constructor.html new file mode 100644 index 0000000..425f59b --- /dev/null +++ b/app/templates/admin/ticket_constructor.html @@ -0,0 +1,98 @@ +{% extends "base.html" %} + +{% block title %}Конструктор билета — {{ event.name }}{% endblock %} + +{% block content %} +
+

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

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

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

+ Предпросмотр билета +
+
+
+
+{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/app/templates/admin/upload.html b/app/templates/admin/upload.html index 0b2aa55..b59dde7 100644 --- a/app/templates/admin/upload.html +++ b/app/templates/admin/upload.html @@ -4,13 +4,14 @@ {% block content %}

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

+

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

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

Столбцы: Отдел, ФИО, Должность, e-mail, название фото

+

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

@@ -30,7 +31,7 @@
2. Загрузить фото гостей
-

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

+

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

@@ -46,4 +47,63 @@
+ +
+
+
+
+
Пример заполнения Excel-таблицы
+

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

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

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

+ +
+
+ +
+ + +
+
+ + +
+
+ + +
+ {% if user %} +
+ + +
+ {% endif %} + + Отмена + +
+
+{% endblock %} diff --git a/app/templates/admin/users.html b/app/templates/admin/users.html new file mode 100644 index 0000000..2e931fb --- /dev/null +++ b/app/templates/admin/users.html @@ -0,0 +1,58 @@ +{% extends "base.html" %} + +{% block title %}Пользователи — InviteMe{% endblock %} + +{% block content %} +
+

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

+ Добавить пользователя +
+ + + + + + + + + + + + + {% for user in users %} + + + + + + + + {% else %} + + + + {% endfor %} + +
ЛогинРольСтатусСозданДействия
{{ user.username }} + {% if user.role == 'admin' %} + Админ + {% elif user.role == 'organizer' %} + Организатор + {% else %} + Сотрудник + {% endif %} + + {% if user.is_active %} + Активен + {% else %} + Заблокирован + {% endif %} + {{ user.created_at.strftime('%d.%m.%Y %H:%M') }} + Изменить + {% if current_user.id != user.id %} +
+ +
+ {% endif %} +
Пользователи не найдены
+{% endblock %} diff --git a/app/templates/base.html b/app/templates/base.html index 46a59c2..959ea83 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -8,7 +8,7 @@ -