""" 해커톤 투표 앱 - 참가자: 이름 선택 → 본인 팀 자동 → 본인 팀 제외 3분야 투표 - 진행자: ?mode=admin&token=XXX 로 결과 확인 """ import json import os import sqlite3 from datetime import datetime from pathlib import Path import streamlit as st DB_PATH = os.environ.get("VOTE_DB", "votes.db") ADMIN_TOKEN = os.environ.get("ADMIN_TOKEN", "change-me") PARTICIPANTS_PATH = os.environ.get( "PARTICIPANTS", str(Path(__file__).parent / "participants.json") ) def load_participants(): """이름→팀 매핑. 파일 없으면 빈 dict.""" p = Path(PARTICIPANTS_PATH) if not p.exists(): return {} return json.loads(p.read_text(encoding="utf-8")) PARTICIPANTS = load_participants() TEAMS = sorted(set(PARTICIPANTS.values())) if PARTICIPANTS else [ f"팀{i}" for i in range(1, 8) ] CATEGORIES = [ ("fun_team", "🎉 재미상", "손선풍기 5개"), ("polish_team", "🏆 완성도상", "양우산 5개"), ("utility_team", "🛠 실용성상", "팜레스트 5개"), ] # 수상 결정 우선순위 (높을수록 먼저 결정, 후순위 상에서 그 팀 제외) # 팜레스트(실용성) > 양우산(완성도) > 손선풍기(재미) PRIZE_PRIORITY = ["utility_team", "polish_team", "fun_team"] def get_conn(): conn = sqlite3.connect(DB_PATH) conn.execute( """ CREATE TABLE IF NOT EXISTS votes ( id INTEGER PRIMARY KEY AUTOINCREMENT, voter_name TEXT NOT NULL UNIQUE, voter_team TEXT NOT NULL, fun_team TEXT NOT NULL, polish_team TEXT NOT NULL, utility_team TEXT NOT NULL, created_at TEXT NOT NULL ) """ ) conn.execute( """ CREATE TABLE IF NOT EXISTS team_titles ( team_name TEXT PRIMARY KEY, title TEXT NOT NULL DEFAULT '' ) """ ) conn.commit() return conn def get_titles(): """팀명 → 결과물 제목 dict. 매 호출 DB 조회 (라이브 반영).""" conn = get_conn() rows = conn.execute("SELECT team_name, title FROM team_titles").fetchall() conn.close() return dict(rows) def set_title(team, title): conn = get_conn() conn.execute( "INSERT INTO team_titles (team_name, title) VALUES (?, ?) " "ON CONFLICT(team_name) DO UPDATE SET title = excluded.title", (team, title.strip()), ) conn.commit() conn.close() def fmt_team(team, titles): """팀 라벨 — 제목 있으면 'N팀 — 제목' 없으면 'N팀'.""" t = titles.get(team, "") return f"{team} — {t}" if t else team def compute_winners(): """ 우선순위 기반 1팀 1상 보장. PRIZE_PRIORITY 순으로 결정, 이미 수상한 팀은 후순위 상에서 제외. return: dict[col] = (winner_team, winner_votes, diff_with_2nd, all_rows_excluded) """ conn = get_conn() rankings = {} for col, _, _ in CATEGORIES: rankings[col] = conn.execute( f"SELECT {col} AS team, COUNT(*) AS c FROM votes " f"GROUP BY {col} ORDER BY c DESC, team ASC" ).fetchall() conn.close() 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] = None continue winner, votes = filtered[0] runner = filtered[1][1] if len(filtered) > 1 else 0 winners[col] = (winner, votes, votes - runner, filtered) excluded.add(winner) return winners, rankings def render_voter(): st.title("🗳 해커톤 투표") st.caption("이름 선택 → 본인 팀 자동 매핑 → 본인 팀 제외 3분야 투표. 한 번만 제출 가능.") if not PARTICIPANTS: st.error("참가자 명단이 없습니다. `assign_teams.py` 먼저 실행하세요.") return name = st.selectbox( "본인 이름", options=sorted(PARTICIPANTS.keys()), index=None, placeholder="이름 선택", ) if not name: st.info("이름을 선택하면 본인 팀이 자동 표시됩니다.") return my_team = PARTICIPANTS[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 conn = get_conn() try: conn.execute( """ INSERT INTO votes (voter_name, voter_team, fun_team, polish_team, utility_team, created_at) VALUES (?, ?, ?, ?, ?, ?) """, ( name, my_team, picks["fun_team"], picks["polish_team"], picks["utility_team"], datetime.now().isoformat(timespec="seconds"), ), ) conn.commit() st.success(f"✅ {name}님 ({my_team}) 투표 완료. 창 닫아도 됩니다.") st.balloons() except sqlite3.IntegrityError: st.error(f"❌ '{name}' 이미 투표함. 진행자에게 문의하세요.") finally: conn.close() def render_admin(): token = st.query_params.get("token", "") if token != ADMIN_TOKEN: st.error("권한 없음. ?mode=admin&token=... 형식 필요.") return st.title("🔐 진행자 콘솔") conn = get_conn() total = conn.execute("SELECT COUNT(*) FROM votes").fetchone()[0] expected = len(PARTICIPANTS) if PARTICIPANTS 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 = {row[0] for row in conn.execute("SELECT voter_name FROM votes").fetchall()} not_voted = [n for n in PARTICIPANTS.keys() if n not in voted] with st.expander(f"⏳ 미투표자 ({len(not_voted)}명)"): for n in sorted(not_voted): st.write(f"- {n} ({PARTICIPANTS[n]})") st.divider() st.subheader("📝 팀별 결과물 제목 입력") st.caption("발표 직후 입력하면 투표 페이지에 즉시 반영됩니다.") titles = get_titles() with st.form("titles_form"): new_titles = {} for team in TEAMS: new_titles[team] = st.text_input( team, value=titles.get(team, ""), placeholder="예: 슬랙 멘션 자동 분류기", 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[0] for w in winners.values() if w} public_lines = [] for col, label, prize in CATEGORIES: rows = rankings[col] st.markdown(f"### {label} ({prize})") if not rows: st.caption("표 없음") continue result = winners.get(col) if not result: st.warning("후보 없음 (모두 우선순위 상 수상)") continue winner_team, winner_votes, diff, _ = result winner_label = fmt_team(winner_team, titles) st.success( f"**우승: {winner_label}** — {winner_votes}표 (2위와 {diff}표 차이)" ) public_lines.append( f"- {label} 우승: **{winner_label}** ({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("🎤 시상식 발표용 (복사해서 화면 공유)") st.code("\n".join(public_lines), language="markdown") st.divider() with st.expander("⚠️ 위험 작업"): if st.button("모든 투표 삭제 (되돌릴 수 없음)"): conn.execute("DELETE FROM votes") conn.commit() st.warning("전체 삭제됨. 새로고침하세요.") conn.close() def render_ceremony(): """시상식 reveal 페이지. 진행자가 클릭으로 단계별 공개.""" token = st.query_params.get("token", "") if token != ADMIN_TOKEN: st.error("권한 없음. ?mode=ceremony&token=... 형식 필요.") return titles = get_titles() winners, _ = compute_winners() # CATEGORIES 순서로 reveal (손선풍기 → 양우산 → 팜레스트) results = [] for col, label, prize in CATEGORIES: result = winners.get(col) if result: winner, votes, diff, _ = result results.append((label, prize, winner, votes, 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('