- DB 테이블 team_titles 추가 - 어드민 페이지에 팀별 제목 입력 폼 (저장 즉시 반영) - 투표 라디오 옵션이 '팀1 — 결과물 제목' 형식으로 표시 - 우승 발표/시상 텍스트에도 제목 포함 - 제목 미입력 시 팀명만 (fallback) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
262 lines
7.9 KiB
Python
262 lines
7.9 KiB
Python
"""
|
|
해커톤 투표 앱
|
|
- 참가자: 이름 선택 → 본인 팀 자동 → 본인 팀 제외 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()
|