feat: 35명 7팀 배정 + 이름 기반 자동 팀 매핑
- assign_teams.py: 부서 다양성 제약(같은 부서 ≤2명) 시드 고정 배정 - participants.json: 이름→팀 매핑 산출물 - app.py: 이름 선택 → 본인 팀 자동 표시 (수동 입력 부정 차단) - 어드민 참여율 메트릭 + 미투표자 목록 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
23
README.md
23
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)`
|
||||
|
||||
## 운영 팁
|
||||
|
||||
|
||||
88
app.py
88
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("📊 분야별 집계")
|
||||
|
||||
101
assign_teams.py
Normal file
101
assign_teams.py
Normal file
@@ -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()
|
||||
37
participants.json
Normal file
37
participants.json
Normal file
@@ -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"
|
||||
}
|
||||
Reference in New Issue
Block a user