""" 해커톤 투표 앱 — DB 없이 단일 JSON. - 참가자: 이름 선택 → 본인 팀 자동 → 본인 팀 제외 3분야 투표 - 진행자: ?mode=admin&token=XXX 로 결과 확인 - 시상: ?mode=ceremony&token=XXX """ import csv import io import json 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 from streamlit_autorefresh import st_autorefresh DATA_PATH = os.environ.get( "DATA_PATH", str(Path(__file__).parent / "hackathon.json") ) ADMIN_TOKEN = os.environ.get("ADMIN_TOKEN", "change-me") CATEGORIES = [ ("fun_team", "🎉 재미상", "손선풍기 5개"), ("polish_team", "🏆 완성도상", "양우산 5개"), ("utility_team", "🛠 실용성상", "팜레스트 5개"), ] TITLE_PLACEHOLDERS = [ "예: Slack 멘션 자동 분류기", "예: kubectl 매크로 CLI", "예: PR 우선순위 큐", "예: 회의 캘린더 브리핑", "예: 신입 가이드 봇", "예: 배포 알림 봇", "예: 점심 투표 봇", ] PRIZE_PRIORITY = ["utility_team", "polish_team", "fun_team"] SHOW_CSS = """ """ _lock = threading.RLock() def _empty_state(): return { "people": [], "settings": {"voting_open": True, "current_stage": "intro"}, "titles": {}, "tie_breaks": {}, "votes": [], "topics": {"categories": []}, } def load_data(): """매 호출 디스크 read. 호스트 편집 즉시 반영.""" with _lock: p = Path(DATA_PATH) if not p.exists(): return _empty_state() try: data = json.loads(p.read_text(encoding="utf-8")) except json.JSONDecodeError: return _empty_state() # 누락 키 채움 base = _empty_state() base.update(data) for k, v in _empty_state().items(): base.setdefault(k, v) # 한 단계 deep merge — 기존 데이터에 누락된 nested 키 보강 for nested_key in ("settings", "topics"): for k, default_v in _empty_state()[nested_key].items(): base[nested_key].setdefault(k, default_v) return base def save_data(data): """원자적 write — tmp 파일 + rename.""" with _lock: tmp = DATA_PATH + ".tmp" Path(tmp).write_text( json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8" ) os.replace(tmp, DATA_PATH) def update_data(fn): """load → modify → save 원자적 묶음.""" with _lock: data = load_data() result = fn(data) save_data(data) return result def get_participants(): return {p["name"]: p["team"] for p in load_data().get("people", [])} def get_teams(): parts = get_participants() return sorted(set(parts.values())) if parts else [f"팀{i}" for i in range(1, 8)] def get_titles(): return load_data().get("titles", {}) def set_title(team, title): def _fn(data): data["titles"][team] = title.strip() update_data(_fn) def is_voting_open(): return load_data().get("settings", {}).get("voting_open", True) def set_voting_open(flag): def _fn(data): data["settings"]["voting_open"] = bool(flag) update_data(_fn) def set_public_base_url(url): def _fn(data): data.setdefault("settings", {}) if url: data["settings"]["public_base_url"] = url.strip() else: data["settings"].pop("public_base_url", None) update_data(_fn) VALID_STAGES = ("intro", "topics", "vote") def get_stage(): return load_data().get("settings", {}).get("current_stage", "intro") def set_stage(stage): if stage not in VALID_STAGES: raise ValueError(f"invalid stage: {stage!r}") def _fn(data): data["settings"]["current_stage"] = stage if stage == "vote": data["settings"]["voting_open"] = True update_data(_fn) def can_accept_votes(data): s = data.get("settings", {}) return s.get("current_stage") == "vote" and s.get("voting_open", False) def get_topics(): return load_data().get("topics", {}).get("categories", []) def update_topics(categories): def _fn(data): data.setdefault("topics", {}) data["topics"]["categories"] = categories update_data(_fn) def get_tie_breaks(): return load_data().get("tie_breaks", {}) def save_tie_break(category, winner, method): def _fn(data): data["tie_breaks"][category] = { "winner_team": winner, "method": method, "decided_at": datetime.now().isoformat(timespec="seconds"), } update_data(_fn) def clear_tie_break(category): def _fn(data): data["tie_breaks"].pop(category, None) update_data(_fn) def insert_vote(voter_name, employee_id, voter_team, picks): """UNIQUE on voter_name. 이미 있으면 ValueError.""" def _fn(data): existing = {v["voter_name"] for v in data["votes"]} if voter_name in existing: raise ValueError("DUPLICATE_VOTER") data["votes"].append( { "voter_name": voter_name, "employee_id": employee_id, "voter_team": voter_team, "fun_team": picks["fun_team"], "polish_team": picks["polish_team"], "utility_team": picks["utility_team"], "created_at": datetime.now().isoformat(timespec="seconds"), } ) update_data(_fn) def list_votes(): return load_data().get("votes", []) def clear_votes(): def _fn(data): data["votes"] = [] update_data(_fn) def fmt_team(team, titles): t = titles.get(team, "") 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() votes = data.get("votes", []) tie_decisions = data.get("tie_breaks", {}) rankings = {} for col, _, _ in CATEGORIES: counts = {} for v in votes: t = v.get(col) if t: counts[t] = counts.get(t, 0) + 1 rankings[col] = sorted(counts.items(), key=lambda x: (-x[1], x[0])) winners = {} excluded = set() for col in PRIZE_PRIORITY: rows = rankings[col] filtered = [(t, c) for t, c in rows if t not in excluded] if not filtered: winners[col] = {"status": "empty"} continue top_votes = filtered[0][1] tied = [t for t, c in filtered if c == top_votes] if len(tied) > 1: decision = tie_decisions.get(col) if decision and decision.get("winner_team") in tied: winner = decision["winner_team"] method = decision.get("method", "") else: winners[col] = { "status": "tie", "tied": tied, "votes": top_votes, } continue else: winner = filtered[0][0] method = "" runner_votes = next((c for t, c in filtered if t != winner), 0) winners[col] = { "status": "ok", "team": winner, "votes": top_votes, "diff": top_votes - runner_votes, "method": method, } excluded.add(winner) return winners, rankings def archive_results(): """결과 timestamped JSON. 모든 winners ok일 때만.""" winners, _ = compute_winners() if any(w.get("status") != "ok" for w in winners.values()): return None titles = get_titles() data = { "timestamp": datetime.now().isoformat(timespec="seconds"), "results": [], } for col, label, prize in CATEGORIES: w = winners.get(col) if w and w.get("status") == "ok": data["results"].append( { "category": label, "prize": prize, "team": w["team"], "title": titles.get(w["team"], ""), "votes": w["votes"], "diff_2nd": w["diff"], "method": w.get("method") or "majority", } ) archive_dir = os.path.dirname(DATA_PATH) or "." ts = datetime.now().strftime("%Y%m%d_%H%M%S") path = os.path.join(archive_dir, f"results_{ts}.json") Path(path).write_text( json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8" ) return path # --- UI --- def render_show(): data = load_data() st.markdown(SHOW_CSS, unsafe_allow_html=True) stage = data.get("settings", {}).get("current_stage", "intro") if stage == "topics": render_stage_topics(data) elif stage == "vote": render_stage_vote(data) else: render_stage_intro(data) def render_stage_intro(data): st.markdown('
🚀 MLOps 해커톤 2026
', unsafe_allow_html=True) st.markdown('
팀 편성
', unsafe_allow_html=True) people = data.get("people", []) teams = {} for p in people: teams.setdefault(p["team"], []).append(p["name"]) team_names = sorted(teams.keys()) # 4×2 그리드 (7팀 + 1 빈 칸) rows = [team_names[i:i + 4] for i in range(0, len(team_names), 4)] for row in rows: cols = st.columns(4) for col, team in zip(cols, row): members = teams[team] members_html = "
".join(members) with col: st.markdown( f'
' f'
{team}
' f'
{members_html}
' f'
', unsafe_allow_html=True, ) st.markdown( '
' '📋 순서: 팀 편성 → 주제 소개 → 해킹 (2시간) → 발표 → 투표 → 시상' '
', unsafe_allow_html=True, ) st.markdown( '
' '🏆 시상 부문: 🎉 재미상 · 🏆 완성도상 · 🛠 실용성상 (1팀 1상)' '
', unsafe_allow_html=True, ) def render_stage_topics(data): st.markdown('
💡 예시 주제
', unsafe_allow_html=True) st.markdown('
영감 얻으세요 — 똑같이 안 만들어도 됩니다
', unsafe_allow_html=True) cats = data.get("topics", {}).get("categories", []) if not cats: st.warning("주제가 비어 있습니다. 어드민에서 입력하세요.") return # 2×2 그리드 rows = [cats[i:i + 2] for i in range(0, len(cats), 2)] for row in rows: cols = st.columns(2) for col, cat in zip(cols, row): cat_id = cat.get("id", "T?") items_html = "".join( f'
▸ {item}
' for item in cat.get("items", []) ) with col: st.markdown( f'
' f'
{cat_id}. {cat.get("title", "")}
' f'
{cat.get("tagline", "")}
' f'
톤: {cat.get("tone", "")}
' f' {items_html}' f'
', unsafe_allow_html=True, ) def render_stage_vote(data): st_autorefresh(interval=3000, key="vote_poll") st.markdown('
🗳 투표
', unsafe_allow_html=True) st.markdown( '
📱 휴대폰으로 QR 스캔 → 본인 이름 선택 → 투표
', unsafe_allow_html=True, ) votes = data.get("votes", []) total = len(data.get("people", [])) voted = len(votes) pct = voted / total if total else 0 st.markdown( f'
{voted} / {total}
', unsafe_allow_html=True, ) st.progress(pct) vote_url = compute_vote_url() qr_png = make_qr_png(vote_url) c1, c2, c3 = st.columns([1, 2, 1]) with c2: st.image(qr_png, use_container_width=False, width=500) st.markdown( f'
{vote_url}
', unsafe_allow_html=True, ) def render_voter(): if not can_accept_votes(load_data()): st.title("🗳 해커톤 투표") st.info("⏳ 지금은 투표 시간이 아닙니다. 진행자가 투표 stage로 전환할 때까지 기다려주세요.") return st.title("🗳 해커톤 투표") if not is_voting_open(): st.error("⏹️ 투표가 마감되었습니다. 시상식을 기다려주세요.") return with st.expander("📖 투표 방식 / 수상 결정 (꼭 읽어주세요)", expanded=True): st.markdown( """ **투표 방식** - 본인 이름 선택 + **본인 사번 입력** - 본인 팀 자동 매핑 - 본인 팀 **제외**, 다른 6팀 중 각 분야별 1팀씩 투표 (총 3표) - **한 번만 제출 가능** - 사번은 실수 시 잘못된 표 확인을 위해서만 기록됨 **수상 결정 — 1팀 1상 한정** - 우선순위: 🛠 **실용성상** > 🏆 **완성도상** > 🎉 **재미상** """ ) PARTS = get_participants() TEAMS = get_teams() if not PARTS: st.error("참가자 명단이 없습니다. `assign_teams.py` 먼저 실행하세요.") return voted_set = {v["voter_name"] for v in list_votes()} def fmt_name(n): return f"{n} ✅ (이미 투표함)" if n in voted_set else n name = st.selectbox( "본인 이름", options=sorted(PARTS.keys()), index=None, placeholder="이름 선택", format_func=fmt_name, ) employee_id = st.text_input( "사번", placeholder="본인 사번만 (전화번호/주민번호 입력 금지)", help="사번은 실수 시 잘못된 표 확인을 위해서만 기록됨. 본인 사번 외 입력하지 마세요.", ) if not name: st.info("이름을 선택하면 본인 팀이 자동 표시됩니다.") return if name in voted_set: st.error( f"❌ {name}님은 이미 투표하셨습니다. 한 번만 가능합니다. " "다른 사람으로 잘못 누른 경우 진행자에게 문의하세요." ) return if not employee_id.strip(): st.warning("사번 입력 후 진행하세요.") return my_team = PARTS[name] titles = get_titles() st.info(f"본인 팀: **{fmt_team(my_team, titles)}**") candidates = [t for t in TEAMS if t != my_team] with st.form("vote", clear_on_submit=False): st.divider() picks = {} for col, label, _ in CATEGORIES: picks[col] = st.radio( label, candidates, index=None, key=col, format_func=lambda t: fmt_team(t, titles), ) submitted = st.form_submit_button("제출") if submitted: if any(picks.get(col) is None for col, _, _ in CATEGORIES): st.error("3분야 모두 선택하세요.") return try: insert_vote(name, employee_id.strip(), my_team, picks) st.success(f"✅ {name}님 ({my_team}) 투표 완료. 창 닫아도 됩니다.") st.balloons() except ValueError: st.error(f"❌ '{name}' 이미 투표함. 진행자에게 문의하세요.") def render_admin(): token = st.query_params.get("token", "") if token != ADMIN_TOKEN: st.error("권한 없음. ?mode=admin&token=... 형식 필요.") return st.title("🔐 진행자 콘솔") with st.expander("🔗 다른 페이지 URL"): st.markdown( f""" - 🖥 **큰 화면 (발표자)**: [/](/) - 📱 **모바일 투표 (QR target)**: [/?mode=vote](?mode=vote) - 🎉 **시상식 (큰 화면)**: [/?mode=ceremony&token=...](?mode=ceremony&token={ADMIN_TOKEN}) - 📦 **JSON 원본 조회**: [/?mode=raw&token=...](?mode=raw&token={ADMIN_TOKEN}) 호스트에서 LAN IP 포함 모든 URL 보기: ```bash ./show-urls.sh ``` """ ) with st.expander("💾 데이터 백업 (hackathon.json 다운로드)"): try: raw_bytes = Path(DATA_PATH).read_bytes() ts = datetime.now().strftime("%Y%m%d_%H%M%S") st.download_button( "📥 hackathon.json 다운로드", raw_bytes, file_name=f"hackathon_{ts}.json", mime="application/json", ) st.caption(f"파일 경로: `{DATA_PATH}` ({len(raw_bytes):,} bytes)") except FileNotFoundError: st.warning(f"파일 없음: {DATA_PATH}") st.divider() st.subheader("🎬 Stage 진행") cur = get_stage() st.markdown(f"**현재 stage:** `{cur}`") stage_order = list(VALID_STAGES) # ("intro", "topics", "vote") idx = stage_order.index(cur) if cur in stage_order else 0 sc1, sc2 = st.columns(2) with sc1: if st.button( "← 이전 stage", disabled=(idx == 0), use_container_width=True, ): set_stage(stage_order[idx - 1]) st.rerun() with sc2: if st.button( "다음 stage →", disabled=(idx == len(stage_order) - 1), type="primary", use_container_width=True, ): set_stage(stage_order[idx + 1]) st.rerun() if cur == "vote": st.caption("ℹ️ vote stage 진입 시 투표가 자동 open 됨. 마감은 아래 '투표 마감' 버튼으로.") st.markdown("**📱 모바일 QR target URL**") cur_url = compute_vote_url() st.caption(f"현재: `{cur_url}`") cur_override = load_data().get("settings", {}).get("public_base_url", "") new_override = st.text_input( "Override (비워두면 자동 감지)", value=cur_override, placeholder="https://hackerthon.altair823.xyz", key="qr_override", ) if st.button("Override 저장"): set_public_base_url(new_override) st.success("저장됨.") st.rerun() st.divider() st.subheader("🗳 투표 상태") voting_open = is_voting_open() cur_label = "🟢 투표 진행 중" if voting_open else "🔴 투표 마감됨" st.markdown(f"**현재**: {cur_label}") cc1, cc2 = st.columns(2) with cc1: if voting_open and st.button("🛑 투표 마감 (시상 시작 전 필수)", type="primary"): set_voting_open(False) st.rerun() with cc2: if not voting_open and st.button("🔓 투표 재개"): set_voting_open(True) st.rerun() st.divider() st.subheader("🗒 주제 편집") cur_topics = get_topics() if not cur_topics: st.warning( "주제 비어있음. 어드민에서 직접 입력하거나, " "데이터 파일을 통째로 재시드하려면: " "`rm data/hackathon.json && docker compose restart vote`" ) else: edit_mode = st.radio( "편집 모드", ["Form", "JSON 직접 편집"], horizontal=True, key="topics_mode" ) if edit_mode == "Form": with st.form("topics_form"): new_cats = [] for cat in cur_topics: cid = cat.get("id", "T?") with st.expander(f"{cid}. {cat.get('title', '')}", expanded=False): title = st.text_input( "title", cat.get("title", ""), key=f"t_{cid}_title" ) tagline = st.text_input( "tagline", cat.get("tagline", ""), key=f"t_{cid}_tagline" ) tone = st.text_input( "tone", cat.get("tone", ""), key=f"t_{cid}_tone" ) items = [] existing_items = cat.get("items", []) # 10개 input 자리 (빈 자리 포함) padded = list(existing_items) + [""] * (10 - len(existing_items)) for i in range(10): items.append( st.text_input( f"주제 {i + 1}", padded[i] if i < len(padded) else "", key=f"t_{cid}_item_{i}", ) ) items = [x for x in items if x.strip()] new_cats.append( { "id": cid, "title": title.strip(), "tagline": tagline.strip(), "tone": tone.strip(), "items": items, } ) if st.form_submit_button("주제 저장"): update_topics(new_cats) st.success("저장됨. 큰 화면 다음 갱신 시 반영.") st.rerun() else: # JSON 직접 편집 current_json = json.dumps( {"categories": cur_topics}, ensure_ascii=False, indent=2 ) edited = st.text_area( "topics JSON", value=current_json, height=400, key="topics_json_editor", ) jc1, jc2 = st.columns(2) with jc1: if st.button("JSON 검증"): try: parsed = json.loads(edited) cats = parsed.get("categories", []) if not isinstance(cats, list): st.error("'categories'는 list 여야 합니다.") else: st.success(f"OK — {len(cats)}개 카테고리") except json.JSONDecodeError as e: st.error(f"JSON 파싱 실패: {e}") with jc2: if st.button("JSON 저장", type="primary"): try: parsed = json.loads(edited) cats = parsed.get("categories", []) if not isinstance(cats, list): st.error("'categories'는 list 여야 합니다.") else: update_topics(cats) st.success("저장됨.") st.rerun() except json.JSONDecodeError as e: st.error(f"저장 실패 — JSON 파싱 에러: {e}") st.divider() PARTS = get_participants() TEAMS = get_teams() votes = list_votes() total = len(votes) expected = len(PARTS) if PARTS else None col_a, col_b, col_c = st.columns(3) col_a.metric("투표 참여자", f"{total}명") col_b.metric("팀 수", f"{len(TEAMS)}팀") if expected: pct = int(100 * total / expected) col_c.metric("참여율", f"{pct}% ({total}/{expected})") if expected and total < expected: voted = {v["voter_name"] for v in votes} not_voted = [n for n in PARTS.keys() if n not in voted] with st.expander(f"⏳ 미투표자 ({len(not_voted)}명)"): for n in sorted(not_voted): st.write(f"- {n} ({PARTS[n]})") st.divider() st.subheader("📝 팀별 결과물 제목 입력") titles = get_titles() with st.form("titles_form"): new_titles = {} for i, team in enumerate(TEAMS): new_titles[team] = st.text_input( team, value=titles.get(team, ""), placeholder=TITLE_PLACEHOLDERS[i % len(TITLE_PLACEHOLDERS)], key=f"title_{team}", ) if st.form_submit_button("제목 저장"): for team, title in new_titles.items(): set_title(team, title) st.success("제목 저장 완료. 투표 페이지에 반영됨.") st.rerun() st.divider() st.subheader("📊 분야별 집계 (우선순위 적용 — 1팀 1상)") st.caption( "수상 결정 순서: 팜레스트(실용성) → 양우산(완성도) → 손선풍기(재미). " "이미 받은 팀은 후순위 상에서 제외." ) winners, rankings = compute_winners() awarded_teams = { w["team"] for w in winners.values() if w.get("status") == "ok" } public_lines = [] pending_ties = [ (col, label, prize) for col, label, prize in CATEGORIES if winners.get(col, {}).get("status") == "tie" ] if pending_ties: st.error( f"⚠️ 동률 미해결 {len(pending_ties)}건. 시상 전 추첨/선택 필요." ) for col, label, prize in CATEGORIES: rows = rankings[col] st.markdown(f"### {label} ({prize})") if not rows: st.caption("표 없음") continue result = winners.get(col, {}) status = result.get("status") if status == "empty": st.warning("후보 없음 (모두 우선순위 상 수상)") continue if status == "tie": tied = result["tied"] votes_n = result["votes"] tied_labels = ", ".join(fmt_team(t, titles) for t in tied) st.warning(f"🟰 동률 ({votes_n}표): {tied_labels}") ca, cb = st.columns([1, 2]) with ca: if st.button("🎲 즉석 추첨", key=f"draw_{col}"): chosen = _rand.choice(tied) save_tie_break(col, chosen, "random") st.rerun() with cb: manual = st.selectbox( "수동 선택", tied, index=None, key=f"manual_{col}", format_func=lambda t: fmt_team(t, titles), placeholder="팀 선택", ) if manual and st.button("확정", key=f"manual_btn_{col}"): save_tie_break(col, manual, "manual") st.rerun() continue winner_team = result["team"] winner_votes = result["votes"] diff = result["diff"] method = result.get("method", "") winner_label = fmt_team(winner_team, titles) method_tag = "" if method == "random": method_tag = " 🎲(추첨)" elif method == "manual": method_tag = " 🖊️(수동)" st.success( f"**우승: {winner_label}**{method_tag} — {winner_votes}표 " f"(2위와 {diff}표 차이)" ) if method: if st.button("동률 결정 취소 (재추첨)", key=f"clear_{col}"): clear_tie_break(col) st.rerun() public_lines.append( f"- {label} 우승: **{winner_label}** " f"({winner_votes}표, 2위와 {diff}표 차이)" ) with st.expander("전체 분포 — raw (제외 적용 전)"): for team, c in rows: marker = "" if team in awarded_teams and team != winner_team: marker = " 🚫상위상수상으로 제외" st.write(f"- {fmt_team(team, titles)}: {c}표{marker}") st.divider() st.subheader("🎤 시상식 발표용 (복사해서 화면 공유)") if public_lines: st.code("\n".join(public_lines), language="markdown") else: st.caption("아직 결과 없음. 투표 마감 후 자동 표시.") st.divider() with st.expander("🔍 감사 로그 (사칭 추적용)"): if not votes: st.caption("투표 없음") else: buf = io.StringIO() w = csv.writer(buf) w.writerow(["시각", "이름", "사번", "본인팀", "재미", "완성도", "실용성"]) for v in votes: w.writerow( [ v["created_at"], v["voter_name"], v.get("employee_id") or "", v["voter_team"], v["fun_team"], v["polish_team"], v["utility_team"], ] ) csv_data = "" + buf.getvalue() st.download_button( "CSV 내려받기", csv_data, file_name="audit.csv", mime="text/csv; charset=utf-8", ) st.dataframe( { "시각": [v["created_at"] for v in votes], "이름": [v["voter_name"] for v in votes], "사번": [v.get("employee_id") or "" for v in votes], "본인팀": [v["voter_team"] for v in votes], "재미": [v["fun_team"] for v in votes], "완성도": [v["polish_team"] for v in votes], "실용성": [v["utility_team"] for v in votes], }, use_container_width=True, hide_index=True, ) from collections import defaultdict by_emp = defaultdict(list) for v in votes: if v.get("employee_id"): by_emp[v["employee_id"]].append(v["voter_name"]) dups = {emp: names for emp, names in by_emp.items() if len(set(names)) > 1} if dups: st.error("⚠️ 같은 사번이 여러 이름으로 투표한 케이스:") for emp, names in dups.items(): st.write(f"- 사번 `{emp}`: {', '.join(names)}") st.divider() with st.expander("⚠️ 위험 작업"): if st.button("모든 투표 삭제 (되돌릴 수 없음)"): clear_votes() st.warning("전체 삭제됨. 새로고침하세요.") def render_ceremony(): token = st.query_params.get("token", "") if token != ADMIN_TOKEN: st.error("권한 없음. ?mode=ceremony&token=... 형식 필요.") return titles = get_titles() votes = list_votes() if not votes: st.warning("📊 아직 투표가 없습니다. 투표 종료 후 시상식을 시작하세요.") return if is_voting_open(): st.error( "⚠️ 투표가 아직 진행 중입니다. 어드민에서 '투표 마감' 후 시상하세요." ) return winners, _ = compute_winners() pending_count = sum( 1 for col, _, _ in CATEGORIES if winners.get(col, {}).get("status") == "tie" ) if pending_count > 0: st.warning("⏳ 시상 준비 중입니다. 잠시만 기다려주세요.") return if not st.session_state.get("ceremony_archived"): archived_path = archive_results() if archived_path: st.session_state.ceremony_archived = archived_path results = [] for col, label, prize in CATEGORIES: result = winners.get(col, {}) if result.get("status") == "ok": results.append( (label, prize, result["team"], result["votes"], result["diff"]) ) if "ceremony_step" not in st.session_state: st.session_state.ceremony_step = 0 if "ceremony_revealed" not in st.session_state: st.session_state.ceremony_revealed = False st.markdown( """ """, unsafe_allow_html=True, ) step = st.session_state.ceremony_step if step == 0: st.markdown('
🎉 해커톤 시상식 🎉
', unsafe_allow_html=True) st.markdown('
준비됐습니다
', unsafe_allow_html=True) if st.button("시작 →", use_container_width=True, type="primary"): st.session_state.ceremony_step = 1 st.session_state.ceremony_revealed = False st.rerun() elif step > len(results): st.markdown('
수고하셨습니다!
', unsafe_allow_html=True) st.balloons() st.snow() if st.button("처음으로", use_container_width=True): st.session_state.ceremony_step = 0 st.session_state.ceremony_revealed = False st.rerun() else: label, prize, winner, votes_n, diff = results[step - 1] st.markdown(f'
{label}
', unsafe_allow_html=True) st.markdown(f'
🎁 상품: {prize}
', unsafe_allow_html=True) if not st.session_state.ceremony_revealed: st.markdown('
🥁🥁🥁
', unsafe_allow_html=True) if st.button("우승팀 공개 →", use_container_width=True, type="primary"): st.session_state.ceremony_revealed = True st.rerun() else: st.balloons() winner_label = fmt_team(winner, titles) st.markdown( f'
🏆
{winner_label}
', unsafe_allow_html=True, ) st.markdown( f'
{votes_n}표 (2위와 {diff}표 차이)
', unsafe_allow_html=True, ) next_label = "다음 부문 →" if step < len(results) else "마무리 →" if st.button(next_label, use_container_width=True, type="primary"): st.session_state.ceremony_step = step + 1 st.session_state.ceremony_revealed = False st.rerun() def render_raw(): """JSON 원본 조회 — admin token 필요.""" token = st.query_params.get("token", "") if token != ADMIN_TOKEN: st.error("권한 없음. ?mode=raw&token=... 형식 필요.") return st.title("📦 hackathon.json 원본") try: raw_text = Path(DATA_PATH).read_text(encoding="utf-8") except FileNotFoundError: st.error(f"파일 없음: {DATA_PATH}") return ts = datetime.now().strftime("%Y%m%d_%H%M%S") col1, col2 = st.columns(2) with col1: st.download_button( "📥 다운로드", raw_text, file_name=f"hackathon_{ts}.json", mime="application/json", use_container_width=True, ) with col2: st.caption(f"`{DATA_PATH}` — {len(raw_text):,} bytes") try: st.json(json.loads(raw_text)) except json.JSONDecodeError: st.code(raw_text, language="json") def main(): st.set_page_config(page_title="해커톤", page_icon="🚀", layout="wide") mode = st.query_params.get("mode", "show") if mode == "admin": render_admin() elif mode == "ceremony": render_ceremony() elif mode == "raw": render_raw() elif mode == "vote": render_voter() else: render_show() if __name__ == "__main__": main()