diff --git a/README.md b/README.md index f7698ca..59e7ed2 100644 --- a/README.md +++ b/README.md @@ -2,18 +2,28 @@ 35명 / 7팀 / 3분야 (재미·완성도·실용성) 투표 앱. 본인 팀 제외 투표. +## 흐름 + +1. `assign_teams.py` 실행 → `participants.json` 생성 (이름→팀 매핑) +2. `app.py` 실행 → 참가자가 본인 이름 선택 → 자동 본인 팀 매핑 → 다른 6팀에 3분야 투표 +3. 어드민 페이지에서 분야별 1위와 2위 차이만 공개 (하위 표수는 expander 내부) + ## 실행 ```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 파일 경로 +# 2. 팀 배정 (시드 고정 = 재현 가능) +python3 assign_teams.py +# → participants.json 저장됨 -# 3. 실행 (홈서버, 외부 접속 허용) +# 3. 환경변수 (선택) +export ADMIN_TOKEN="강한-토큰-아무거나" +export VOTE_DB="votes.db" +export PARTICIPANTS="participants.json" + +# 4. 홈서버 실행 (외부 접속 허용) streamlit run app.py --server.address 0.0.0.0 --server.port 8501 ``` @@ -31,7 +41,8 @@ streamlit run app.py --server.address 0.0.0.0 --server.port 8501 ## 데이터 -`votes.db` (sqlite). 테이블: `votes(id, voter_name UNIQUE, voter_team, fun_team, polish_team, utility_team, created_at)`. +- `participants.json` — 이름→팀 매핑 (`assign_teams.py` 산출물) +- `votes.db` (sqlite) — 테이블: `votes(id, voter_name UNIQUE, voter_team, fun_team, polish_team, utility_team, created_at)` ## 운영 팁 diff --git a/app.py b/app.py index ef926c9..f5c07ed 100644 --- a/app.py +++ b/app.py @@ -1,18 +1,35 @@ """ 해커톤 투표 앱 -- 참가자: 본인 팀 제외하고 3분야 투표 +- 참가자: 이름 선택 → 본인 팀 자동 → 본인 팀 제외 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") -TEAMS = os.environ.get("TEAMS", "팀1,팀2,팀3,팀4,팀5,팀6,팀7").split(",") -TEAMS = [t.strip() for t in TEAMS if t.strip()] +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", "🎉 재미상"), @@ -42,31 +59,35 @@ def get_conn(): def render_voter(): st.title("🗳 해커톤 투표") - st.caption("본인 팀 제외하고 다른 팀에 투표하세요. 한 번만 제출 가능.") + 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] + st.info(f"본인 팀: **{my_team}**") + candidates = [t for t in TEAMS if t != my_team] 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 = {} + st.divider() + picks = {} + for col, label in CATEGORIES: + picks[col] = st.radio(label, candidates, index=None, key=col) + submitted = st.form_submit_button("제출") 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 @@ -80,7 +101,7 @@ def render_voter(): VALUES (?, ?, ?, ?, ?, ?) """, ( - name.strip(), + name, my_team, picks["fun_team"], picks["polish_team"], @@ -89,10 +110,10 @@ def render_voter(): ), ) conn.commit() - st.success(f"✅ {name.strip()}님 투표 완료. 창 닫아도 됩니다.") + st.success(f"✅ {name}님 ({my_team}) 투표 완료. 창 닫아도 됩니다.") st.balloons() except sqlite3.IntegrityError: - st.error(f"❌ '{name.strip()}' 이미 투표한 이름입니다. 진행자에게 문의하세요.") + st.error(f"❌ '{name}' 이미 투표함. 진행자에게 문의하세요.") finally: conn.close() @@ -108,9 +129,20 @@ def render_admin(): conn = get_conn() total = conn.execute("SELECT COUNT(*) FROM votes").fetchone()[0] - col_a, col_b = st.columns(2) + 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("📊 분야별 집계") diff --git a/assign_teams.py b/assign_teams.py new file mode 100644 index 0000000..58edaa9 --- /dev/null +++ b/assign_teams.py @@ -0,0 +1,101 @@ +""" +참가자 35명 → 7팀 (팀당 5명) 배정. +같은 부서 2명 이하 제약. 랜덤 시드로 재현 가능. +결과: participants.json (이름→팀 매핑) + 콘솔 출력. +""" +import json +import random +from collections import Counter +from pathlib import Path + +PEOPLE = [ + ("한지승", "MLOps Platform"), + ("변수민", "MLOps Data"), + ("박재호", "MLOps Data"), + ("김태현", "MLOps Data"), + ("강승형", "MLOps Data"), + ("손현준", "MLOps Data"), + ("김동국", "MLOps Data"), + ("김재현", "MLOps HPC"), + ("이준석", "MLOps HPC"), + ("오근현", "MLOps HPC"), + ("김정명", "MLOps HPC"), + ("김영관", "MLOps HPC"), + ("유용혁", "MLOps HPC"), + ("최호진", "MLOps HPC"), + ("전효준", "MLOps HPC"), + ("김병훈", "MLOps System"), + ("이지환", "MLOps System"), + ("서희", "MLOps System"), + ("정채윤", "MLOps System"), + ("장혁진", "MLOps System"), + ("장다현", "MLOps System"), + ("박영훈", "MLOps System"), + ("길주현", "MLOps System"), + ("조민정", "AI Efficiency Tech"), + ("김민섭", "AI Efficiency Tech"), + ("김호승", "AI Efficiency Tech"), + ("서한배", "AI Efficiency Tech"), + ("심성환", "AI Efficiency Tech"), + ("유준희", "AI Efficiency Tech"), + ("이성재", "AI Efficiency Tech"), + ("이재광", "AI Efficiency Tech"), + ("이정태", "AI Efficiency Tech"), + ("이준형", "AI Efficiency Tech"), + ("정현준", "AI Efficiency Tech"), + ("유지원", "AI Efficiency Tech"), +] + +NUM_TEAMS = 7 +TEAM_SIZE = 5 +MAX_SAME_DEPT = 2 +SEED = 20260428 # 행사일 시드 (재현 가능) + + +def assign(seed): + rng = random.Random(seed) + for attempt in range(5000): + shuffled = PEOPLE[:] + rng.shuffle(shuffled) + teams = [ + shuffled[i * TEAM_SIZE : (i + 1) * TEAM_SIZE] + for i in range(NUM_TEAMS) + ] + ok = all( + max(Counter(d for _, d in team).values()) <= MAX_SAME_DEPT + for team in teams + ) + if ok: + return teams, attempt + 1 + raise RuntimeError(f"제약 만족하는 배정 5000회 시도에도 실패") + + +def main(): + assert len(PEOPLE) == NUM_TEAMS * TEAM_SIZE, ( + f"인원수 불일치: {len(PEOPLE)}명, 기대 {NUM_TEAMS * TEAM_SIZE}명" + ) + + teams, attempts = assign(SEED) + print(f"# 시드 {SEED}, 시도 {attempts}회 만에 배정 완료\n") + + mapping = {} + for i, team in enumerate(teams, 1): + team_name = f"팀{i}" + dept_counts = Counter(d for _, d in team) + dept_summary = ", ".join(f"{d.replace('MLOps ', '').replace('AI Efficiency Tech', 'AI Eff')} {c}" for d, c in dept_counts.items()) + print(f"## {team_name} ({dept_summary})") + for name, dept in team: + print(f" - {name} ({dept})") + mapping[name] = team_name + print() + + out = Path(__file__).parent / "participants.json" + out.write_text( + json.dumps(mapping, ensure_ascii=False, indent=2), + encoding="utf-8", + ) + print(f"저장: {out}") + + +if __name__ == "__main__": + main() diff --git a/participants.json b/participants.json new file mode 100644 index 0000000..640e38a --- /dev/null +++ b/participants.json @@ -0,0 +1,37 @@ +{ + "유준희": "팀1", + "김재현": "팀1", + "조민정": "팀1", + "손현준": "팀1", + "유용혁": "팀1", + "한지승": "팀2", + "김정명": "팀2", + "박재호": "팀2", + "김동국": "팀2", + "장혁진": "팀2", + "강승형": "팀3", + "김태현": "팀3", + "김민섭": "팀3", + "이성재": "팀3", + "길주현": "팀3", + "서한배": "팀4", + "최호진": "팀4", + "김영관": "팀4", + "이정태": "팀4", + "박영훈": "팀4", + "유지원": "팀5", + "전효준": "팀5", + "정현준": "팀5", + "장다현": "팀5", + "정채윤": "팀5", + "오근현": "팀6", + "이준형": "팀6", + "이준석": "팀6", + "서희": "팀6", + "김호승": "팀6", + "이재광": "팀7", + "이지환": "팀7", + "김병훈": "팀7", + "심성환": "팀7", + "변수민": "팀7" +} \ No newline at end of file