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:
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("📊 분야별 집계")
|
||||
|
||||
Reference in New Issue
Block a user