Initial commit: InviteMe production redesign
This commit is contained in:
@@ -0,0 +1,47 @@
|
||||
from flask import request
|
||||
|
||||
from app import db
|
||||
from app.models import AuditLog
|
||||
|
||||
|
||||
def log_action(action, target_type=None, target_id=None, details=None):
|
||||
"""Записывает действие пользователя в аудит-лог.
|
||||
|
||||
Аргументы:
|
||||
action: короткий код действия, например "guest_create", "event_delete".
|
||||
target_type: тип объекта (event, guest, user, ...).
|
||||
target_id: идентификатор объекта (может быть строкой).
|
||||
details: произвольное текстовое описание или причина.
|
||||
"""
|
||||
try:
|
||||
from app.routes import current_user
|
||||
|
||||
user = current_user()
|
||||
user_id = user.id if user else None
|
||||
username = user.username if user else None
|
||||
except Exception:
|
||||
user_id = None
|
||||
username = None
|
||||
|
||||
ip_address = None
|
||||
try:
|
||||
if request:
|
||||
# Учитываем прокси
|
||||
ip_address = request.headers.get("X-Forwarded-For", request.remote_addr)
|
||||
if ip_address:
|
||||
ip_address = ip_address.split(",")[0].strip()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
record = AuditLog(
|
||||
user_id=user_id,
|
||||
username=username,
|
||||
action=action,
|
||||
target_type=target_type,
|
||||
target_id=str(target_id) if target_id is not None else None,
|
||||
details=details,
|
||||
ip_address=ip_address,
|
||||
)
|
||||
db.session.add(record)
|
||||
# Не делаем commit здесь, чтобы не ломать транзакции вызывающего кода.
|
||||
# Вызывающий код сам делает db.session.commit() после основных изменений.
|
||||
@@ -0,0 +1,291 @@
|
||||
import smtplib
|
||||
import time
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
from email.mime.text import MIMEText
|
||||
from email.mime.image import MIMEImage
|
||||
from email.utils import formataddr, formatdate
|
||||
from pathlib import Path
|
||||
from flask import current_app, has_request_context, render_template, url_for
|
||||
|
||||
from app.services.ticket_service import generate_ticket_image
|
||||
from app.utils.paths import event_qr_folder, safe_child_path
|
||||
|
||||
|
||||
OUTLOOK_CONTENT_ID_PROP = "http://schemas.microsoft.com/mapi/proptag/0x3712001F"
|
||||
|
||||
|
||||
def _build_mime_message(to_address, subject, html_body, sender_name, attachments):
|
||||
"""Собирает MIME-сообщение с inline-вложениями."""
|
||||
config = current_app.config
|
||||
sender_address = config.get("MAIL_USERNAME", "")
|
||||
if not sender_address:
|
||||
raise ValueError("MAIL_USERNAME должен быть указан в конфигурации")
|
||||
# Имя отправителя — отдельно от email-адреса
|
||||
display_name = sender_name or config.get("MAIL_DEFAULT_SENDER") or ""
|
||||
|
||||
msg = MIMEMultipart("related")
|
||||
msg["Subject"] = subject
|
||||
msg["From"] = formataddr((display_name, sender_address))
|
||||
msg["To"] = to_address
|
||||
msg["Date"] = formatdate(localtime=True)
|
||||
msg.attach(MIMEText(html_body, "html", "utf-8"))
|
||||
|
||||
for att in attachments:
|
||||
path = att["path"]
|
||||
cid = att.get("cid")
|
||||
filename = att.get("filename", path.name)
|
||||
with open(path, "rb") as img_file:
|
||||
img = MIMEImage(img_file.read())
|
||||
if cid:
|
||||
img.add_header("Content-ID", f"<{cid}>")
|
||||
img.add_header("Content-Disposition", "inline", filename=filename)
|
||||
msg.attach(img)
|
||||
|
||||
return msg
|
||||
|
||||
|
||||
def _send_smtp_email(to_address, subject, html_body, sender_name, attachments):
|
||||
"""Отправляет письмо через SMTP с inline-вложениями."""
|
||||
config = current_app.config
|
||||
|
||||
server = config.get("MAIL_SERVER", "")
|
||||
port = config.get("MAIL_PORT", 465)
|
||||
username = config.get("MAIL_USERNAME", "")
|
||||
password = config.get("MAIL_PASSWORD", "")
|
||||
use_ssl = config.get("MAIL_USE_SSL", True)
|
||||
|
||||
if not username or not password:
|
||||
raise ValueError("MAIL_USERNAME и MAIL_PASSWORD должны быть указаны в конфигурации")
|
||||
if not server or not port:
|
||||
raise ValueError("MAIL_SERVER и MAIL_PORT должны быть указаны в конфигурации")
|
||||
|
||||
msg = _build_mime_message(to_address, subject, html_body, sender_name, attachments)
|
||||
|
||||
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)
|
||||
|
||||
return msg
|
||||
|
||||
|
||||
def _send_outlook_email(to_address, subject, html_body, sender_name, attachments):
|
||||
"""Отправляет письмо через локальный Outlook (MAPI/COM)."""
|
||||
try:
|
||||
import win32com.client
|
||||
except ImportError as e:
|
||||
raise RuntimeError("Для отправки через Outlook необходима библиотека pywin32") from e
|
||||
|
||||
try:
|
||||
outlook = win32com.client.Dispatch("Outlook.Application")
|
||||
except Exception as e:
|
||||
raise RuntimeError("Не удалось подключиться к Outlook. Убедитесь, что Outlook установлен и запущен.") from e
|
||||
|
||||
mail = outlook.CreateItem(0) # olMailItem
|
||||
mail.To = to_address
|
||||
mail.Subject = subject
|
||||
mail.HTMLBody = html_body
|
||||
|
||||
for att in attachments:
|
||||
path = att["path"]
|
||||
cid = att.get("cid")
|
||||
filename = att.get("filename", path.name)
|
||||
# 1 = olByValue, 0 = позиция (inline)
|
||||
attachment = mail.Attachments.Add(str(path), 1, 0, filename)
|
||||
if cid:
|
||||
attachment.PropertyAccessor.SetProperty(OUTLOOK_CONTENT_ID_PROP, cid)
|
||||
|
||||
mail.Send()
|
||||
|
||||
|
||||
def _send_ews_email(to_address, subject, html_body, sender_name, attachments):
|
||||
"""Отправляет письмо через Exchange Web Services (EWS)."""
|
||||
try:
|
||||
from exchangelib import Credentials, Configuration, Account, Message, Mailbox, HTMLBody, FileAttachment
|
||||
except ImportError as e:
|
||||
raise RuntimeError("Для отправки через EWS необходима библиотека exchangelib") from e
|
||||
|
||||
config = current_app.config
|
||||
ews_url = config.get("EWS_URL", "")
|
||||
username = config.get("MAIL_USERNAME", "")
|
||||
password = config.get("MAIL_PASSWORD", "")
|
||||
|
||||
if not ews_url or not username or not password:
|
||||
raise ValueError("Для EWS должны быть указаны EWS URL, логин и пароль")
|
||||
|
||||
try:
|
||||
credentials = Credentials(username=username, password=password)
|
||||
ews_config = Configuration(service_endpoint=ews_url, credentials=credentials)
|
||||
account = Account(
|
||||
primary_smtp_address=username,
|
||||
config=ews_config,
|
||||
autodiscover=False,
|
||||
access_type="delegate",
|
||||
)
|
||||
|
||||
msg = Message(
|
||||
account=account,
|
||||
subject=subject,
|
||||
body=HTMLBody(html_body),
|
||||
to_recipients=[Mailbox(email_address=to_address)],
|
||||
)
|
||||
|
||||
for att in attachments:
|
||||
path = att["path"]
|
||||
filename = att.get("filename", path.name)
|
||||
cid = att.get("cid")
|
||||
with open(path, "rb") as f:
|
||||
content = f.read()
|
||||
file_att = FileAttachment(name=filename, content=content, content_id=cid)
|
||||
msg.attach(file_att)
|
||||
|
||||
msg.send()
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"Ошибка отправки через EWS: {e}") from e
|
||||
|
||||
|
||||
def _save_imap_copy(to_address, subject, html_body, sender_name, attachments):
|
||||
"""Сохраняет копию отправленного письма в IMAP-папку (обычно Sent)."""
|
||||
import imaplib
|
||||
|
||||
config = current_app.config
|
||||
if not config.get("IMAP_SAVE_COPY"):
|
||||
return
|
||||
|
||||
server = config.get("IMAP_SERVER", "")
|
||||
port = config.get("IMAP_PORT", 993)
|
||||
username = config.get("IMAP_USERNAME", "")
|
||||
password = config.get("IMAP_PASSWORD", "")
|
||||
folder = config.get("IMAP_FOLDER", "Sent")
|
||||
use_ssl = config.get("IMAP_USE_SSL", True)
|
||||
|
||||
if not server or not username or not password:
|
||||
raise ValueError("Для сохранения копии в IMAP должны быть указаны сервер, логин и пароль")
|
||||
|
||||
msg = _build_mime_message(to_address, subject, html_body, sender_name, attachments)
|
||||
msg_bytes = msg.as_bytes()
|
||||
date_str = imaplib.Time2Internaldate(time.time())
|
||||
|
||||
try:
|
||||
if use_ssl:
|
||||
mail = imaplib.IMAP4_SSL(server, port)
|
||||
else:
|
||||
mail = imaplib.IMAP4(server, port)
|
||||
try:
|
||||
mail.starttls()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
mail.login(username, password)
|
||||
mail.select(folder)
|
||||
result, data = mail.append(folder, "", date_str, msg_bytes)
|
||||
if result != "OK":
|
||||
raise RuntimeError(f"IMAP append failed: {data}")
|
||||
mail.close()
|
||||
mail.logout()
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"Ошибка сохранения копии в IMAP: {e}") from e
|
||||
|
||||
|
||||
def _send_email(to_address, subject, html_body, sender_name="InviteMe", attachments=None):
|
||||
"""Маршрутизирует отправку через SMTP, EWS или локальный Outlook."""
|
||||
attachments = attachments or []
|
||||
if current_app.config.get("MAIL_USE_LOCAL_OUTLOOK"):
|
||||
_send_outlook_email(to_address, subject, html_body, sender_name, attachments)
|
||||
elif current_app.config.get("MAIL_USE_EWS"):
|
||||
_send_ews_email(to_address, subject, html_body, sender_name, attachments)
|
||||
else:
|
||||
_send_smtp_email(to_address, subject, html_body, sender_name, attachments)
|
||||
|
||||
# Сохраняем копию в IMAP, если включено
|
||||
if current_app.config.get("IMAP_SAVE_COPY"):
|
||||
_save_imap_copy(to_address, subject, html_body, sender_name, attachments)
|
||||
|
||||
|
||||
def send_invitation(guest, event_name=None, additional_text=""):
|
||||
"""Отправляет приглашение гостю на email с QR-кодом и билетом во вложении."""
|
||||
from app.models import utcnow
|
||||
|
||||
event = guest.event
|
||||
guest.invitation_status = "sending"
|
||||
guest.invitation_attempts = (guest.invitation_attempts or 0) + 1
|
||||
guest.invitation_last_attempt_at = utcnow()
|
||||
guest.invitation_last_error = None
|
||||
|
||||
if event_name is None:
|
||||
event_name = event.name if event else "Мероприятие"
|
||||
|
||||
# Генерируем билет
|
||||
try:
|
||||
ticket_path = generate_ticket_image(guest, guest.event)
|
||||
except Exception as e:
|
||||
ticket_path = None
|
||||
current_app.logger.warning(f"Не удалось сгенерировать билет для {guest.email}: {e}")
|
||||
|
||||
base_url = (current_app.config.get("BASE_URL") or "").rstrip("/")
|
||||
if base_url:
|
||||
invitation_url = f"{base_url}/invite/{guest.token}"
|
||||
elif has_request_context():
|
||||
invitation_url = url_for("main.public_invitation", token=guest.token, _external=True)
|
||||
else:
|
||||
invitation_url = f"http://localhost:5000/invite/{guest.token}"
|
||||
|
||||
html_body = render_template(
|
||||
"email/invitation.html",
|
||||
guest=guest,
|
||||
event_name=event_name,
|
||||
additional_text=additional_text,
|
||||
invitation_message=event.invitation_message if event else None,
|
||||
invitation_url=invitation_url,
|
||||
qr_cid="qr_code_image",
|
||||
ticket_cid="ticket_image" if ticket_path else None,
|
||||
)
|
||||
|
||||
attachments = []
|
||||
|
||||
# QR-код
|
||||
qr_path = safe_child_path(event_qr_folder(guest.event_id), guest.qr_filename)
|
||||
if qr_path and qr_path.exists():
|
||||
attachments.append({"path": qr_path, "cid": "qr_code_image", "filename": guest.qr_filename})
|
||||
|
||||
# Билет
|
||||
if ticket_path and ticket_path.exists():
|
||||
attachments.append({
|
||||
"path": ticket_path,
|
||||
"cid": "ticket_image",
|
||||
"filename": f"ticket_{guest.token}.png",
|
||||
})
|
||||
|
||||
try:
|
||||
_send_email(
|
||||
to_address=guest.email,
|
||||
subject=(event.invitation_subject if event and event.invitation_subject else f"Приглашение на мероприятие: {event_name}"),
|
||||
html_body=html_body,
|
||||
attachments=attachments,
|
||||
)
|
||||
except Exception as exc:
|
||||
guest.invitation_status = "failed"
|
||||
guest.invitation_last_error = str(exc)[:1000]
|
||||
raise
|
||||
|
||||
guest.invitation_sent = True
|
||||
guest.invitation_status = "sent"
|
||||
guest.invitation_sent_at = utcnow()
|
||||
guest.invitation_last_error = None
|
||||
return True
|
||||
|
||||
|
||||
def send_test_email(to_address):
|
||||
"""Отправляет тестовое письмо для проверки настроек."""
|
||||
html_body = render_template("email/test_email.html")
|
||||
_send_email(
|
||||
to_address=to_address,
|
||||
subject="Тестовое письмо InviteMe",
|
||||
html_body=html_body,
|
||||
)
|
||||
return True
|
||||
@@ -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
|
||||
@@ -0,0 +1,43 @@
|
||||
import qrcode
|
||||
from pathlib import Path
|
||||
from flask import current_app, url_for
|
||||
|
||||
from app.utils.paths import event_qr_folder
|
||||
|
||||
|
||||
def generate_qr_for_guest(guest, base_url=None):
|
||||
"""Генерирует QR-код для гостя и сохраняет его в папку мероприятия."""
|
||||
token = guest.token
|
||||
qr_folder = event_qr_folder(guest.event_id)
|
||||
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:
|
||||
configured_base_url = (current_app.config.get("BASE_URL") or "").rstrip("/")
|
||||
if configured_base_url:
|
||||
check_in_url = f"{configured_base_url}/check-in?token={token}"
|
||||
else:
|
||||
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,348 @@
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
from flask import current_app
|
||||
|
||||
from app.services.qr_service import generate_qr_for_guest
|
||||
from app.utils.timezone import utc_to_local
|
||||
from app.utils.paths import event_logo_folder, event_tickets_folder, event_photo_folder, event_qr_folder, safe_child_path
|
||||
|
||||
|
||||
TICKET_SIZE = (650, 1150)
|
||||
|
||||
NON_TRANSFERABLE_TEXT = (
|
||||
"Билет действителен только для указанного гостя. "
|
||||
"Передача третьим лицам запрещена."
|
||||
)
|
||||
|
||||
|
||||
def _load_logo_image(logo_path):
|
||||
"""Load a previously validated raster logo."""
|
||||
return Image.open(str(logo_path)).convert("RGBA")
|
||||
|
||||
|
||||
def _draw_logo_centered(img, event, y, max_width):
|
||||
"""Рисует логотип по центру, возвращает новую y-координату."""
|
||||
if not event.logo_filename:
|
||||
return y
|
||||
|
||||
logo_path = safe_child_path(event_logo_folder(event.id), event.logo_filename)
|
||||
if not logo_path or not logo_path.exists():
|
||||
current_app.logger.warning(f"Логотип не найден: {logo_path}")
|
||||
return y
|
||||
|
||||
try:
|
||||
logo = _load_logo_image(logo_path)
|
||||
if logo is None:
|
||||
return y
|
||||
|
||||
# Масштабируем по ширине
|
||||
if logo.width <= 0 or logo.height <= 0:
|
||||
current_app.logger.warning(f"Логотип имеет некорректные размеры: {logo.width}x{logo.height}")
|
||||
return y
|
||||
|
||||
ratio = logo.width / logo.height
|
||||
new_width = min(max_width, logo.width)
|
||||
new_height = int(new_width / ratio)
|
||||
logo = logo.resize((new_width, new_height), Image.Resampling.LANCZOS)
|
||||
|
||||
x = (TICKET_SIZE[0] - new_width) // 2
|
||||
img.paste(logo, (x, y), logo)
|
||||
return y + new_height + 25
|
||||
except Exception as e:
|
||||
current_app.logger.warning(f"Не удалось отрисовать логотип {event.logo_filename}: {e}")
|
||||
return y
|
||||
|
||||
|
||||
def hex_to_rgb(hex_color):
|
||||
"""Конвертирует HEX-цвет в кортеж RGB."""
|
||||
hex_color = hex_color.lstrip("#")
|
||||
if len(hex_color) != 6:
|
||||
return (255, 255, 255)
|
||||
return tuple(int(hex_color[i:i + 2], 16) for i in (0, 2, 4))
|
||||
|
||||
|
||||
def _adjust_color(color, factor=0.85):
|
||||
"""Затемняет или осветляет RGB-цвет."""
|
||||
rgb = color if isinstance(color, tuple) else hex_to_rgb(color)
|
||||
return tuple(min(255, max(0, int(c * factor))) for c in rgb)
|
||||
|
||||
|
||||
def _get_font(size, style="regular"):
|
||||
"""Пытается загрузить системный шрифт."""
|
||||
candidates = {
|
||||
"regular": ["calibri.ttf", "segoeui.ttf", "arial.ttf"],
|
||||
"bold": ["calibrib.ttf", "segoeuib.ttf", "arialbd.ttf"],
|
||||
"serif": ["times.ttf", "georgia.ttf", "cambria.ttf", "calibri.ttf"],
|
||||
"serif_bold": ["timesbd.ttf", "georgiab.ttf", "cambriab.ttf", "calibrib.ttf"],
|
||||
}
|
||||
|
||||
for font_name in candidates.get(style, candidates["regular"]):
|
||||
font_path = Path("C:/Windows/Fonts") / font_name
|
||||
if font_path.exists():
|
||||
try:
|
||||
return ImageFont.truetype(str(font_path), size)
|
||||
except Exception:
|
||||
pass
|
||||
return ImageFont.load_default()
|
||||
|
||||
|
||||
def _draw_text_centered(draw, text, y, width, font, fill, max_width=None):
|
||||
"""Рисует текст по центру с переносом по словам."""
|
||||
if not text:
|
||||
return y
|
||||
|
||||
max_width = max_width or width - 80
|
||||
words = text.split()
|
||||
lines = []
|
||||
current_line = ""
|
||||
|
||||
for word in words:
|
||||
test_line = f"{current_line} {word}".strip()
|
||||
bbox = draw.textbbox((0, 0), test_line, font=font)
|
||||
if bbox[2] - bbox[0] <= max_width:
|
||||
current_line = test_line
|
||||
else:
|
||||
if current_line:
|
||||
lines.append(current_line)
|
||||
current_line = word
|
||||
if current_line:
|
||||
lines.append(current_line)
|
||||
|
||||
line_height = font.size + 10
|
||||
for line in lines:
|
||||
bbox = draw.textbbox((0, 0), line, font=font)
|
||||
text_width = bbox[2] - bbox[0]
|
||||
x = (width - text_width) // 2
|
||||
draw.text((x, y), line, font=font, fill=fill)
|
||||
y += line_height
|
||||
|
||||
return y
|
||||
|
||||
|
||||
def _draw_text_left(draw, text, x, y, font, fill, max_width):
|
||||
"""Рисует текст слева с переносом по словам."""
|
||||
if not text:
|
||||
return y
|
||||
|
||||
words = text.split()
|
||||
lines = []
|
||||
current_line = ""
|
||||
|
||||
for word in words:
|
||||
test_line = f"{current_line} {word}".strip()
|
||||
bbox = draw.textbbox((0, 0), test_line, font=font)
|
||||
if bbox[2] - bbox[0] <= max_width:
|
||||
current_line = test_line
|
||||
else:
|
||||
if current_line:
|
||||
lines.append(current_line)
|
||||
current_line = word
|
||||
if current_line:
|
||||
lines.append(current_line)
|
||||
|
||||
line_height = font.size + 6
|
||||
for line in lines:
|
||||
draw.text((x, y), line, font=font, fill=fill)
|
||||
y += line_height
|
||||
|
||||
return y
|
||||
|
||||
|
||||
def _draw_info_row(draw, label, value, x, y, width, label_font, value_font, fill):
|
||||
"""Рисует строку с подписью и значением по центру."""
|
||||
if not value:
|
||||
return y
|
||||
|
||||
# Подпись по центру
|
||||
bbox = draw.textbbox((0, 0), label, font=label_font)
|
||||
label_width = bbox[2] - bbox[0]
|
||||
draw.text((x + (width - label_width) // 2, y), label, font=label_font, fill=fill)
|
||||
y += label_font.size + 4
|
||||
|
||||
# Значение по центру
|
||||
words = value.split()
|
||||
lines = []
|
||||
current_line = ""
|
||||
for word in words:
|
||||
test_line = f"{current_line} {word}".strip()
|
||||
bbox = draw.textbbox((0, 0), test_line, font=value_font)
|
||||
if bbox[2] - bbox[0] <= width:
|
||||
current_line = test_line
|
||||
else:
|
||||
if current_line:
|
||||
lines.append(current_line)
|
||||
current_line = word
|
||||
if current_line:
|
||||
lines.append(current_line)
|
||||
|
||||
line_height = value_font.size + 6
|
||||
for line in lines:
|
||||
bbox = draw.textbbox((0, 0), line, font=value_font)
|
||||
text_width = bbox[2] - bbox[0]
|
||||
draw.text((x + (width - text_width) // 2, y), line, font=value_font, fill=fill)
|
||||
y += line_height
|
||||
|
||||
return y + 16
|
||||
|
||||
|
||||
def _draw_ornament(draw, y, width, color):
|
||||
"""Рисует декоративную горизонтальную линию с точками по краям."""
|
||||
margin = 100
|
||||
draw.line([(margin, y), (width - margin, y)], fill=color, width=1)
|
||||
r = 4
|
||||
draw.ellipse([margin - r, y - r, margin + r, y + r], fill=color)
|
||||
draw.ellipse([width - margin - r, y - r, width - margin + r, y + r], fill=color)
|
||||
|
||||
|
||||
def generate_ticket_image(guest, event, output_path=None):
|
||||
"""Генерирует элегантный PNG-пригласительный билет."""
|
||||
if not guest.qr_filename:
|
||||
generate_qr_for_guest(guest)
|
||||
|
||||
width, height = TICKET_SIZE
|
||||
bg_color = hex_to_rgb(event.ticket_background_color or "#ffffff")
|
||||
text_color = hex_to_rgb(event.ticket_text_color or "#1a1a1a")
|
||||
accent_color = hex_to_rgb(event.ticket_accent_color or "#0d6efd")
|
||||
|
||||
img = Image.new("RGB", (width, height), bg_color)
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
# Мягкий фоновый градиент/рамка
|
||||
for i in range(20):
|
||||
alpha = int(255 * (1 - i / 40))
|
||||
c = tuple(min(255, int(bg_color[j] + (accent_color[j] - bg_color[j]) * (i / 60))) for j in range(3))
|
||||
draw.rectangle([i, i, width - 1 - i, height - 1 - i], outline=c, width=1)
|
||||
|
||||
# Декоративная внешняя рамка
|
||||
draw.rectangle([22, 22, width - 22, height - 22], outline=accent_color, width=2)
|
||||
draw.rectangle([28, 28, width - 28, height - 28], outline=_adjust_color(accent_color, 1.3), width=1)
|
||||
|
||||
# Шрифты
|
||||
title_font = _get_font(32, "serif_bold")
|
||||
subtitle_font = _get_font(20, "serif")
|
||||
name_font = _get_font(28, "bold")
|
||||
body_font = _get_font(17, "regular")
|
||||
label_font = _get_font(13, "regular")
|
||||
small_font = _get_font(12, "regular")
|
||||
|
||||
y = 50
|
||||
|
||||
# Логотип по центру сверху
|
||||
y = _draw_logo_centered(img, event, y, max_width=300)
|
||||
|
||||
# Название мероприятия
|
||||
title = event.ticket_title or event.name
|
||||
y = _draw_text_centered(draw, title, y, width, title_font, text_color, max_width=width - 80)
|
||||
|
||||
# Подзаголовок
|
||||
if event.ticket_subtitle:
|
||||
y += 8
|
||||
y = _draw_text_centered(draw, event.ticket_subtitle, y, width, subtitle_font, text_color, max_width=width - 80)
|
||||
|
||||
y += 25
|
||||
_draw_ornament(draw, y, width, accent_color)
|
||||
y += 30
|
||||
|
||||
# Приглашается
|
||||
y = _draw_text_centered(draw, "ПРИГЛАШАЕТСЯ", y, width, label_font, text_color)
|
||||
y += 12
|
||||
y = _draw_text_centered(draw, guest.build_full_name(), y, width, name_font, text_color, max_width=width - 80)
|
||||
y += 30
|
||||
|
||||
# Данные гостя
|
||||
content_width = width - 120
|
||||
x_content = 60
|
||||
|
||||
if guest.department:
|
||||
y = _draw_info_row(draw, "Отдел", guest.department, x_content, y, content_width, label_font, body_font, text_color)
|
||||
if guest.position:
|
||||
y = _draw_info_row(draw, "Должность", guest.position, x_content, y, content_width, label_font, body_font, text_color)
|
||||
if guest.email:
|
||||
y = _draw_info_row(draw, "Email", guest.email, x_content, y, content_width, label_font, body_font, text_color)
|
||||
if guest.phone:
|
||||
y = _draw_info_row(draw, "Телефон", guest.phone, x_content, y, content_width, label_font, body_font, text_color)
|
||||
|
||||
# Инфо о мероприятии
|
||||
if event.event_date or event.location:
|
||||
y += 10
|
||||
_draw_ornament(draw, y, width, accent_color)
|
||||
y += 25
|
||||
if event.event_date:
|
||||
local_event_date = utc_to_local(event.event_date)
|
||||
date_value = local_event_date.strftime("%d.%m.%Y")
|
||||
time_value = local_event_date.strftime("%H:%M")
|
||||
y = _draw_info_row(draw, "Дата", date_value, x_content, y, content_width, label_font, body_font, text_color)
|
||||
y = _draw_info_row(draw, "Время", time_value, x_content, y, content_width, label_font, body_font, text_color)
|
||||
if event.location:
|
||||
y = _draw_info_row(draw, "Место проведения", event.location, x_content, y, content_width, label_font, body_font, text_color)
|
||||
|
||||
# QR-код
|
||||
if event.ticket_show_qr:
|
||||
y += 15
|
||||
qr_path = safe_child_path(event_qr_folder(guest.event_id), guest.qr_filename)
|
||||
if qr_path and qr_path.exists():
|
||||
qr_size = 200
|
||||
qr_img = Image.open(str(qr_path)).convert("RGB")
|
||||
qr_img = qr_img.resize((qr_size, qr_size))
|
||||
x_qr = (width - qr_size) // 2
|
||||
img.paste(qr_img, (x_qr, y))
|
||||
y += qr_size + 12
|
||||
y = _draw_text_centered(draw, "QR-код для входа", y, width, small_font, text_color)
|
||||
|
||||
# Фото гостя
|
||||
if event.ticket_show_photo and guest.photo_filename:
|
||||
photo_path = safe_child_path(event_photo_folder(guest.event_id), guest.photo_filename)
|
||||
if photo_path and photo_path.exists():
|
||||
photo_img = Image.open(str(photo_path)).convert("RGB")
|
||||
photo_img.thumbnail((130, 130))
|
||||
x_photo = (width - photo_img.width) // 2
|
||||
img.paste(photo_img, (x_photo, y))
|
||||
y += photo_img.height + 20
|
||||
|
||||
# Дополнительный текст
|
||||
if event.ticket_body_text:
|
||||
y += 10
|
||||
y = _draw_text_centered(draw, event.ticket_body_text, y, width, small_font, text_color, max_width=width - 80)
|
||||
y += small_font.size + 10
|
||||
|
||||
# Номер билета
|
||||
ticket_id = f"№ {guest.token[:8].upper()}" if not guest.token.startswith("preview") else "№ PREVIEW"
|
||||
y = max(y, height - 105)
|
||||
_draw_text_centered(draw, ticket_id, y, width, small_font, text_color)
|
||||
|
||||
# Нижнее уведомление
|
||||
draw.rectangle([22, height - 65, width - 22, height - 22], fill=accent_color)
|
||||
_draw_text_centered(draw, NON_TRANSFERABLE_TEXT, height - 50, width, small_font, (255, 255, 255), max_width=width - 60)
|
||||
|
||||
# Сохранение
|
||||
if output_path is None:
|
||||
tickets_folder = event_tickets_folder(event.id)
|
||||
tickets_folder.mkdir(parents=True, exist_ok=True)
|
||||
output_path = tickets_folder / f"ticket_{event.id}_{guest.token}.png"
|
||||
|
||||
img.save(str(output_path), "PNG")
|
||||
return Path(output_path)
|
||||
|
||||
|
||||
def generate_preview_ticket(event, sample_guest=None):
|
||||
"""Генерирует билет для предпросмотра."""
|
||||
if sample_guest is None:
|
||||
from app.models import Guest
|
||||
sample_guest = Guest(
|
||||
event_id=event.id,
|
||||
last_name="Иванов",
|
||||
first_name="Иван",
|
||||
middle_name="Иванович",
|
||||
full_name="Иванов Иван Иванович",
|
||||
department="Отдел продаж",
|
||||
position="Менеджер",
|
||||
email="example@company.com",
|
||||
phone="+7 (999) 123-45-67",
|
||||
token="preview-token-12345",
|
||||
qr_filename=None,
|
||||
)
|
||||
from app.services.qr_service import generate_qr_for_guest
|
||||
generate_qr_for_guest(sample_guest, base_url="http://example.com")
|
||||
|
||||
return generate_ticket_image(sample_guest, event)
|
||||
Reference in New Issue
Block a user