From 874de0a46d461f07f12a9bd54f5cfeedd90902ee Mon Sep 17 00:00:00 2001 From: th-kim0823 Date: Mon, 27 Apr 2026 19:54:31 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20QR=20PNG=20=EC=83=9D=EC=84=B1=20+=20vot?= =?UTF-8?q?e=20URL=20resolver?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- app.py | 32 ++++++++++++++++++++++++++++++++ tests/e2e.py | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+) diff --git a/app.py b/app.py index 61dc913..ee659c8 100644 --- a/app.py +++ b/app.py @@ -11,8 +11,11 @@ import os import random as _rand import threading from datetime import datetime +import socket +from io import BytesIO from pathlib import Path +import qrcode import streamlit as st DATA_PATH = os.environ.get( @@ -200,6 +203,35 @@ def fmt_team(team, titles): return f"{team} — {t}" if t else team +def make_qr_png(url: str, box_size: int = 20) -> bytes: + img = qrcode.make(url, box_size=box_size, border=2) + buf = BytesIO() + img.save(buf, format="PNG") + return buf.getvalue() + + +def _detect_lan_ip() -> str: + """LAN IP 자동 감지. 실패 시 'localhost'.""" + try: + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + s.connect(("8.8.8.8", 80)) + ip = s.getsockname()[0] + s.close() + return ip + except Exception: + return "localhost" + + +def compute_vote_url() -> str: + data = load_data() + base = ( + data.get("settings", {}).get("public_base_url") + or os.environ.get("PUBLIC_BASE_URL") + or f"http://{_detect_lan_ip()}:8501" + ) + return f"{base.rstrip('/')}/?mode=vote" + + def compute_winners(): """우선순위 기반 1팀 1상 + 동률 처리.""" data = load_data() diff --git a/tests/e2e.py b/tests/e2e.py index da7af96..8655980 100644 --- a/tests/e2e.py +++ b/tests/e2e.py @@ -299,6 +299,38 @@ def t_topics_seeded_after_assign(): assert c["tone"] +def t_make_qr_png(): + from app import make_qr_png + png = make_qr_png("http://localhost:8501/?mode=vote") + # PNG signature 8 bytes + assert png[:8] == b"\x89PNG\r\n\x1a\n" + assert len(png) > 100 + + +def t_compute_vote_url_priority(): + import os as _os + from app import compute_vote_url, save_data, load_data, _empty_state + save_data(_empty_state()) + + # 1. settings.public_base_url 우선 + d = load_data() + d["settings"]["public_base_url"] = "http://example.com:9000" + save_data(d) + assert compute_vote_url() == "http://example.com:9000/?mode=vote" + + # 2. env fallback + d["settings"].pop("public_base_url") + save_data(d) + _os.environ["PUBLIC_BASE_URL"] = "http://env-host:7777" + assert compute_vote_url() == "http://env-host:7777/?mode=vote" + _os.environ.pop("PUBLIC_BASE_URL") + + # 3. localhost / LAN fallback + url = compute_vote_url() + assert url.endswith("/?mode=vote") + assert url.startswith("http://") + + if __name__ == "__main__": print(f"# E2E (data={TEST_DATA})\n") test("hackathon.json 로드 (34명, 7팀)", t_load) @@ -318,6 +350,8 @@ if __name__ == "__main__": test("stage 헬퍼", t_stage_helpers) test("topics 헬퍼", t_topics_helpers) test("topics 시드", t_topics_seeded_after_assign) + test("QR PNG 생성", t_make_qr_png) + test("vote URL 우선순위", t_compute_vote_url_priority) fails = sum(1 for r, _ in results if r == FAIL) print(f"\n# {len(results)} 중 통과 {len(results) - fails}, 실패 {fails}")