commit 5fe8842e88c23814eea027a354072851d6596884 Author: th-kim0823 Date: Sat Apr 25 18:55:21 2026 +0900 feat: 해커톤 투표 앱 초기 구현 - 35명/7팀/3분야(재미·완성도·실용성) 투표 - 본인 팀 제외 자동 처리 - 이름 UNIQUE 중복 방지 - 진행자 어드민 페이지: 1위와 2위 차이만 공개, 하위 팀 표수는 비공개 - sqlite 단일 파일 저장 Co-Authored-By: Claude Opus 4.7 (1M context) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0463afa --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +__pycache__/ +*.pyc +.venv/ +venv/ +*.db +*.sqlite +.env +.streamlit/secrets.toml diff --git a/README.md b/README.md new file mode 100644 index 0000000..f7698ca --- /dev/null +++ b/README.md @@ -0,0 +1,40 @@ +# 해커톤 투표 + +35명 / 7팀 / 3분야 (재미·완성도·실용성) 투표 앱. 본인 팀 제외 투표. + +## 실행 + +```bash +# 1. 의존성 설치 +pip install -r requirements.txt + +# 2. 환경변수 (선택) +export TEAMS="팀1,팀2,팀3,팀4,팀5,팀6,팀7" # 콤마 구분 +export ADMIN_TOKEN="강한-토큰-아무거나" +export VOTE_DB="votes.db" # sqlite 파일 경로 + +# 3. 실행 (홈서버, 외부 접속 허용) +streamlit run app.py --server.address 0.0.0.0 --server.port 8501 +``` + +## URL + +- 참가자: `http://<홈서버-IP>:8501/` +- 진행자: `http://<홈서버-IP>:8501/?mode=admin&token=` + +## 흐름 + +1. 참가자 — 이름 입력 → 본인 팀 선택 → 본인 팀 빼고 3분야 라디오 → 제출 +2. 중복 방지 — 같은 이름은 한 번만 투표 가능 (UNIQUE 제약) +3. 진행자 — 어드민 페이지에서 분야별 집계 확인 +4. 시상식 — 어드민 페이지 하단 "시상식 발표용" 박스 복사 (1위와 2위 차이만 표시, 하위 팀 표수 비공개) + +## 데이터 + +`votes.db` (sqlite). 테이블: `votes(id, voter_name UNIQUE, voter_team, fun_team, polish_team, utility_team, created_at)`. + +## 운영 팁 + +- 행사 끝나면 `votes.db` 백업 후 보관 또는 삭제 +- 부정 투표 의심 시 어드민 → 위험 작업 → 전체 삭제 후 재투표 진행 가능 +- ADMIN_TOKEN은 `change-me` 기본값 — 반드시 변경 diff --git a/app.py b/app.py new file mode 100644 index 0000000..ef926c9 --- /dev/null +++ b/app.py @@ -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() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..61b5d9d --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +streamlit>=1.32