phase 3 22 qr-code-renew

This commit is contained in:
2026-03-10 11:39:37 +00:00
parent 0e08e08fe1
commit 887a718a65
2 changed files with 192 additions and 43 deletions

View File

@@ -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()

View File

@@ -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"