phase 3 22 qr-code-renew
This commit is contained in:
@@ -1,51 +1,185 @@
|
|||||||
|
"""
|
||||||
|
Генерирует горизонтальный PDF-билет (600×250 pt).
|
||||||
|
Поддержка кириллицы: ищет системный TTF-шрифт; при неудаче — транслитерация.
|
||||||
|
"""
|
||||||
import io
|
import io
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
|
||||||
import qrcode
|
import qrcode
|
||||||
from reportlab.lib.pagesizes import landscape, A5
|
|
||||||
from reportlab.pdfgen import canvas
|
|
||||||
from reportlab.lib import colors
|
|
||||||
from reportlab.lib.utils import ImageReader
|
from reportlab.lib.utils import ImageReader
|
||||||
|
from reportlab.pdfbase import pdfmetrics
|
||||||
|
from reportlab.pdfbase.ttfonts import TTFont
|
||||||
|
from reportlab.pdfgen import canvas
|
||||||
|
|
||||||
def generate_qr_ticket(ticket_id: int, user_id: int, seat_id: int = 0) -> bytes:
|
# ─── Cyrillic font detection ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_FONT_CANDIDATES = [
|
||||||
|
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
|
||||||
|
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
|
||||||
|
"/usr/share/fonts/truetype/ubuntu/Ubuntu-R.ttf",
|
||||||
|
"/usr/share/fonts/truetype/freefont/FreeSans.ttf",
|
||||||
|
"/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf",
|
||||||
|
]
|
||||||
|
|
||||||
|
_FONT_NAME = "Helvetica" # fallback
|
||||||
|
|
||||||
|
for _path in _FONT_CANDIDATES:
|
||||||
|
if os.path.exists(_path):
|
||||||
|
try:
|
||||||
|
pdfmetrics.registerFont(TTFont("CyrFont", _path))
|
||||||
|
_FONT_NAME = "CyrFont"
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
break
|
||||||
|
|
||||||
|
# ─── Transliteration fallback (used when no Cyrillic font is found) ──────────
|
||||||
|
|
||||||
|
_TRANSLIT_MAP: dict[str, str] = {
|
||||||
|
"а": "a", "б": "b", "в": "v", "г": "g", "д": "d",
|
||||||
|
"е": "e", "ё": "yo", "ж": "zh", "з": "z", "и": "i",
|
||||||
|
"й": "y", "к": "k", "л": "l", "м": "m", "н": "n",
|
||||||
|
"о": "o", "п": "p", "р": "r", "с": "s", "т": "t",
|
||||||
|
"у": "u", "ф": "f", "х": "kh", "ц": "ts", "ч": "ch",
|
||||||
|
"ш": "sh", "щ": "shch","ъ": "", "ы": "y", "ь": "",
|
||||||
|
"э": "e", "ю": "yu", "я": "ya",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _safe(text: str) -> str:
|
||||||
|
"""Pass text through unchanged if a Cyrillic font is registered; otherwise transliterate."""
|
||||||
|
if _FONT_NAME != "Helvetica":
|
||||||
|
return text
|
||||||
|
result: list[str] = []
|
||||||
|
for ch in text:
|
||||||
|
lower = ch.lower()
|
||||||
|
if lower in _TRANSLIT_MAP:
|
||||||
|
t = _TRANSLIT_MAP[lower]
|
||||||
|
result.append(t.capitalize() if ch.isupper() and t else t)
|
||||||
|
else:
|
||||||
|
result.append(ch)
|
||||||
|
return "".join(result)
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Color helpers ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _white(c: canvas.Canvas) -> None:
|
||||||
|
c.setFillColorRGB(1.0, 1.0, 1.0)
|
||||||
|
|
||||||
|
|
||||||
|
def _muted(c: canvas.Canvas) -> None:
|
||||||
|
c.setFillColorRGB(0.67, 0.67, 0.67) # #aaaaaa
|
||||||
|
|
||||||
|
|
||||||
|
def _accent(c: canvas.Canvas) -> None:
|
||||||
|
c.setFillColorRGB(0.89, 0.15, 0.21) # #e32636
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Main generator ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def generate_qr_ticket(
|
||||||
|
ticket_id: int,
|
||||||
|
title: str,
|
||||||
|
date_str: str,
|
||||||
|
sector: str,
|
||||||
|
row: int,
|
||||||
|
number: int,
|
||||||
|
price: int,
|
||||||
|
) -> bytes:
|
||||||
|
"""
|
||||||
|
Renders a landscape ticket (600×250 pt) and returns PDF bytes.
|
||||||
|
|
||||||
|
Left panel — event info + seat details (x 30‥410)
|
||||||
|
Right panel — QR code (x 420, y 50, 150×150)
|
||||||
|
"""
|
||||||
|
W, H = 600, 250
|
||||||
buffer = io.BytesIO()
|
buffer = io.BytesIO()
|
||||||
width, height = landscape(A5)
|
c = canvas.Canvas(buffer, pagesize=(W, H))
|
||||||
c = canvas.Canvas(buffer, pagesize=(width, height))
|
|
||||||
|
|
||||||
# Темный фон (под дизайн фронтенда)
|
# ── Background ──
|
||||||
c.setFillColor(colors.HexColor("#121212"))
|
c.setFillColorRGB(0.1, 0.1, 0.1)
|
||||||
c.rect(0, 0, width, height, fill=1, stroke=0)
|
c.rect(0, 0, W, H, fill=1, stroke=0)
|
||||||
|
|
||||||
# Белая карточка билета
|
# ── Accent top bar ──
|
||||||
margin = 20
|
c.setFillColorRGB(0.89, 0.15, 0.21)
|
||||||
c.setFillColor(colors.white)
|
c.rect(0, H - 6, W, 6, fill=1, stroke=0)
|
||||||
c.roundRect(margin, margin, width - 2*margin, height - 2*margin, 10, fill=1, stroke=0)
|
|
||||||
|
|
||||||
# Текст
|
# ── Ticket ID badge (top-right of left panel) ──
|
||||||
c.setFillColor(colors.black)
|
_muted(c)
|
||||||
c.setFont("Helvetica-Bold", 28)
|
c.setFont(_FONT_NAME, 9)
|
||||||
c.drawString(margin + 30, height - margin - 50, "EVENT TICKET")
|
c.drawRightString(400, H - 20, f"#{ticket_id}")
|
||||||
|
|
||||||
c.setFont("Helvetica", 14)
|
# ── Tournament title ──
|
||||||
c.drawString(margin + 30, height - margin - 100, f"Ticket ID: {ticket_id}")
|
_white(c)
|
||||||
c.drawString(margin + 30, height - margin - 130, f"User ID: {user_id}")
|
c.setFont(_FONT_NAME, 20)
|
||||||
if seat_id:
|
c.drawString(30, H - 50, _safe(title))
|
||||||
c.drawString(margin + 30, height - margin - 160, f"Seat ID: {seat_id}")
|
|
||||||
|
|
||||||
c.setFont("Helvetica-Oblique", 10)
|
# ── Date & venue ──
|
||||||
c.setFillColor(colors.gray)
|
_white(c)
|
||||||
c.drawString(margin + 30, margin + 30, "Scan this QR code at the entrance.")
|
c.setFont(_FONT_NAME, 12)
|
||||||
|
c.drawString(30, H - 72, _safe(date_str))
|
||||||
|
|
||||||
# QR-код
|
_muted(c)
|
||||||
qr = qrcode.QRCode(box_size=5, border=1)
|
c.setFont(_FONT_NAME, 12)
|
||||||
qr.add_data(f"TICKET:{ticket_id}|USER:{user_id}|SEAT:{seat_id}")
|
c.drawString(30, H - 90, _safe("Место: Main Arena, Москва"))
|
||||||
|
|
||||||
|
# ── Separator ──
|
||||||
|
c.setStrokeColorRGB(0.25, 0.25, 0.25)
|
||||||
|
c.setLineWidth(0.8)
|
||||||
|
c.line(30, H - 105, 400, H - 105)
|
||||||
|
|
||||||
|
# ── Seat details grid ──
|
||||||
|
labels = [
|
||||||
|
(_safe("Сектор"), str(sector)),
|
||||||
|
(_safe("Ряд"), str(row)),
|
||||||
|
(_safe("Место"), str(number)),
|
||||||
|
(_safe("Цена"), f"{price:,} RUB".replace(",", " ")),
|
||||||
|
]
|
||||||
|
|
||||||
|
col_w = 90
|
||||||
|
x_start = 30
|
||||||
|
y_label = H - 130
|
||||||
|
y_value = H - 148
|
||||||
|
|
||||||
|
for i, (label, value) in enumerate(labels):
|
||||||
|
x = x_start + i * col_w
|
||||||
|
|
||||||
|
_muted(c)
|
||||||
|
c.setFont(_FONT_NAME, 9)
|
||||||
|
c.drawString(x, y_label, label)
|
||||||
|
|
||||||
|
_white(c)
|
||||||
|
c.setFont(_FONT_NAME, 14)
|
||||||
|
c.drawString(x, y_value, value)
|
||||||
|
|
||||||
|
# ── Perforated divider line (left panel / QR panel) ──
|
||||||
|
c.setStrokeColorRGB(0.25, 0.25, 0.25)
|
||||||
|
c.setDash(4, 4)
|
||||||
|
c.setLineWidth(0.8)
|
||||||
|
c.line(415, 15, 415, H - 15)
|
||||||
|
c.setDash() # reset dash
|
||||||
|
|
||||||
|
# ── Scan hint ──
|
||||||
|
_muted(c)
|
||||||
|
c.setFont(_FONT_NAME, 8)
|
||||||
|
c.drawCentredString(495, 30, _safe("Сканировать при входе"))
|
||||||
|
|
||||||
|
# ── QR code ──
|
||||||
|
qr_data = json.dumps(
|
||||||
|
{"id": ticket_id, "t": title, "s": sector, "r": row, "m": number},
|
||||||
|
ensure_ascii=False,
|
||||||
|
separators=(",", ":"),
|
||||||
|
)
|
||||||
|
qr = qrcode.QRCode(box_size=5, border=1, error_correction=qrcode.constants.ERROR_CORRECT_M)
|
||||||
|
qr.add_data(qr_data)
|
||||||
qr.make(fit=True)
|
qr.make(fit=True)
|
||||||
img = qr.make_image(fill_color="black", back_color="white")
|
img = qr.make_image(fill_color="white", back_color="#1a1a1a")
|
||||||
|
|
||||||
# Буферизация картинки для ReportLab
|
img_buf = io.BytesIO()
|
||||||
img_buffer = io.BytesIO()
|
img.save(img_buf, format="PNG")
|
||||||
img.save(img_buffer, format="PNG")
|
img_buf.seek(0)
|
||||||
img_buffer.seek(0)
|
|
||||||
|
|
||||||
c.drawImage(ImageReader(img_buffer), width - margin - 160, margin + 30, width=130, height=130)
|
c.drawImage(ImageReader(img_buf), 420, 50, width=150, height=150)
|
||||||
|
|
||||||
c.showPage()
|
c.showPage()
|
||||||
c.save()
|
c.save()
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_asyn
|
|||||||
|
|
||||||
from core.minio import ensure_bucket_exists, upload_pdf
|
from core.minio import ensure_bucket_exists, upload_pdf
|
||||||
from core.pdf_generator import generate_qr_ticket
|
from core.pdf_generator import generate_qr_ticket
|
||||||
from database.models import Ticket
|
from database.models import Seat, Ticket, Tournament
|
||||||
|
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
level=logging.INFO,
|
level=logging.INFO,
|
||||||
@@ -40,22 +40,37 @@ async def _handle_ticket_paid(
|
|||||||
log.error("Event 'ticket_paid' missing 'ticket_id': %s", payload)
|
log.error("Event 'ticket_paid' missing 'ticket_id': %s", payload)
|
||||||
return
|
return
|
||||||
|
|
||||||
result = await db_session.execute(select(Ticket).where(Ticket.id == ticket_id))
|
# JOIN: Ticket → Seat → Tournament (one query, no N+1)
|
||||||
ticket: Ticket | None = result.scalar_one_or_none()
|
stmt = (
|
||||||
|
select(Ticket, Seat, Tournament)
|
||||||
|
.join(Seat, Ticket.seat_id == Seat.id)
|
||||||
|
.join(Tournament, Seat.tournament_id == Tournament.id)
|
||||||
|
.where(Ticket.id == ticket_id)
|
||||||
|
)
|
||||||
|
row = (await db_session.execute(stmt)).first()
|
||||||
|
|
||||||
if ticket is None:
|
if row is None:
|
||||||
log.error("Ticket %s not found in DB", ticket_id)
|
log.error("Ticket %s (or related Seat/Tournament) not found in DB", ticket_id)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
ticket, seat, tournament = row
|
||||||
|
|
||||||
if ticket.pdf_url:
|
if ticket.pdf_url:
|
||||||
log.info("Ticket %s already has a PDF, skipping (idempotency guard)", ticket_id)
|
log.info("Ticket %s already has a PDF, skipping (idempotency guard)", ticket_id)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# Format event date: "DD.MM.YYYY HH:MM"
|
||||||
|
date_str = tournament.event_date.strftime("%d.%m.%Y %H:%M")
|
||||||
|
|
||||||
log.info("Generating PDF for ticket %s …", ticket_id)
|
log.info("Generating PDF for ticket %s …", ticket_id)
|
||||||
pdf_bytes = generate_qr_ticket(
|
pdf_bytes = generate_qr_ticket(
|
||||||
ticket_id=ticket.id,
|
ticket_id=ticket.id,
|
||||||
user_id=ticket.user_id or 0,
|
title=tournament.title,
|
||||||
seat_id=ticket.seat_id,
|
date_str=date_str,
|
||||||
|
sector=seat.sector,
|
||||||
|
row=seat.row,
|
||||||
|
number=seat.number,
|
||||||
|
price=seat.price,
|
||||||
)
|
)
|
||||||
|
|
||||||
object_name = f"tickets/ticket_{ticket_id}.pdf"
|
object_name = f"tickets/ticket_{ticket_id}.pdf"
|
||||||
|
|||||||
Reference in New Issue
Block a user