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}")