Initial commit: InviteMe event registration app

This commit is contained in:
InviteMe Developer
2026-08-28 12:28:52 +03:00
commit b4e10b8448
23 changed files with 1718 additions and 0 deletions
+67
View File
@@ -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
+76
View File
@@ -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 и резервному копированию.
+25
View File
@@ -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
+47
View File
@@ -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"<Guest {self.full_name} ({self.email})>"
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"<CheckIn guest_id={self.guest_id} at={self.checked_in_at}>"
+436
View File
@@ -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/<int:guest_id>/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/<int:guest_id>/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/<int:guest_id>/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/<token>", 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/<token>")
@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"))
View File
+60
View File
@@ -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", "<qr_code_image>")
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
+112
View File
@@ -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
+37
View File
@@ -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
+67
View File
@@ -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;
}
+198
View File
@@ -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 = `<strong>${guest.full_name}</strong><br><small>${guest.department}</small>`;
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);
}
});
+91
View File
@@ -0,0 +1,91 @@
{% extends "base.html" %}
{% block title %}Аналитика — InviteMe{% endblock %}
{% block content %}
<h1 class="mb-4">Аналитика мероприятия</h1>
<div class="row">
<div class="col-md-3">
<div class="card text-white bg-info mb-3">
<div class="card-body">
<h5 class="card-title">Всего гостей</h5>
<p class="card-text display-6">{{ total }}</p>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card text-white bg-success mb-3">
<div class="card-body">
<h5 class="card-title">Пришло</h5>
<p class="card-text display-6">{{ checked_in }}</p>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card text-white bg-secondary mb-3">
<div class="card-body">
<h5 class="card-title">Осталось</h5>
<p class="card-text display-6">{{ not_checked_in }}</p>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card text-white bg-primary mb-3">
<div class="card-body">
<h5 class="card-title">Посещаемость</h5>
<p class="card-text display-6">{{ attendance_percent }}%</p>
</div>
</div>
</div>
</div>
<div class="row">
<div class="col-md-6">
<div class="card">
<div class="card-body">
<h5 class="card-title">Быстрый поиск</h5>
<form action="{{ url_for('main.admin_guests') }}" method="GET" class="d-flex">
<input type="text" name="q" class="form-control me-2" placeholder="Фамилия или ФИО" required>
<button type="submit" class="btn btn-primary">Найти</button>
</form>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card">
<div class="card-body">
<h5 class="card-title">Действия</h5>
<a href="{{ url_for('main.admin_report') }}" class="btn btn-success">Выгрузить отчёт Excel</a>
<form action="{{ url_for('main.admin_send_invitations') }}" method="POST" class="d-inline">
<button type="submit" class="btn btn-outline-primary">Разослать приглашения</button>
</form>
</div>
</div>
</div>
</div>
<h3 class="mt-5">Последние входы</h3>
<table class="table table-striped">
<thead>
<tr>
<th>Время</th>
<th>ФИО</th>
<th>Отдел</th>
</tr>
</thead>
<tbody>
{% for check_in in recent_check_ins %}
<tr>
<td>{{ check_in.checked_in_at.strftime('%d.%m.%Y %H:%M:%S') }}</td>
<td>{{ check_in.guest.full_name }}</td>
<td>{{ check_in.guest.department }}</td>
</tr>
{% else %}
<tr>
<td colspan="3" class="text-muted">Пока никто не отмечен</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endblock %}
+46
View File
@@ -0,0 +1,46 @@
{% extends "base.html" %}
{% block title %}{{ 'Редактировать' if guest else 'Добавить' }} гостя — InviteMe{% endblock %}
{% block content %}
<h1 class="mb-4">{{ 'Редактировать' if guest else 'Добавить' }} гостя</h1>
<div class="row">
<div class="col-md-6">
<form method="POST">
<div class="mb-3">
<label for="full_name" class="form-label">ФИО</label>
<input type="text" class="form-control" id="full_name" name="full_name" value="{{ guest.full_name if guest else '' }}" required>
</div>
<div class="mb-3">
<label for="department" class="form-label">Отдел</label>
<input type="text" class="form-control" id="department" name="department" value="{{ guest.department if guest else '' }}" required>
</div>
<div class="mb-3">
<label for="position" class="form-label">Должность</label>
<input type="text" class="form-control" id="position" name="position" value="{{ guest.position if guest else '' }}">
</div>
<div class="mb-3">
<label for="email" class="form-label">Email</label>
<input type="email" class="form-control" id="email" name="email" value="{{ guest.email if guest else '' }}" required>
</div>
<div class="mb-3">
<label for="photo_filename" class="form-label">Название фото</label>
<input type="text" class="form-control" id="photo_filename" name="photo_filename" value="{{ guest.photo_filename if guest else '' }}">
</div>
{% if not guest %}
<div class="mb-3">
<label for="base_url" class="form-label">Базовый URL для QR-кода (опционально)</label>
<input type="url" class="form-control" id="base_url" name="base_url" placeholder="https://events.company.com">
</div>
<div class="mb-3 form-check">
<input type="checkbox" class="form-check-input" id="send_invitation" name="send_invitation" checked>
<label class="form-check-label" for="send_invitation">Отправить приглашение сразу</label>
</div>
{% endif %}
<button type="submit" class="btn btn-primary">Сохранить</button>
<a href="{{ url_for('main.admin_guests') }}" class="btn btn-secondary">Отмена</a>
</form>
</div>
</div>
{% endblock %}
+78
View File
@@ -0,0 +1,78 @@
{% extends "base.html" %}
{% block title %}Гости — InviteMe{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h1>Гости</h1>
<a href="{{ url_for('main.admin_guest_add') }}" class="btn btn-primary">Добавить гостя</a>
</div>
<form method="GET" class="row g-3 mb-4">
<div class="col-auto">
<input type="text" name="q" class="form-control" placeholder="Поиск по ФИО" value="{{ search }}">
</div>
<div class="col-auto">
<button type="submit" class="btn btn-outline-primary">Найти</button>
</div>
</form>
<table class="table table-striped table-hover">
<thead>
<tr>
<th>ФИО</th>
<th>Отдел</th>
<th>Должность</th>
<th>Email</th>
<th>Фото</th>
<th>QR</th>
<th>Статус</th>
<th>Действия</th>
</tr>
</thead>
<tbody>
{% for guest in guests %}
<tr class="{{ 'table-secondary' if not guest.is_active }}">
<td>{{ guest.full_name }}</td>
<td>{{ guest.department }}</td>
<td>{{ guest.position or '-' }}</td>
<td>{{ guest.email }}</td>
<td>
{% if guest.photo_filename %}
<span class="badge bg-success">Есть</span>
{% else %}
<span class="badge bg-warning text-dark">Нет</span>
{% endif %}
</td>
<td>
{% if guest.qr_filename %}
<span class="badge bg-success">Да</span>
{% else %}
<span class="badge bg-secondary">Нет</span>
{% endif %}
</td>
<td>
{% if guest.is_checked_in %}
<span class="badge bg-success">Пришёл</span>
{% else %}
<span class="badge bg-secondary">Не пришёл</span>
{% endif %}
</td>
<td>
<a href="{{ url_for('main.admin_guest_edit', guest_id=guest.id) }}" class="btn btn-sm btn-outline-primary">Изменить</a>
<form action="{{ url_for('main.admin_guest_resend', guest_id=guest.id) }}" method="POST" class="d-inline">
<button type="submit" class="btn btn-sm btn-outline-success">QR</button>
</form>
<form action="{{ url_for('main.admin_guest_delete', guest_id=guest.id) }}" method="POST" class="d-inline" onsubmit="return confirm('Заблокировать гостя?')">
<button type="submit" class="btn btn-sm btn-outline-danger">Блок</button>
</form>
</td>
</tr>
{% else %}
<tr>
<td colspan="8" class="text-center text-muted">Гости не найдены</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endblock %}
+49
View File
@@ -0,0 +1,49 @@
{% extends "base.html" %}
{% block title %}Загрузка данных — InviteMe{% endblock %}
{% block content %}
<h1 class="mb-4">Загрузка данных</h1>
<div class="row">
<div class="col-md-6">
<div class="card">
<div class="card-body">
<h5 class="card-title">1. Загрузить Excel со списком гостей</h5>
<p class="text-muted">Столбцы: Отдел, ФИО, Должность, e-mail, название фото</p>
<form method="POST" enctype="multipart/form-data">
<div class="mb-3">
<label for="excel_file" class="form-label">Excel-файл</label>
<input type="file" class="form-control" id="excel_file" name="excel_file" accept=".xlsx,.xls" required>
</div>
<div class="mb-3">
<label for="base_url" class="form-label">Базовый URL для QR-кодов (опционально)</label>
<input type="url" class="form-control" id="base_url" name="base_url" placeholder="https://events.company.com">
</div>
<button type="submit" class="btn btn-primary">Загрузить и сгенерировать QR</button>
</form>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card">
<div class="card-body">
<h5 class="card-title">2. Загрузить фото гостей</h5>
<p class="text-muted">Фото должны называться точно так же, как указано в столбце «название фото»</p>
<form method="POST" enctype="multipart/form-data">
<div class="mb-3">
<label for="photo_files" class="form-label">Фото (можно несколько)</label>
<input type="file" class="form-control" id="photo_files" name="photo_files" accept="image/*" multiple>
</div>
<div class="mb-3">
<label for="zip_file" class="form-label">Или ZIP-архив</label>
<input type="file" class="form-control" id="zip_file" name="zip_file" accept=".zip">
</div>
<button type="submit" class="btn btn-primary">Загрузить фото</button>
</form>
</div>
</div>
</div>
</div>
{% endblock %}
+65
View File
@@ -0,0 +1,65 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}InviteMe{% endblock %}</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
</head>
<body>
<nav class="navbar navbar-expand-lg navbar-dark bg-primary">
<div class="container-fluid">
<a class="navbar-brand" href="{{ url_for('main.index') }}">InviteMe</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav me-auto">
{% if session.role == 'admin' %}
<li class="nav-item">
<a class="nav-link" href="{{ url_for('main.admin_dashboard') }}">Аналитика</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{{ url_for('main.admin_upload') }}">Загрузка</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{{ url_for('main.admin_guests') }}">Гости</a>
</li>
{% endif %}
{% if session.role in ('admin', 'staff') %}
<li class="nav-item">
<a class="nav-link" href="{{ url_for('main.check_in') }}">Вход</a>
</li>
{% endif %}
</ul>
{% if session.role %}
<ul class="navbar-nav">
<li class="nav-item">
<a class="nav-link" href="{{ url_for('main.logout') }}">Выйти</a>
</li>
</ul>
{% endif %}
</div>
</div>
</nav>
<div class="container mt-4">
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="alert alert-{{ 'danger' if category == 'error' else category }} alert-dismissible fade show" role="alert">
{{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
{% endfor %}
{% endif %}
{% endwith %}
{% block content %}{% endblock %}
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
{% block scripts %}{% endblock %}
</body>
</html>
+52
View File
@@ -0,0 +1,52 @@
{% extends "base.html" %}
{% block title %}Отметка входа — InviteMe{% endblock %}
{% block content %}
<div class="checkin-container">
<h1 class="mb-4 text-center">Отметка входа</h1>
<div class="row">
<div class="col-md-6 mb-3">
<div class="card">
<div class="card-body">
<h5 class="card-title">Сканировать QR-код</h5>
<div id="qr-reader" class="qr-reader"></div>
<button id="start-scan" class="btn btn-primary w-100 mt-2">Включить камеру</button>
<button id="stop-scan" class="btn btn-secondary w-100 mt-2 d-none">Выключить камеру</button>
</div>
</div>
</div>
<div class="col-md-6 mb-3">
<div class="card">
<div class="card-body">
<h5 class="card-title">Или найти по ФИО</h5>
<input type="text" id="search-input" class="form-control" placeholder="Введите фамилию..." autocomplete="off">
<div id="search-results" class="list-group mt-2"></div>
</div>
</div>
</div>
</div>
<div id="guest-card" class="card mt-4 d-none">
<div class="card-body text-center">
<img id="guest-photo" src="" alt="Фото гостя" class="guest-photo mb-3">
<h2 id="guest-name" class="card-title"></h2>
<p id="guest-department" class="lead"></p>
<p id="guest-position" class="text-muted"></p>
<p id="guest-email" class="text-muted"></p>
<div id="check-in-status" class="alert d-none"></div>
<button id="check-in-btn" class="btn btn-success btn-lg w-100">Отметить вход</button>
<button id="reset-btn" class="btn btn-outline-secondary w-100 mt-2">Сбросить</button>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script src="https://unpkg.com/html5-qrcode@2.3.8/html5-qrcode.min.js"></script>
<script src="{{ url_for('static', filename='js/checkin.js') }}"></script>
{% endblock %}
+27
View File
@@ -0,0 +1,27 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<title>Приглашение на {{ event_name }}</title>
</head>
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
<div style="max-width: 600px; margin: 0 auto; padding: 20px; border: 1px solid #ddd; border-radius: 8px;">
<h2 style="color: #0d6efd;">Приглашение на мероприятие</h2>
<p>Здравствуйте, <strong>{{ guest.full_name }}</strong>!</p>
<p>Вы приглашены на мероприятие <strong>{{ event_name }}</strong>.</p>
<p>Ваш персональный QR-код для входа:</p>
<div style="text-align: center; margin: 20px 0;">
<img src="cid:qr_code_image" alt="QR-код" style="max-width: 250px;">
</div>
{% if additional_text %}
<p>{{ additional_text }}</p>
{% endif %}
<p style="color: #666; font-size: 14px;">
Пожалуйста, предъявите этот QR-код при входе. Не передавайте его другим лицам.
</p>
</div>
</body>
</html>
+26
View File
@@ -0,0 +1,26 @@
{% extends "base.html" %}
{% block title %}Вход — InviteMe{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-md-4">
<div class="card shadow-sm mt-5">
<div class="card-body">
<h3 class="card-title text-center mb-4">InviteMe</h3>
<form method="POST" action="{{ url_for('main.login') }}">
<div class="mb-3">
<label for="username" class="form-label">Логин</label>
<input type="text" class="form-control" id="username" name="username" required autofocus>
</div>
<div class="mb-3">
<label for="password" class="form-label">Пароль</label>
<input type="password" class="form-control" id="password" name="password" required>
</div>
<button type="submit" class="btn btn-primary w-100">Войти</button>
</form>
</div>
</div>
</div>
</div>
{% endblock %}
+37
View File
@@ -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"
+108
View File
@@ -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://<ip-сервера>: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/`
+8
View File
@@ -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
+6
View File
@@ -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)