From b4e10b84482e38f5184677d12cd87ffaa5628268 Mon Sep 17 00:00:00 2001 From: InviteMe Developer Date: Fri, 28 Aug 2026 12:28:52 +0300 Subject: [PATCH] Initial commit: InviteMe event registration app --- .gitignore | 67 +++++ README.md | 76 +++++ app/__init__.py | 25 ++ app/models.py | 47 +++ app/routes.py | 436 ++++++++++++++++++++++++++++ app/services/__init__.py | 0 app/services/email_service.py | 60 ++++ app/services/excel_service.py | 112 +++++++ app/services/qr_service.py | 37 +++ app/static/css/style.css | 67 +++++ app/static/js/checkin.js | 198 +++++++++++++ app/templates/admin/dashboard.html | 91 ++++++ app/templates/admin/guest_form.html | 46 +++ app/templates/admin/guests.html | 78 +++++ app/templates/admin/upload.html | 49 ++++ app/templates/base.html | 65 +++++ app/templates/checkin/index.html | 52 ++++ app/templates/email/invitation.html | 27 ++ app/templates/login.html | 26 ++ config.py | 37 +++ docs/DEPLOY.md | 108 +++++++ requirements.txt | 8 + run.py | 6 + 23 files changed, 1718 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 app/__init__.py create mode 100644 app/models.py create mode 100644 app/routes.py create mode 100644 app/services/__init__.py create mode 100644 app/services/email_service.py create mode 100644 app/services/excel_service.py create mode 100644 app/services/qr_service.py create mode 100644 app/static/css/style.css create mode 100644 app/static/js/checkin.js create mode 100644 app/templates/admin/dashboard.html create mode 100644 app/templates/admin/guest_form.html create mode 100644 app/templates/admin/guests.html create mode 100644 app/templates/admin/upload.html create mode 100644 app/templates/base.html create mode 100644 app/templates/checkin/index.html create mode 100644 app/templates/email/invitation.html create mode 100644 app/templates/login.html create mode 100644 config.py create mode 100644 docs/DEPLOY.md create mode 100644 requirements.txt create mode 100644 run.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5506335 --- /dev/null +++ b/.gitignore @@ -0,0 +1,67 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Virtual environments +venv/ +ENV/ +env/ +.venv/ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# Application data +instance/ +*.db +*.sqlite +*.sqlite3 + +# Uploads +app/static/uploads/photos/* +app/static/uploads/qrcodes/* +app/static/uploads/reports/* +app/static/uploads/temp/* + +# Keep directory structure +!app/static/uploads/photos/.gitkeep +!app/static/uploads/qrcodes/.gitkeep +!app/static/uploads/reports/.gitkeep +!app/static/uploads/temp/.gitkeep + +# Environment +.env + +# Logs +*.log +logs/ + +# OS +.DS_Store +Thumbs.db +desktop.ini + +# Test files +test_guests.xlsx diff --git a/README.md b/README.md new file mode 100644 index 0000000..0f231ec --- /dev/null +++ b/README.md @@ -0,0 +1,76 @@ +# InviteMe + +Приложение для регистрации гостей на корпоративные мероприятия. Аналог Timepad с упрощённым функционалом. + +## Возможности + +- Загрузка списка гостей из Excel +- Хранение фото гостей и привязка по названию файла +- Генерация персональных QR-кодов +- Автоматическая рассылка приглашений на email +- Ручное добавление/удаление/блокировка участников +- Проверка входа по QR-коду или поиску по ФИО +- Аналитика посещаемости для администратора +- Выгрузка отчётов в Excel + +## Стек + +- Python 3.10+ +- Flask + Flask-SQLAlchemy +- SQLite +- Bootstrap 5 +- html5-qrcode +- SMTP + +## Быстрый старт + +```bash +cd C:\Projects\Integrita\InviteMe +python -m venv venv +venv\Scripts\activate +pip install -r requirements.txt +``` + +Создайте файл `.env` в корне проекта (см. `docs/DEPLOY.md`) и запустите: + +```bash +flask --app run.py run --host=0.0.0.0 --port=5000 +``` + +## Учётные записи по умолчанию + +- Админ: `admin` / `admin` +- Сотрудник на входе: `staff` / `staff` + +> Обязательно смените пароли в production через переменные окружения или `.env`. + +## Структура проекта + +``` +InviteMe/ +├── app/ +│ ├── __init__.py +│ ├── models.py # Модели Guest и CheckIn +│ ├── routes.py # Все маршруты приложения +│ ├── services/ # QR, email, Excel +│ ├── static/ +│ │ ├── css/ +│ │ ├── js/ +│ │ └── uploads/ # Фото и QR-коды +│ └── templates/ +│ ├── admin/ +│ ├── checkin/ +│ └── email/ +├── config.py # Конфигурация +├── docs/ +│ └── DEPLOY.md # Подробная инструкция по развёртыванию +├── requirements.txt +├── run.py +└── README.md +``` + +## Контекст проекта + +Проект разрабатывается для компании **Integrita**. Планируется развёртывание на виртуальной машине. + +См. `docs/DEPLOY.md` для инструкций по настройке SMTP, запуску в production и резервному копированию. diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..5c0fc08 --- /dev/null +++ b/app/__init__.py @@ -0,0 +1,25 @@ +from flask import Flask +from flask_sqlalchemy import SQLAlchemy +from config import Config +import os + +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(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() + + return app diff --git a/app/models.py b/app/models.py new file mode 100644 index 0000000..e7948ad --- /dev/null +++ b/app/models.py @@ -0,0 +1,47 @@ +import uuid +from datetime import datetime +from app import db + + +class Guest(db.Model): + __tablename__ = "guests" + + id = db.Column(db.Integer, primary_key=True) + department = db.Column(db.String(255), nullable=False) + 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) + photo_filename = db.Column(db.String(255), nullable=True) + token = db.Column(db.String(36), unique=True, nullable=False, default=lambda: str(uuid.uuid4())) + qr_filename = db.Column(db.String(255), nullable=True) + is_active = db.Column(db.Boolean, default=True, nullable=False) + invitation_sent = db.Column(db.Boolean, default=False, nullable=False) + created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) + updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) + + check_ins = db.relationship("CheckIn", backref="guest", lazy=True, cascade="all, delete-orphan") + + @property + def is_checked_in(self): + return len(self.check_ins) > 0 + + @property + def last_check_in(self): + if not self.check_ins: + return None + return max(check_in.checked_in_at for check_in in self.check_ins) + + def __repr__(self): + return f"" + + +class CheckIn(db.Model): + __tablename__ = "check_ins" + + id = db.Column(db.Integer, primary_key=True) + guest_id = db.Column(db.Integer, db.ForeignKey("guests.id"), nullable=False) + checked_in_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) + device_info = db.Column(db.String(500), nullable=True) + + def __repr__(self): + return f"" diff --git a/app/routes.py b/app/routes.py new file mode 100644 index 0000000..5c07bc8 --- /dev/null +++ b/app/routes.py @@ -0,0 +1,436 @@ +import os +import zipfile +from datetime import datetime +from pathlib import Path + +from flask import ( + Blueprint, render_template, request, redirect, url_for, flash, + session, jsonify, send_file, current_app +) +from werkzeug.utils import secure_filename + +from app import db +from app.models import 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 + +main_bp = Blueprint("main", __name__) + + +# --------------------------- +# Helpers +# --------------------------- + +def allowed_file(filename, extensions): + return "." in filename and filename.rsplit(".", 1)[1].lower() in extensions + + +def login_user(role): + session["role"] = role + session.permanent = True + + +def logout_user(): + session.pop("role", None) + + +def require_role(*roles): + def decorator(f): + def wrapped(*args, **kwargs): + if session.get("role") not in roles: + flash("Требуется авторизация", "warning") + return redirect(url_for("main.login")) + return f(*args, **kwargs) + wrapped.__name__ = f.__name__ + return wrapped + return decorator + + +# --------------------------- +# Auth +# --------------------------- + +@main_bp.route("/login", methods=["GET", "POST"]) +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")) + else: + flash("Неверный логин или пароль", "danger") + + return render_template("login.html") + + +@main_bp.route("/logout") +def logout(): + logout_user() + flash("Вы вышли из системы", "info") + return redirect(url_for("main.login")) + + +# --------------------------- +# Admin Dashboard +# --------------------------- + +@main_bp.route("/admin") +@require_role("admin") +def admin_dashboard(): + total = Guest.query.filter_by(is_active=True).count() + checked_in = db.session.query(CheckIn).count() + not_checked_in = total - checked_in + attendance_percent = round((checked_in / total) * 100, 1) if total else 0 + + recent_check_ins = ( + CheckIn.query + .order_by(CheckIn.checked_in_at.desc()) + .limit(10) + .all() + ) + + return render_template( + "admin/dashboard.html", + total=total, + checked_in=checked_in, + not_checked_in=not_checked_in, + attendance_percent=attendance_percent, + recent_check_ins=recent_check_ins, + ) + + +# --------------------------- +# Upload Excel / Photos +# --------------------------- + +@main_bp.route("/admin/upload", methods=["GET", "POST"]) +@require_role("admin") +def admin_upload(): + 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)) + flash( + f"Импорт завершён: добавлено {result['imported']}, обновлено {result['updated']} из {result['total']}", + "success", + ) + + # 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() + for guest in guests: + generate_qr_for_guest(guest, base_url=base_url) + db.session.commit() + + 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") + + # Photos upload (ZIP or multiple files) + photo_files = request.files.getlist("photo_files") + zip_file = request.files.get("zip_file") + + photo_folder = Path(current_app.config["PHOTO_FOLDER"]) + uploaded_count = 0 + + if zip_file and zip_file.filename: + if allowed_file(zip_file.filename, {"zip"}): + zip_path = Path(current_app.config["UPLOAD_FOLDER"]) / "temp" / secure_filename(zip_file.filename) + zip_path.parent.mkdir(parents=True, exist_ok=True) + zip_file.save(str(zip_path)) + try: + with zipfile.ZipFile(zip_path, "r") as z: + z.extractall(str(photo_folder)) + uploaded_count = len([name for name in z.namelist() if not name.endswith("/")]) + flash(f"Из ZIP распаковано файлов: {uploaded_count}", "success") + except Exception as e: + flash(f"Ошибка распаковки ZIP: {str(e)}", "danger") + finally: + if zip_path.exists(): + zip_path.unlink() + else: + flash("Неверный формат ZIP-архива", "danger") + + for photo in photo_files: + if photo and photo.filename: + filename = secure_filename(photo.filename) + photo.save(str(photo_folder / filename)) + uploaded_count += 1 + + if uploaded_count: + flash(f"Загружено фото: {uploaded_count}", "success") + + return redirect(url_for("main.admin_upload")) + + return render_template("admin/upload.html") + + +# --------------------------- +# Guests management +# --------------------------- + +@main_bp.route("/admin/guests") +@require_role("admin") +def admin_guests(): + search = request.args.get("q", "").strip() + query = Guest.query + 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) + + +@main_bp.route("/admin/guests/add", methods=["GET", "POST"]) +@require_role("admin") +def admin_guest_add(): + if request.method == "POST": + guest = Guest( + department=request.form.get("department", "").strip(), + full_name=request.form.get("full_name", "").strip(), + position=request.form.get("position", "").strip(), + email=request.form.get("email", "").strip(), + photo_filename=request.form.get("photo_filename", "").strip(), + ) + db.session.add(guest) + db.session.commit() + + base_url = request.form.get("base_url", "").strip() or None + generate_qr_for_guest(guest, base_url=base_url) + db.session.commit() + + if request.form.get("send_invitation"): + try: + send_invitation(guest) + flash("Гость добавлен и приглашение отправлено", "success") + except Exception as e: + flash(f"Гость добавлен, но не удалось отправить письмо: {str(e)}", "warning") + else: + flash("Гость добавлен", "success") + + return redirect(url_for("main.admin_guests")) + + return render_template("admin/guest_form.html", guest=None) + + +@main_bp.route("/admin/guests//edit", methods=["GET", "POST"]) +@require_role("admin") +def admin_guest_edit(guest_id): + guest = Guest.query.get_or_404(guest_id) + if request.method == "POST": + guest.department = request.form.get("department", "").strip() + guest.full_name = request.form.get("full_name", "").strip() + guest.position = request.form.get("position", "").strip() + guest.email = request.form.get("email", "").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) + + +@main_bp.route("/admin/guests//delete", methods=["POST"]) +@require_role("admin") +def admin_guest_delete(guest_id): + guest = Guest.query.get_or_404(guest_id) + guest.is_active = False + db.session.commit() + flash("Гость заблокирован (QR-код аннулирован)", "success") + return redirect(url_for("main.admin_guests")) + + +@main_bp.route("/admin/guests//resend", methods=["POST"]) +@require_role("admin") +def admin_guest_resend(guest_id): + guest = Guest.query.get_or_404(guest_id) + if not guest.qr_filename: + generate_qr_for_guest(guest) + db.session.commit() + try: + send_invitation(guest) + flash("Приглашение повторно отправлено", "success") + except Exception as e: + flash(f"Ошибка отправки: {str(e)}", "danger") + return redirect(url_for("main.admin_guests")) + + +@main_bp.route("/admin/send-invitations", methods=["POST"]) +@require_role("admin") +def admin_send_invitations(): + guests = Guest.query.filter_by(is_active=True, invitation_sent=False).all() + sent = 0 + failed = 0 + for guest in guests: + if not guest.qr_filename: + generate_qr_for_guest(guest) + try: + send_invitation(guest) + sent += 1 + except Exception: + failed += 1 + db.session.commit() + flash(f"Отправлено приглашений: {sent}, ошибок: {failed}", "success" if failed == 0 else "warning") + return redirect(url_for("main.admin_dashboard")) + + +# --------------------------- +# Reports +# --------------------------- + +@main_bp.route("/admin/report") +@require_role("admin") +def admin_report(): + 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" + output_path = report_path / filename + + try: + export_report_to_excel(str(output_path)) + 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")) + + +# --------------------------- +# Check-in +# --------------------------- + +@main_bp.route("/check-in") +@require_role("admin", "staff") +def check_in(): + token = request.args.get("token", "").strip() + guest = None + already_checked_in = False + + if token: + guest = Guest.query.filter_by(token=token, is_active=True).first() + if guest: + already_checked_in = guest.is_checked_in + + return render_template( + "checkin/index.html", + guest=guest, + token=token, + already_checked_in=already_checked_in, + ) + + +# --------------------------- +# API +# --------------------------- + +@main_bp.route("/api/guests/search") +@require_role("admin", "staff") +def api_guests_search(): + q = request.args.get("q", "").strip() + if len(q) < 2: + return jsonify([]) + + guests = ( + Guest.query + .filter(Guest.is_active == True) + .filter(Guest.full_name.ilike(f"%{q}%")) + .order_by(Guest.full_name) + .limit(20) + .all() + ) + + return jsonify([ + { + "id": g.id, + "full_name": g.full_name, + "department": g.department, + "position": g.position, + "email": g.email, + "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, + } + for g in guests + ]) + + +@main_bp.route("/api/check-in/", methods=["POST"]) +@require_role("admin", "staff") +def api_check_in(token): + guest = Guest.query.filter_by(token=token, is_active=True).first() + if not guest: + return jsonify({"success": False, "message": "Гость не найден или QR-код аннулирован"}), 404 + + if guest.is_checked_in: + return jsonify({ + "success": False, + "already_checked_in": True, + "checked_in_at": guest.last_check_in.isoformat(), + "message": "Гость уже отмечен как вошедший", + }), 409 + + check_in_record = CheckIn( + guest_id=guest.id, + device_info=request.headers.get("User-Agent", "")[:500], + ) + db.session.add(check_in_record) + db.session.commit() + + return jsonify({ + "success": True, + "message": "Вход отмечен", + "checked_in_at": check_in_record.checked_in_at.isoformat(), + "guest": { + "full_name": guest.full_name, + "department": guest.department, + "position": guest.position, + }, + }) + + +@main_bp.route("/api/guest/") +@require_role("admin", "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, + "department": guest.department, + "position": guest.position, + "email": guest.email, + "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, + }) + + +# --------------------------- +# Home +# --------------------------- + +@main_bp.route("/") +def index(): + if session.get("role") == "admin": + return redirect(url_for("main.admin_dashboard")) + elif session.get("role") == "staff": + return redirect(url_for("main.check_in")) + return redirect(url_for("main.login")) diff --git a/app/services/__init__.py b/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/services/email_service.py b/app/services/email_service.py new file mode 100644 index 0000000..5ff08b1 --- /dev/null +++ b/app/services/email_service.py @@ -0,0 +1,60 @@ +import smtplib +from email.mime.multipart import MIMEMultipart +from email.mime.text import MIMEText +from email.mime.image import MIMEImage +from email.utils import formataddr +from pathlib import Path +from flask import current_app, render_template + + +def send_invitation(guest, event_name="Корпоратив", additional_text=""): + """Отправляет приглашение гостю на email с QR-кодом во вложении.""" + config = current_app.config + + msg = MIMEMultipart("related") + msg["Subject"] = f"Приглашение на мероприятие: {event_name}" + msg["From"] = formataddr(("InviteMe", config.get("MAIL_DEFAULT_SENDER", config["MAIL_USERNAME"]))) + msg["To"] = guest.email + + # HTML-шаблон письма + html_body = render_template( + "email/invitation.html", + guest=guest, + event_name=event_name, + additional_text=additional_text, + qr_cid="qr_code_image", + ) + + msg.attach(MIMEText(html_body, "html", "utf-8")) + + # Прикрепляем QR-код + qr_path = Path(config["QR_FOLDER"]) / guest.qr_filename + if qr_path.exists(): + with open(qr_path, "rb") as img_file: + img = MIMEImage(img_file.read()) + img.add_header("Content-ID", "") + img.add_header("Content-Disposition", "inline", filename=guest.qr_filename) + msg.attach(img) + + # Отправка через SMTP + server = config["MAIL_SERVER"] + port = config["MAIL_PORT"] + username = config["MAIL_USERNAME"] + password = config["MAIL_PASSWORD"] + use_ssl = config.get("MAIL_USE_SSL", True) + + if not username or not password: + raise ValueError("MAIL_USERNAME и MAIL_PASSWORD должны быть указаны в конфигурации") + + if use_ssl: + with smtplib.SMTP_SSL(server, port) as smtp: + smtp.login(username, password) + smtp.send_message(msg) + else: + with smtplib.SMTP(server, port) as smtp: + smtp.starttls() + smtp.login(username, password) + smtp.send_message(msg) + + guest.invitation_sent = True + return True diff --git a/app/services/excel_service.py b/app/services/excel_service.py new file mode 100644 index 0000000..4ed2749 --- /dev/null +++ b/app/services/excel_service.py @@ -0,0 +1,112 @@ +from pathlib import Path +from openpyxl import load_workbook, Workbook +from openpyxl.styles import Font, PatternFill, Alignment +from app import db +from app.models import Guest + + +REQUIRED_COLUMNS = ["Отдел", "ФИО", "Должность", "e-mail", "название фото"] + + +def read_guests_from_excel(file_path): + """Читает список гостей из Excel-файла.""" + file_path = Path(file_path) + wb = load_workbook(file_path, data_only=True) + ws = wb.active + + # Определяем заголовки + headers = [str(cell.value).strip() if cell.value else "" for cell in ws[1]] + + missing = [col for col in REQUIRED_COLUMNS if col not in headers] + if missing: + raise ValueError(f"В файле отсутствуют обязательные столбцы: {', '.join(missing)}") + + col_indices = {col: headers.index(col) for col in REQUIRED_COLUMNS} + guests_data = [] + + for row in 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 "" + + if not full_name or not email: + continue + + guests_data.append({ + "department": str(row[col_indices["Отдел"]]).strip() if row[col_indices["Отдел"]] else "", + "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 "", + }) + + return guests_data + + +def import_guests_from_excel(file_path): + """Импортирует гостей из Excel в базу данных.""" + 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() + if guest: + guest.department = data["department"] + guest.full_name = data["full_name"] + guest.position = data["position"] + guest.photo_filename = data["photo_filename"] + guest.is_active = True + updated += 1 + else: + guest = Guest(**data) + db.session.add(guest) + imported += 1 + + db.session.commit() + return {"imported": imported, "updated": updated, "total": len(guests_data)} + + +def export_report_to_excel(output_path, event_name="Корпоратив"): + """Выгружает отчёт о посещаемости в Excel.""" + guests = Guest.query.order_by(Guest.department, Guest.full_name).all() + + wb = Workbook() + ws = wb.active + ws.title = "Отчёт" + + headers = ["Отдел", "ФИО", "Должность", "Email", "Название фото", "Статус", "Время входа"] + ws.append(headers) + + # Стили заголовков + for cell in ws[1]: + cell.font = Font(bold=True) + cell.fill = PatternFill(start_color="DDDDDD", end_color="DDDDDD", fill_type="solid") + cell.alignment = Alignment(horizontal="center") + + for guest in guests: + status = "Пришёл" if guest.is_checked_in else "Не пришёл" + check_in_time = guest.last_check_in.strftime("%d.%m.%Y %H:%M:%S") if guest.last_check_in else "" + ws.append([ + guest.department, + guest.full_name, + guest.position or "", + guest.email, + guest.photo_filename or "", + status, + check_in_time, + ]) + + # Автоширина колонок + for column in ws.columns: + max_length = 0 + column_letter = column[0].column_letter + for cell in column: + try: + if cell.value: + max_length = max(max_length, len(str(cell.value))) + except Exception: + pass + ws.column_dimensions[column_letter].width = min(max_length + 2, 50) + + wb.save(output_path) + return output_path diff --git a/app/services/qr_service.py b/app/services/qr_service.py new file mode 100644 index 0000000..5daf8b9 --- /dev/null +++ b/app/services/qr_service.py @@ -0,0 +1,37 @@ +import qrcode +from pathlib import Path +from flask import current_app, url_for + + +def generate_qr_for_guest(guest, base_url=None): + """Генерирует QR-код для гостя и сохраняет его в uploads/qrcodes/.""" + token = guest.token + qr_folder = Path(current_app.config["QR_FOLDER"]) + qr_folder.mkdir(parents=True, exist_ok=True) + + qr_filename = f"{token}.png" + qr_path = qr_folder / qr_filename + + # URL, который будет закодирован в QR + if base_url is None: + try: + check_in_url = url_for("main.check_in", token=token, _external=True) + except Exception: + check_in_url = f"http://localhost:5000/check-in?token={token}" + else: + check_in_url = f"{base_url.rstrip('/')}/check-in?token={token}" + + qr = qrcode.QRCode( + version=1, + error_correction=qrcode.constants.ERROR_CORRECT_H, + box_size=10, + border=4, + ) + qr.add_data(check_in_url) + qr.make(fit=True) + + img = qr.make_image(fill_color="black", back_color="white") + img.save(str(qr_path)) + + guest.qr_filename = qr_filename + return qr_path, check_in_url diff --git a/app/static/css/style.css b/app/static/css/style.css new file mode 100644 index 0000000..4829a6e --- /dev/null +++ b/app/static/css/style.css @@ -0,0 +1,67 @@ +body { + background-color: #f8f9fa; +} + +.navbar-brand { + font-weight: bold; +} + +.checkin-container { + max-width: 900px; + margin: 0 auto; +} + +.qr-reader { + width: 100%; + min-height: 300px; + border: 2px dashed #dee2e6; + border-radius: 8px; + display: flex; + align-items: center; + justify-content: center; + background-color: #fff; +} + +.guest-photo { + width: 100%; + max-width: 300px; + height: 300px; + object-fit: cover; + border-radius: 12px; + border: 3px solid #dee2e6; + background-color: #e9ecef; +} + +.guest-photo-placeholder { + width: 100%; + max-width: 300px; + height: 300px; + display: flex; + align-items: center; + justify-content: center; + background-color: #e9ecef; + border-radius: 12px; + color: #6c757d; + font-size: 1.2rem; +} + +#search-results .list-group-item { + cursor: pointer; +} + +#search-results .list-group-item:hover { + background-color: #f8f9fa; +} + +.btn-lg { + padding: 15px; + font-size: 1.25rem; +} + +.card { + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05); +} + +.table th { + white-space: nowrap; +} diff --git a/app/static/js/checkin.js b/app/static/js/checkin.js new file mode 100644 index 0000000..f38e482 --- /dev/null +++ b/app/static/js/checkin.js @@ -0,0 +1,198 @@ +let html5QrCode = null; +let currentToken = null; + +const qrReader = document.getElementById("qr-reader"); +const startScanBtn = document.getElementById("start-scan"); +const stopScanBtn = document.getElementById("stop-scan"); +const searchInput = document.getElementById("search-input"); +const searchResults = document.getElementById("search-results"); +const guestCard = document.getElementById("guest-card"); +const guestPhoto = document.getElementById("guest-photo"); +const guestName = document.getElementById("guest-name"); +const guestDepartment = document.getElementById("guest-department"); +const guestPosition = document.getElementById("guest-position"); +const guestEmail = document.getElementById("guest-email"); +const checkInStatus = document.getElementById("check-in-status"); +const checkInBtn = document.getElementById("check-in-btn"); +const resetBtn = document.getElementById("reset-btn"); + +function resetGuestCard() { + guestCard.classList.add("d-none"); + currentToken = null; + guestPhoto.src = ""; + guestName.textContent = ""; + guestDepartment.textContent = ""; + guestPosition.textContent = ""; + guestEmail.textContent = ""; + checkInStatus.classList.add("d-none"); + checkInStatus.className = "alert d-none"; + checkInBtn.disabled = false; + checkInBtn.textContent = "Отметить вход"; +} + +function showGuest(guest) { + currentToken = guest.token; + guestCard.classList.remove("d-none"); + + if (guest.photo_url) { + guestPhoto.src = guest.photo_url; + guestPhoto.classList.remove("d-none"); + } else { + guestPhoto.src = ""; + guestPhoto.classList.add("d-none"); + // Show placeholder + if (!guestPhoto.nextElementSibling || !guestPhoto.nextElementSibling.classList.contains("guest-photo-placeholder")) { + const placeholder = document.createElement("div"); + placeholder.className = "guest-photo-placeholder mx-auto mb-3"; + placeholder.textContent = "Нет фото"; + guestPhoto.parentNode.insertBefore(placeholder, guestPhoto.nextSibling); + } + } + + guestName.textContent = guest.full_name; + guestDepartment.textContent = guest.department; + guestPosition.textContent = guest.position || ""; + guestEmail.textContent = guest.email; + + if (guest.checked_in) { + checkInStatus.classList.remove("d-none", "alert-success", "alert-warning"); + checkInStatus.classList.add("alert-warning"); + checkInStatus.textContent = "Гость уже отмечен как вошедший"; + checkInBtn.disabled = true; + } else { + checkInStatus.classList.add("d-none"); + checkInBtn.disabled = false; + } + + window.scrollTo({ top: guestCard.offsetTop - 20, behavior: "smooth" }); +} + +function loadGuestByToken(token) { + fetch(`/api/guest/${token}`) + .then(response => { + if (!response.ok) throw new Error("Гость не найден"); + return response.json(); + }) + .then(guest => showGuest(guest)) + .catch(error => { + alert(error.message); + }); +} + +// QR Scanner +startScanBtn.addEventListener("click", () => { + html5QrCode = new Html5Qrcode("qr-reader"); + 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"); + + // 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("Камеры не найдены"); + } + }) + .catch(err => { + alert("Ошибка доступа к камере: " + err.message); + }); +}); + +stopScanBtn.addEventListener("click", () => { + if (html5QrCode) { + html5QrCode.stop(); + } + stopScanBtn.classList.add("d-none"); + startScanBtn.classList.remove("d-none"); +}); + +// Search by name +let searchTimeout = null; +searchInput.addEventListener("input", (e) => { + const query = e.target.value.trim(); + searchResults.innerHTML = ""; + + if (query.length < 2) return; + + clearTimeout(searchTimeout); + searchTimeout = setTimeout(() => { + fetch(`/api/guests/search?q=${encodeURIComponent(query)}`) + .then(response => response.json()) + .then(guests => { + searchResults.innerHTML = ""; + guests.forEach(guest => { + const item = document.createElement("button"); + item.className = "list-group-item list-group-item-action"; + item.innerHTML = `${guest.full_name}
${guest.department}`; + item.addEventListener("click", () => { + searchResults.innerHTML = ""; + searchInput.value = ""; + showGuest(guest); + }); + searchResults.appendChild(item); + }); + }); + }, 300); +}); + +// Check-in button +checkInBtn.addEventListener("click", () => { + if (!currentToken) return; + + checkInBtn.disabled = true; + checkInBtn.textContent = "Отметка..."; + + fetch(`/api/check-in/${currentToken}`, { method: "POST" }) + .then(response => response.json().then(data => ({ status: response.status, data }))) + .then(({ status, data }) => { + checkInStatus.classList.remove("d-none", "alert-success", "alert-warning", "alert-danger"); + if (data.success) { + checkInStatus.classList.add("alert-success"); + checkInStatus.textContent = "Вход успешно отмечен!"; + checkInBtn.textContent = "Вход отмечен"; + } else if (status === 409) { + checkInStatus.classList.add("alert-warning"); + checkInStatus.textContent = "Гость уже отмечен как вошедший"; + checkInBtn.textContent = "Уже отмечен"; + } else { + checkInStatus.classList.add("alert-danger"); + checkInStatus.textContent = data.message || "Ошибка"; + checkInBtn.disabled = false; + checkInBtn.textContent = "Отметить вход"; + } + }) + .catch(error => { + checkInStatus.classList.remove("d-none", "alert-success", "alert-warning", "alert-danger"); + checkInStatus.classList.add("alert-danger"); + checkInStatus.textContent = "Ошибка сети: " + error.message; + checkInBtn.disabled = false; + checkInBtn.textContent = "Отметить вход"; + }); +}); + +resetBtn.addEventListener("click", resetGuestCard); + +// If token is in URL (scanned via external scanner) +document.addEventListener("DOMContentLoaded", () => { + const urlParams = new URLSearchParams(window.location.search); + const token = urlParams.get("token"); + if (token) { + loadGuestByToken(token); + } +}); diff --git a/app/templates/admin/dashboard.html b/app/templates/admin/dashboard.html new file mode 100644 index 0000000..beb0b8b --- /dev/null +++ b/app/templates/admin/dashboard.html @@ -0,0 +1,91 @@ +{% extends "base.html" %} + +{% block title %}Аналитика — InviteMe{% endblock %} + +{% block content %} +

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

+ +
+
+
+
+
Всего гостей
+

{{ total }}

+
+
+
+
+
+
+
Пришло
+

{{ checked_in }}

+
+
+
+
+
+
+
Осталось
+

{{ not_checked_in }}

+
+
+
+
+
+
+
Посещаемость
+

{{ attendance_percent }}%

+
+
+
+
+ +
+
+
+
+
Быстрый поиск
+
+ + +
+
+
+
+
+
+
+
Действия
+ Выгрузить отчёт Excel +
+ +
+
+
+
+
+ +

Последние входы

+ + + + + + + + + + {% for check_in in recent_check_ins %} + + + + + + {% else %} + + + + {% endfor %} + +
ВремяФИООтдел
{{ check_in.checked_in_at.strftime('%d.%m.%Y %H:%M:%S') }}{{ check_in.guest.full_name }}{{ check_in.guest.department }}
Пока никто не отмечен
+{% endblock %} diff --git a/app/templates/admin/guest_form.html b/app/templates/admin/guest_form.html new file mode 100644 index 0000000..a8f97ee --- /dev/null +++ b/app/templates/admin/guest_form.html @@ -0,0 +1,46 @@ +{% extends "base.html" %} + +{% block title %}{{ 'Редактировать' if guest else 'Добавить' }} гостя — InviteMe{% endblock %} + +{% block content %} +

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

+ +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ {% if not guest %} +
+ + +
+
+ + +
+ {% endif %} + + Отмена +
+
+
+{% endblock %} diff --git a/app/templates/admin/guests.html b/app/templates/admin/guests.html new file mode 100644 index 0000000..4f7ac9b --- /dev/null +++ b/app/templates/admin/guests.html @@ -0,0 +1,78 @@ +{% extends "base.html" %} + +{% block title %}Гости — InviteMe{% endblock %} + +{% block content %} +
+

Гости

+ Добавить гостя +
+ +
+
+ +
+
+ +
+
+ + + + + + + + + + + + + + + + {% for guest in guests %} + + + + + + + + + + + {% else %} + + + + {% endfor %} + +
ФИООтделДолжностьEmailФотоQRСтатусДействия
{{ guest.full_name }}{{ guest.department }}{{ guest.position or '-' }}{{ guest.email }} + {% if guest.photo_filename %} + Есть + {% else %} + Нет + {% endif %} + + {% if guest.qr_filename %} + Да + {% else %} + Нет + {% endif %} + + {% if guest.is_checked_in %} + Пришёл + {% else %} + Не пришёл + {% endif %} + + Изменить +
+ +
+
+ +
+
Гости не найдены
+{% endblock %} diff --git a/app/templates/admin/upload.html b/app/templates/admin/upload.html new file mode 100644 index 0000000..0b2aa55 --- /dev/null +++ b/app/templates/admin/upload.html @@ -0,0 +1,49 @@ +{% extends "base.html" %} + +{% block title %}Загрузка данных — InviteMe{% endblock %} + +{% block content %} +

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

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

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

+
+
+ + +
+
+ + +
+ +
+
+
+
+ +
+
+
+
2. Загрузить фото гостей
+

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

+
+
+ + +
+
+ + +
+ +
+
+
+
+
+{% endblock %} diff --git a/app/templates/base.html b/app/templates/base.html new file mode 100644 index 0000000..46a59c2 --- /dev/null +++ b/app/templates/base.html @@ -0,0 +1,65 @@ + + + + + + {% block title %}InviteMe{% endblock %} + + + + + + +
+ {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} + {% for category, message in messages %} + + {% endfor %} + {% endif %} + {% endwith %} + + {% block content %}{% endblock %} +
+ + + {% block scripts %}{% endblock %} + + diff --git a/app/templates/checkin/index.html b/app/templates/checkin/index.html new file mode 100644 index 0000000..b3a35a6 --- /dev/null +++ b/app/templates/checkin/index.html @@ -0,0 +1,52 @@ +{% extends "base.html" %} + +{% block title %}Отметка входа — InviteMe{% endblock %} + +{% block content %} +
+

Отметка входа

+ +
+
+
+
+
Сканировать QR-код
+
+ + +
+
+
+ +
+
+
+
Или найти по ФИО
+ +
+
+
+
+
+ +
+
+ Фото гостя +

+

+

+

+ +
+ + + +
+
+
+{% endblock %} + +{% block scripts %} + + +{% endblock %} diff --git a/app/templates/email/invitation.html b/app/templates/email/invitation.html new file mode 100644 index 0000000..9a1a82e --- /dev/null +++ b/app/templates/email/invitation.html @@ -0,0 +1,27 @@ + + + + + Приглашение на {{ event_name }} + + +
+

Приглашение на мероприятие

+

Здравствуйте, {{ guest.full_name }}!

+

Вы приглашены на мероприятие {{ event_name }}.

+ +

Ваш персональный QR-код для входа:

+
+ QR-код +
+ + {% if additional_text %} +

{{ additional_text }}

+ {% endif %} + +

+ Пожалуйста, предъявите этот QR-код при входе. Не передавайте его другим лицам. +

+
+ + diff --git a/app/templates/login.html b/app/templates/login.html new file mode 100644 index 0000000..525b971 --- /dev/null +++ b/app/templates/login.html @@ -0,0 +1,26 @@ +{% extends "base.html" %} + +{% block title %}Вход — InviteMe{% endblock %} + +{% block content %} +
+
+
+
+

InviteMe

+
+
+ + +
+
+ + +
+ +
+
+
+
+
+{% endblock %} diff --git a/config.py b/config.py new file mode 100644 index 0000000..7710956 --- /dev/null +++ b/config.py @@ -0,0 +1,37 @@ +import os +from pathlib import Path + +from dotenv import load_dotenv + +BASE_DIR = Path(__file__).resolve().parent +load_dotenv(BASE_DIR / ".env") + +class Config: + SECRET_KEY = os.environ.get("SECRET_KEY") or "dev-secret-key-change-in-production" + BASE_URL = os.environ.get("BASE_URL") or None + + # Database + SQLALCHEMY_DATABASE_URI = os.environ.get("DATABASE_URL") or f"sqlite:///{BASE_DIR / 'instance' / 'inviteme.db'}" + SQLALCHEMY_TRACK_MODIFICATIONS = False + + # Uploads + UPLOAD_FOLDER = BASE_DIR / "app" / "static" / "uploads" + PHOTO_FOLDER = UPLOAD_FOLDER / "photos" + QR_FOLDER = UPLOAD_FOLDER / "qrcodes" + MAX_CONTENT_LENGTH = 16 * 1024 * 1024 # 16 MB + + # Email (SMTP) + MAIL_SERVER = os.environ.get("MAIL_SERVER") or "smtp.yandex.ru" + MAIL_PORT = int(os.environ.get("MAIL_PORT") or 465) + MAIL_USE_SSL = os.environ.get("MAIL_USE_SSL", "true").lower() in ["true", "1", "yes"] + MAIL_USERNAME = os.environ.get("MAIL_USERNAME") or "" + MAIL_PASSWORD = os.environ.get("MAIL_PASSWORD") or "" + MAIL_DEFAULT_SENDER = os.environ.get("MAIL_DEFAULT_SENDER") or MAIL_USERNAME + + # Admin credentials + ADMIN_USERNAME = os.environ.get("ADMIN_USERNAME") or "admin" + ADMIN_PASSWORD = os.environ.get("ADMIN_PASSWORD") or "admin" + + # Staff credentials (for check-in devices) + STAFF_USERNAME = os.environ.get("STAFF_USERNAME") or "staff" + STAFF_PASSWORD = os.environ.get("STAFF_PASSWORD") or "staff" diff --git a/docs/DEPLOY.md b/docs/DEPLOY.md new file mode 100644 index 0000000..46a357a --- /dev/null +++ b/docs/DEPLOY.md @@ -0,0 +1,108 @@ +# Руководство по развёртыванию InviteMe + +## Системные требования + +- Windows Server / Windows 10+ / Linux +- Python 3.10 или новее +- Доступ в интернет для отправки писем (SMTP) + +## Установка на Windows + +### 1. Подготовка окружения + +```cmd +cd C:\Projects\Integrita\InviteMe +python -m venv venv +venv\Scripts\activate +pip install -r requirements.txt +``` + +### 2. Настройка конфигурации + +Создайте файл `.env` в корне проекта: + +```env +SECRET_KEY=your-strong-secret-key-here +ADMIN_USERNAME=admin +ADMIN_PASSWORD=your-admin-password +STAFF_USERNAME=staff +STAFF_PASSWORD=your-staff-password + +MAIL_SERVER=smtp.yandex.ru +MAIL_PORT=465 +MAIL_USE_SSL=true +MAIL_USERNAME=your-email@yandex.ru +MAIL_PASSWORD=your-app-password +MAIL_DEFAULT_SENDER=your-email@yandex.ru +``` + +> Для Yandex Mail используйте пароль приложения, а не основной пароль. + +### 3. Запуск в режиме разработки + +```cmd +flask --app run.py run --host=0.0.0.0 --port=5000 +``` + +Откройте в браузере: `http://localhost:5000` + +### 4. Запуск в production + +Для production рекомендуется использовать WSGI-сервер: + +```cmd +pip install waitress +waitress-serve --host=0.0.0.0 --port=5000 run:app +``` + +Или через PowerShell: + +```powershell +waitress-serve --host=0.0.0.0 --port=5000 run:app +``` + +### 5. Доступ с других устройств + +- Убедитесь, что порт 5000 открыт в брандмауэре +- Используйте IP-адрес сервера в локальной сети +- Для QR-кодов укажите внешний URL при загрузке Excel или в `.env`: + +```env +BASE_URL=https://your-domain-or-ip +``` + +### 6. Резервное копирование + +Важные данные для бэкапа: +- `instance/inviteme.db` — база данных +- `app/static/uploads/photos/` — фото гостей +- `app/static/uploads/qrcodes/` — QR-коды +- `.env` — конфигурация + +## Инструкция для сотрудников на входе + +1. Откройте `http://:5000/check-in` +2. Войдите под учётной записью staff +3. Для проверки QR: + - Нажмите «Включить камеру» + - Наведите на QR-код гостя +4. Для поиска по ФИО: + - Введите фамилию в поле поиска + - Выберите нужного человека из списка +5. Сверьте фото в программе с гостем +6. Нажмите «Отметить вход» + +## Устранение неполадок + +### Письма не отправляются +- Проверьте настройки SMTP в `.env` +- Убедитесь, что используется пароль приложения (для Yandex/Google) +- Проверьте логи в консоли + +### QR-код ведёт на localhost +- При загрузке Excel укажите базовый URL сервера +- Или добавьте `BASE_URL` в `.env` + +### Фото не отображаются +- Проверьте, что название фото в Excel совпадает с именем файла (с расширением) +- Фото должны быть в папке `app/static/uploads/photos/` diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..75b1dff --- /dev/null +++ b/requirements.txt @@ -0,0 +1,8 @@ +Flask==3.0.0 +Flask-SQLAlchemy==3.1.1 +Werkzeug==3.0.1 +openpyxl==3.1.2 +qrcode==7.4.2 +Pillow==10.1.0 +python-dotenv==1.0.0 +Jinja2==3.1.2 diff --git a/run.py b/run.py new file mode 100644 index 0000000..f61366d --- /dev/null +++ b/run.py @@ -0,0 +1,6 @@ +from app import create_app + +app = create_app() + +if __name__ == "__main__": + app.run(host="0.0.0.0", port=5000, debug=True)