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:
th-kim0823
2026-04-25 19:02:27 +09:00
parent 5fe8842e88
commit e661372f84
4 changed files with 215 additions and 34 deletions

88
app.py
View File

@@ -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("📊 분야별 집계")