""" 해커톤 투표 앱 - 참가자: 이름 선택 → 본인 팀 자동 → 본인 팀 제외 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", "🎉 재미상"), ("polish_team", "🏆 완성도상"), ("utility_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 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("📊 분야별 집계") public_lines = [] # 시상식 발표용 (하위 비공개) for col, label in CATEGORIES: rows = conn.execute( f"SELECT {col} AS team, COUNT(*) AS c FROM votes GROUP BY {col} ORDER BY c DESC, team ASC" ).fetchall() st.markdown(f"### {label}") if not rows: st.caption("표 없음") continue winner_team, winner_votes = rows[0] runner_votes = rows[1][1] if len(rows) > 1 else 0 diff = winner_votes - runner_votes 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("전체 분포 (진행자만)"): for team, c in rows: st.write(f"- {fmt_team(team, titles)}: {c}표") 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 main(): st.set_page_config(page_title="해커톤 투표", page_icon="🗳") mode = st.query_params.get("mode", "vote") if mode == "admin": render_admin() else: render_voter() if __name__ == "__main__": main()