From 887a718a650a5cb563e974e786c0642b73324918 Mon Sep 17 00:00:00 2001 From: openit Date: Tue, 10 Mar 2026 11:39:37 +0000 Subject: [PATCH] phase 3 22 qr-code-renew --- backend/core/pdf_generator.py | 206 ++++++++++++++++++++++++++++------ backend/worker.py | 29 +++-- 2 files changed, 192 insertions(+), 43 deletions(-) diff --git a/backend/core/pdf_generator.py b/backend/core/pdf_generator.py index 3a6afa6..d74f2ca 100644 --- a/backend/core/pdf_generator.py +++ b/backend/core/pdf_generator.py @@ -1,51 +1,185 @@ +""" +Генерирует горизонтальный PDF-билет (600×250 pt). +Поддержка кириллицы: ищет системный TTF-шрифт; при неудаче — транслитерация. +""" import io +import json +import os + 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.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() - width, height = landscape(A5) - c = canvas.Canvas(buffer, pagesize=(width, height)) + c = canvas.Canvas(buffer, pagesize=(W, H)) - # Темный фон (под дизайн фронтенда) - c.setFillColor(colors.HexColor("#121212")) - c.rect(0, 0, width, height, fill=1, stroke=0) + # ── Background ── + c.setFillColorRGB(0.1, 0.1, 0.1) + c.rect(0, 0, W, H, fill=1, stroke=0) - # Белая карточка билета - margin = 20 - c.setFillColor(colors.white) - c.roundRect(margin, margin, width - 2*margin, height - 2*margin, 10, fill=1, stroke=0) + # ── Accent top bar ── + c.setFillColorRGB(0.89, 0.15, 0.21) + c.rect(0, H - 6, W, 6, fill=1, stroke=0) - # Текст - c.setFillColor(colors.black) - c.setFont("Helvetica-Bold", 28) - c.drawString(margin + 30, height - margin - 50, "EVENT TICKET") + # ── Ticket ID badge (top-right of left panel) ── + _muted(c) + c.setFont(_FONT_NAME, 9) + c.drawRightString(400, H - 20, f"#{ticket_id}") - c.setFont("Helvetica", 14) - c.drawString(margin + 30, height - margin - 100, f"Ticket ID: {ticket_id}") - c.drawString(margin + 30, height - margin - 130, f"User ID: {user_id}") - if seat_id: - c.drawString(margin + 30, height - margin - 160, f"Seat ID: {seat_id}") + # ── Tournament title ── + _white(c) + c.setFont(_FONT_NAME, 20) + c.drawString(30, H - 50, _safe(title)) - c.setFont("Helvetica-Oblique", 10) - c.setFillColor(colors.gray) - c.drawString(margin + 30, margin + 30, "Scan this QR code at the entrance.") + # ── Date & venue ── + _white(c) + c.setFont(_FONT_NAME, 12) + c.drawString(30, H - 72, _safe(date_str)) - # QR-код - qr = qrcode.QRCode(box_size=5, border=1) - qr.add_data(f"TICKET:{ticket_id}|USER:{user_id}|SEAT:{seat_id}") + _muted(c) + c.setFont(_FONT_NAME, 12) + 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) - img = qr.make_image(fill_color="black", back_color="white") - - # Буферизация картинки для ReportLab - img_buffer = io.BytesIO() - img.save(img_buffer, format="PNG") - img_buffer.seek(0) - - c.drawImage(ImageReader(img_buffer), width - margin - 160, margin + 30, width=130, height=130) + img = qr.make_image(fill_color="white", back_color="#1a1a1a") + + img_buf = io.BytesIO() + img.save(img_buf, format="PNG") + img_buf.seek(0) + + c.drawImage(ImageReader(img_buf), 420, 50, width=150, height=150) c.showPage() c.save() diff --git a/backend/worker.py b/backend/worker.py index 60b49ba..b8846a0 100644 --- a/backend/worker.py +++ b/backend/worker.py @@ -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.pdf_generator import generate_qr_ticket -from database.models import Ticket +from database.models import Seat, Ticket, Tournament logging.basicConfig( level=logging.INFO, @@ -40,22 +40,37 @@ async def _handle_ticket_paid( log.error("Event 'ticket_paid' missing 'ticket_id': %s", payload) return - result = await db_session.execute(select(Ticket).where(Ticket.id == ticket_id)) - ticket: Ticket | None = result.scalar_one_or_none() + # JOIN: Ticket → Seat → Tournament (one query, no N+1) + 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: - log.error("Ticket %s not found in DB", ticket_id) + if row is None: + log.error("Ticket %s (or related Seat/Tournament) not found in DB", ticket_id) return + ticket, seat, tournament = row + if ticket.pdf_url: log.info("Ticket %s already has a PDF, skipping (idempotency guard)", ticket_id) 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) pdf_bytes = generate_qr_ticket( ticket_id=ticket.id, - user_id=ticket.user_id or 0, - seat_id=ticket.seat_id, + title=tournament.title, + date_str=date_str, + sector=seat.sector, + row=seat.row, + number=seat.number, + price=seat.price, ) object_name = f"tickets/ticket_{ticket_id}.pdf"