Initial commit: InviteMe event registration app
This commit is contained in:
+67
@@ -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
|
||||
@@ -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 и резервному копированию.
|
||||
@@ -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
|
||||
@@ -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
@@ -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"))
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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>
|
||||
@@ -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 %}
|
||||
@@ -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>
|
||||
@@ -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 %}
|
||||
@@ -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
@@ -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/`
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user