Initial commit: InviteMe production redesign

This commit is contained in:
InviteMe Developer
2026-09-01 10:37:40 +03:00
commit d54a17b0ad
71 changed files with 6898 additions and 0 deletions
+208
View File
@@ -0,0 +1,208 @@
import io
from pathlib import Path
from flask import current_app
from openpyxl import load_workbook, Workbook
from openpyxl.styles import Font, PatternFill, Alignment
from app import db
from app.models import Guest
from app.utils.security import safe_upload_filename
REQUIRED_COLUMNS = ["Отдел", "Фамилия", "Имя", "Отчество", "Должность", "Почта", "Телефон", "Название фото"]
def build_guest_import_template():
wb = Workbook()
ws = wb.active
ws.title = "Гости"
ws.append(REQUIRED_COLUMNS)
ws.append(["IT", "Иванов", "Иван", "Иванович", "Разработчик", "ivanov@example.com", "+7 999 111-22-33", "ivanov.jpg"])
for cell in ws[1]:
cell.font = Font(bold=True, color="FFFFFF")
cell.fill = PatternFill(start_color="0891B2", end_color="0891B2", fill_type="solid")
cell.alignment = Alignment(horizontal="center")
for index, width in enumerate([22, 20, 18, 22, 24, 30, 22, 24], start=1):
ws.column_dimensions[ws.cell(row=1, column=index).column_letter].width = width
ws.freeze_panes = "A2"
ws.auto_filter.ref = "A1:H2"
output = io.BytesIO()
wb.save(output)
wb.close()
output.seek(0)
return output
def _build_full_name(last_name, first_name, middle_name):
parts = [p for p in [last_name, first_name, middle_name] if p]
return " ".join(parts)
def _safe_excel_value(value):
"""Prevent spreadsheet formula injection in exported user-controlled cells."""
if isinstance(value, str) and value.startswith(("=", "+", "-", "@", "\t", "\r")):
return f"'{value}"
return value
def read_guests_from_excel(file_path):
"""Читает список гостей из Excel-файла (путь или файловый объект)."""
if not hasattr(file_path, "read"):
file_path = Path(file_path)
wb = load_workbook(file_path, data_only=True)
try:
ws = wb.active
if ws.max_row - 1 > current_app.config["MAX_EXCEL_ROWS"]:
raise ValueError(f"В Excel допускается не более {current_app.config['MAX_EXCEL_ROWS']} строк")
# Определяем заголовки и сразу считываем все строки
headers = [str(cell.value).strip() if cell.value else "" for cell in ws[1]]
rows = list(ws.iter_rows(min_row=2, values_only=True))
finally:
wb.close()
missing = [col for col in REQUIRED_COLUMNS if col not in headers]
if missing:
raise ValueError(f"В файле отсутствуют обязательные столбцы: {', '.join(missing)}")
col_indices = {col: headers.index(col) for col in REQUIRED_COLUMNS}
guests_data = []
for row in rows:
last_name = str(row[col_indices["Фамилия"]]).strip() if row[col_indices["Фамилия"]] else ""
first_name = str(row[col_indices["Имя"]]).strip() if row[col_indices["Имя"]] else ""
middle_name = str(row[col_indices["Отчество"]]).strip() if row[col_indices["Отчество"]] else ""
email = str(row[col_indices["Почта"]]).strip() if row[col_indices["Почта"]] else ""
if not email:
continue
full_name = _build_full_name(last_name, first_name, middle_name)
if not full_name:
# Если ФИО не заполнено, но есть email — пропускаем строку
continue
raw_photo_filename = str(row[col_indices["Название фото"]]).strip() if row[col_indices["Название фото"]] else ""
photo_filename = safe_upload_filename(raw_photo_filename) if raw_photo_filename else ""
guests_data.append({
"department": str(row[col_indices["Отдел"]]).strip() if row[col_indices["Отдел"]] else "",
"last_name": last_name,
"first_name": first_name,
"middle_name": middle_name,
"full_name": full_name,
"position": str(row[col_indices["Должность"]]).strip() if row[col_indices["Должность"]] else "",
"email": email,
"phone": str(row[col_indices["Телефон"]]).strip() if row[col_indices["Телефон"]] else "",
"photo_filename": photo_filename,
})
return guests_data
def import_guests_from_excel(file_path, event_id):
"""Импортирует гостей из Excel в базу данных в рамках указанного мероприятия."""
if not event_id:
raise ValueError("Не выбрано мероприятие для импорта")
guests_data = read_guests_from_excel(file_path)
imported = 0
updated = 0
for data in guests_data:
guest = Guest.query.filter_by(event_id=event_id, email=data["email"]).first()
if guest:
guest.department = data["department"]
guest.last_name = data["last_name"]
guest.first_name = data["first_name"]
guest.middle_name = data["middle_name"]
guest.full_name = data["full_name"]
guest.position = data["position"]
guest.phone = data["phone"]
guest.photo_filename = data["photo_filename"]
guest.is_active = True
updated += 1
else:
guest = Guest(
event_id=event_id,
department=data["department"],
last_name=data["last_name"],
first_name=data["first_name"],
middle_name=data["middle_name"],
full_name=data["full_name"],
position=data["position"],
email=data["email"],
phone=data["phone"],
photo_filename=data["photo_filename"],
)
db.session.add(guest)
imported += 1
db.session.commit()
return {"imported": imported, "updated": updated, "total": len(guests_data)}
def export_report_to_excel(output_path, event_id, event_name=None):
"""Выгружает отчёт о посещаемости для мероприятия в Excel."""
if not event_id:
raise ValueError("Не выбрано мероприятие для отчёта")
guests = Guest.query.filter_by(event_id=event_id).order_by(Guest.department, Guest.full_name).all()
wb = Workbook()
ws = wb.active
ws.title = "Отчёт"
headers = [
"Мероприятие", "Отдел", "Фамилия", "Имя", "Отчество",
"Должность", "Email", "Телефон", "Название фото",
"Статус приглашения", "Попыток отправки", "Ошибка отправки",
"RSVP", "Ответ получен", "Особенности питания", "Комментарий",
"Статус входа", "Время входа"
]
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([
_safe_excel_value(event_name or ""),
_safe_excel_value(guest.department),
_safe_excel_value(guest.last_name or ""),
_safe_excel_value(guest.first_name or ""),
_safe_excel_value(guest.middle_name or ""),
_safe_excel_value(guest.position or ""),
_safe_excel_value(guest.email),
_safe_excel_value(guest.phone or ""),
_safe_excel_value(guest.photo_filename or ""),
guest.invitation_status or "pending",
guest.invitation_attempts or 0,
_safe_excel_value(guest.invitation_last_error or ""),
guest.rsvp_status or "pending",
guest.rsvp_responded_at.strftime("%d.%m.%Y %H:%M:%S") if guest.rsvp_responded_at else "",
_safe_excel_value(guest.dietary_requirements or ""),
_safe_excel_value(guest.rsvp_comment 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