feat: 해커톤 투표 앱 초기 구현
- 35명/7팀/3분야(재미·완성도·실용성) 투표 - 본인 팀 제외 자동 처리 - 이름 UNIQUE 중복 방지 - 진행자 어드민 페이지: 1위와 2위 차이만 공개, 하위 팀 표수는 비공개 - sqlite 단일 파일 저장 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
169
app.py
Normal file
169
app.py
Normal file
@@ -0,0 +1,169 @@
|
||||
"""
|
||||
해커톤 투표 앱
|
||||
- 참가자: 본인 팀 제외하고 3분야 투표
|
||||
- 진행자: ?mode=admin&token=XXX 로 결과 확인
|
||||
"""
|
||||
import os
|
||||
import sqlite3
|
||||
from datetime import datetime
|
||||
|
||||
import streamlit as st
|
||||
|
||||
DB_PATH = os.environ.get("VOTE_DB", "votes.db")
|
||||
ADMIN_TOKEN = os.environ.get("ADMIN_TOKEN", "change-me")
|
||||
TEAMS = os.environ.get("TEAMS", "팀1,팀2,팀3,팀4,팀5,팀6,팀7").split(",")
|
||||
TEAMS = [t.strip() for t in TEAMS if t.strip()]
|
||||
|
||||
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.commit()
|
||||
return conn
|
||||
|
||||
|
||||
def render_voter():
|
||||
st.title("🗳 해커톤 투표")
|
||||
st.caption("본인 팀 제외하고 다른 팀에 투표하세요. 한 번만 제출 가능.")
|
||||
|
||||
with st.form("vote", clear_on_submit=False):
|
||||
name = st.text_input("이름", placeholder="홍길동")
|
||||
my_team = st.selectbox("본인 팀", TEAMS, index=None, placeholder="선택")
|
||||
|
||||
if my_team:
|
||||
candidates = [t for t in TEAMS if t != my_team]
|
||||
st.divider()
|
||||
picks = {}
|
||||
for col, label in CATEGORIES:
|
||||
picks[col] = st.radio(label, candidates, index=None, key=col)
|
||||
submitted = st.form_submit_button("제출")
|
||||
else:
|
||||
st.info("본인 팀을 먼저 선택하세요.")
|
||||
submitted = st.form_submit_button("제출", disabled=True)
|
||||
picks = {}
|
||||
|
||||
if submitted:
|
||||
if not name.strip():
|
||||
st.error("이름을 입력하세요.")
|
||||
return
|
||||
if not my_team:
|
||||
st.error("본인 팀을 선택하세요.")
|
||||
return
|
||||
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.strip(),
|
||||
my_team,
|
||||
picks["fun_team"],
|
||||
picks["polish_team"],
|
||||
picks["utility_team"],
|
||||
datetime.now().isoformat(timespec="seconds"),
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
st.success(f"✅ {name.strip()}님 투표 완료. 창 닫아도 됩니다.")
|
||||
st.balloons()
|
||||
except sqlite3.IntegrityError:
|
||||
st.error(f"❌ '{name.strip()}' 이미 투표한 이름입니다. 진행자에게 문의하세요.")
|
||||
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]
|
||||
|
||||
col_a, col_b = st.columns(2)
|
||||
col_a.metric("투표 참여자", f"{total}명")
|
||||
col_b.metric("팀 수", f"{len(TEAMS)}팀")
|
||||
|
||||
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
|
||||
|
||||
st.success(
|
||||
f"**우승: {winner_team}** — {winner_votes}표 (2위와 {diff}표 차이)"
|
||||
)
|
||||
public_lines.append(
|
||||
f"- {label} 우승: **{winner_team}** ({winner_votes}표, 2위와 {diff}표 차이)"
|
||||
)
|
||||
|
||||
with st.expander("전체 분포 (진행자만)"):
|
||||
for team, c in rows:
|
||||
st.write(f"- {team}: {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()
|
||||
Reference in New Issue
Block a user