Auto-commit before push to git.mycloud24.ru

This commit is contained in:
InviteMe Developer
2026-08-28 15:41:39 +03:00
parent 3bd62cc289
commit a69abb948f
26 changed files with 1908 additions and 171 deletions
+23 -2
View File
@@ -6,16 +6,28 @@ from email.utils import formataddr
from pathlib import Path
from flask import current_app, render_template
from app.services.ticket_service import generate_ticket_image
def send_invitation(guest, event_name="Корпоратив", additional_text=""):
"""Отправляет приглашение гостю на email с QR-кодом во вложении."""
def send_invitation(guest, event_name=None, additional_text=""):
"""Отправляет приглашение гостю на email с QR-кодом и билетом во вложении."""
config = current_app.config
if event_name is None:
event_name = guest.event.name if guest.event else "Мероприятие"
msg = MIMEMultipart("related")
msg["Subject"] = f"Приглашение на мероприятие: {event_name}"
msg["From"] = formataddr(("InviteMe", config.get("MAIL_DEFAULT_SENDER", config["MAIL_USERNAME"])))
msg["To"] = guest.email
# Генерируем билет
try:
ticket_path = generate_ticket_image(guest, guest.event)
except Exception as e:
ticket_path = None
current_app.logger.warning(f"Не удалось сгенерировать билет для {guest.email}: {e}")
# HTML-шаблон письма
html_body = render_template(
"email/invitation.html",
@@ -23,6 +35,7 @@ def send_invitation(guest, event_name="Корпоратив", additional_text=""
event_name=event_name,
additional_text=additional_text,
qr_cid="qr_code_image",
ticket_cid="ticket_image" if ticket_path else None,
)
msg.attach(MIMEText(html_body, "html", "utf-8"))
@@ -36,6 +49,14 @@ def send_invitation(guest, event_name="Корпоратив", additional_text=""
img.add_header("Content-Disposition", "inline", filename=guest.qr_filename)
msg.attach(img)
# Прикрепляем билет
if ticket_path and ticket_path.exists():
with open(ticket_path, "rb") as img_file:
img = MIMEImage(img_file.read())
img.add_header("Content-ID", "<ticket_image>")
img.add_header("Content-Disposition", "inline", filename=f"ticket_{guest.token}.png")
msg.attach(img)
# Отправка через SMTP
server = config["MAIL_SERVER"]
port = config["MAIL_PORT"]
+69 -20
View File
@@ -5,17 +5,27 @@ from app import db
from app.models import Guest
REQUIRED_COLUMNS = ["Отдел", "ФИО", "Должность", "e-mail", "название фото"]
REQUIRED_COLUMNS = ["Отдел", "Фамилия", "Имя", "Отчество", "Должность", "Почта", "Телефон", "Название фото"]
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 read_guests_from_excel(file_path):
"""Читает список гостей из Excel-файла."""
file_path = Path(file_path)
"""Читает список гостей из Excel-файла (путь или файловый объект)."""
if not hasattr(file_path, "read"):
file_path = Path(file_path)
wb = load_workbook(file_path, data_only=True)
ws = wb.active
try:
ws = wb.active
# Определяем заголовки
headers = [str(cell.value).strip() if cell.value else "" for cell in ws[1]]
# Определяем заголовки и сразу считываем все строки
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:
@@ -24,41 +34,70 @@ def read_guests_from_excel(file_path):
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 ""
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 full_name or not email:
if not email:
continue
full_name = _build_full_name(last_name, first_name, middle_name)
if not full_name:
# Если ФИО не заполнено, но есть email — пропускаем строку
continue
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,
"photo_filename": str(row[col_indices["название фото"]]).strip() if row[col_indices["название фото"]] else "",
"phone": str(row[col_indices["Телефон"]]).strip() if row[col_indices["Телефон"]] else "",
"photo_filename": str(row[col_indices["Название фото"]]).strip() if row[col_indices["Название фото"]] else "",
})
return guests_data
def import_guests_from_excel(file_path):
"""Импортирует гостей из Excel в базу данных."""
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(email=data["email"]).first()
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(**data)
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
@@ -66,15 +105,21 @@ def import_guests_from_excel(file_path):
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()
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", "Название фото", "Статус", "Время входа"]
headers = [
"Мероприятие", "Отдел", "Фамилия", "Имя", "Отчество",
"Должность", "Email", "Телефон", "Название фото", "Статус", "Время входа"
]
ws.append(headers)
# Стили заголовков
@@ -87,10 +132,14 @@ def export_report_to_excel(output_path, event_name="Корпоратив"):
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([
event_name or "",
guest.department,
guest.full_name,
guest.last_name or "",
guest.first_name or "",
guest.middle_name or "",
guest.position or "",
guest.email,
guest.phone or "",
guest.photo_filename or "",
status,
check_in_time,
+369
View File
@@ -0,0 +1,369 @@
import io
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
TICKET_SIZE = (650, 1150)
NON_TRANSFERABLE_TEXT = (
"Билет действителен только для указанного гостя. "
"Передача третьим лицам запрещена."
)
def _load_logo_image(logo_path):
"""Загружает логотип: PNG/JPG через Pillow, SVG через svglib + reportlab."""
if str(logo_path).lower().endswith(".svg"):
try:
from svglib.svglib import svg2rlg
from reportlab.graphics import renderPM
drawing = svg2rlg(str(logo_path))
if drawing is None:
raise ValueError("Не удалось распарсить SVG")
# svglib может оставить нулевые размеры; подстраховываемся
orig_width = drawing.width or 0
orig_height = drawing.height or 0
scale = max(orig_width, orig_height) / 1000
if scale > 0:
drawing.width /= scale
drawing.height /= scale
drawing.scale(1 / scale, 1 / scale)
png_data = renderPM.drawToString(drawing, fmt="PNG", dpi=150)
return Image.open(io.BytesIO(png_data)).convert("RGBA")
except Exception as e:
current_app.logger.warning(f"Не удалось конвертировать SVG {logo_path}: {e}")
return None
else:
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 = Path(current_app.config["LOGO_FOLDER"]) / event.logo_filename
if 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:
date_value = event.event_date.strftime("%d.%m.%Y")
time_value = event.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 = Path(current_app.config["QR_FOLDER"]) / guest.qr_filename
if 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 = Path(current_app.config["PHOTO_FOLDER"]) / guest.photo_filename
if 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 = Path(current_app.config["UPLOAD_FOLDER"]) / "tickets"
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(
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)