diff --git a/Dockerfile b/Dockerfile index ab50db4..5683d93 100644 --- a/Dockerfile +++ b/Dockerfile @@ -12,11 +12,12 @@ COPY requirements.txt ./ RUN pip install --no-cache-dir -r requirements.txt COPY app.py assign_teams.py ./ -COPY participants.json ./ +COPY roster.json participants.json ./ EXPOSE 8501 ENV VOTE_DB=/data/votes.db \ + ROSTER=/app/roster.json \ PARTICIPANTS=/app/participants.json CMD ["streamlit", "run", "app.py", \ diff --git a/README.md b/README.md index ced30be..bb32c46 100644 --- a/README.md +++ b/README.md @@ -60,8 +60,20 @@ streamlit run app.py --server.address 0.0.0.0 --server.port 8501 ## 데이터 -- `participants.json` — 이름→팀 매핑 (`assign_teams.py` 산출물) -- `votes.db` (sqlite) — 테이블: `votes(id, voter_name UNIQUE, voter_team, fun_team, polish_team, utility_team, created_at)` +- `roster.json` — **단일 명단 파일** (`assign_teams.py` 산출물). 호스트에서 직접 편집 가능, 앱이 매 요청 reload. + ```json + { + "people": [ + {"name": "홍길동", "team": "팀1", "dept": "MLOps Data", "senior": true, "notes": ""}, + ... + ] + } + ``` + - 사람을 다른 팀으로 옮기기: `team` 값만 변경 + - 사람 추가/제거: 객체 추가/삭제 + - 변경 후 컨테이너 재시작 불필요 (핫리로드) +- `participants.json` — legacy 형식 (이름→팀 string). roster.json 없으면 fallback. +- `votes.db` (sqlite, WAL) — `votes`, `team_titles`, `tie_breaks`, `settings` 테이블 ## 운영 팁 diff --git a/app.py b/app.py index 500773b..45ddaa2 100644 --- a/app.py +++ b/app.py @@ -13,23 +13,47 @@ import streamlit as st DB_PATH = os.environ.get("VOTE_DB", "votes.db") ADMIN_TOKEN = os.environ.get("ADMIN_TOKEN", "change-me") -PARTICIPANTS_PATH = os.environ.get( +ROSTER_PATH = os.environ.get( + "ROSTER", str(Path(__file__).parent / "roster.json") +) +LEGACY_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")) +def load_roster(): + """ + roster.json 우선, 없으면 legacy participants.json fallback. + 매 호출마다 디스크에서 read → 호스트 편집 핫리로드. + return: {"people": [...]} 형식 dict + """ + roster_p = Path(ROSTER_PATH) + if roster_p.exists(): + return json.loads(roster_p.read_text(encoding="utf-8")) + + legacy_p = Path(LEGACY_PARTICIPANTS_PATH) + if legacy_p.exists(): + # legacy: {name: team_str} + legacy = json.loads(legacy_p.read_text(encoding="utf-8")) + return { + "people": [ + {"name": n, "team": t, "dept": "", "senior": False, "notes": ""} + for n, t in legacy.items() + ] + } + return {"people": []} -PARTICIPANTS = load_participants() -TEAMS = sorted(set(PARTICIPANTS.values())) if PARTICIPANTS else [ - f"팀{i}" for i in range(1, 8) -] +def get_participants(): + """이름→팀 매핑. 매 호출 reload (호스트 편집 즉시 반영).""" + roster = load_roster() + return {p["name"]: p["team"] for p in roster.get("people", [])} + + +def get_teams(): + """팀명 정렬 list. 매 호출 reload.""" + parts = get_participants() + return sorted(set(parts.values())) if parts else [f"팀{i}" for i in range(1, 8)] CATEGORIES = [ ("fun_team", "🎉 재미상", "손선풍기 5개"), @@ -268,7 +292,9 @@ def render_voter(): st.caption("이름 선택 → 본인 팀 자동 매핑 → 본인 팀 제외 3분야 투표. 한 번만 제출 가능.") - if not PARTICIPANTS: + PARTS = get_participants() + TEAMS = get_teams() + if not PARTS: st.error("참가자 명단이 없습니다. `assign_teams.py` 먼저 실행하세요.") return @@ -284,7 +310,7 @@ def render_voter(): name = st.selectbox( "본인 이름", - options=sorted(PARTICIPANTS.keys()), + options=sorted(PARTS.keys()), index=None, placeholder="이름 선택", format_func=fmt_name, @@ -308,7 +334,7 @@ def render_voter(): st.warning("사번 입력 후 진행하세요.") return - my_team = PARTICIPANTS[name] + my_team = PARTS[name] titles = get_titles() st.info(f"본인 팀: **{fmt_team(my_team, titles)}**") candidates = [t for t in TEAMS if t != my_team] @@ -382,10 +408,12 @@ def render_admin(): st.divider() + PARTS = get_participants() + TEAMS = get_teams() conn = get_conn() total = conn.execute("SELECT COUNT(*) FROM votes").fetchone()[0] - expected = len(PARTICIPANTS) if PARTICIPANTS else None + expected = len(PARTS) if PARTS else None col_a, col_b, col_c = st.columns(3) col_a.metric("투표 참여자", f"{total}명") col_b.metric("팀 수", f"{len(TEAMS)}팀") @@ -395,10 +423,10 @@ def render_admin(): 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] + not_voted = [n for n in PARTS.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.write(f"- {n} ({PARTS[n]})") st.divider() st.subheader("📝 팀별 결과물 제목 입력") diff --git a/assign_teams.py b/assign_teams.py index 71a612d..b3e7d5a 100644 --- a/assign_teams.py +++ b/assign_teams.py @@ -240,12 +240,40 @@ def main(): mapping[name] = team_name print() + # roster.json (단일 파일, 호스트에서 직접 편집 가능) + people_records = [] + for i, team in enumerate(teams, 1): + team_name = f"팀{i}" + for name, dept in team: + note_parts = [] + if name == "한지승": + note_parts.append("지각 가능") + if name == "김재현": + note_parts.append("최주니어") + people_records.append( + { + "name": name, + "team": team_name, + "dept": dept, + "senior": name in SENIORS, + "notes": ", ".join(note_parts), + } + ) + + roster_path = Path(__file__).parent / "roster.json" + roster_path.write_text( + json.dumps({"people": people_records}, ensure_ascii=False, indent=2), + encoding="utf-8", + ) + print(f"저장: {roster_path}") + + # 구 형식 호환 (legacy participants.json 도 함께 저장 — 마이그레이션 기간) out = Path(__file__).parent / "participants.json" out.write_text( json.dumps(mapping, ensure_ascii=False, indent=2), encoding="utf-8", ) - print(f"저장: {out}") + print(f"저장: {out} (legacy)") # teams.md 박제 (진행자 인쇄/공유용) md_lines = ["# 해커톤 팀 배정 (확정)\n"] diff --git a/docker-compose.yml b/docker-compose.yml index af9b6d0..29aecb5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,11 +8,14 @@ services: environment: ADMIN_TOKEN: ${ADMIN_TOKEN:-change-me} VOTE_DB: /data/votes.db + ROSTER: /app/roster.json PARTICIPANTS: /app/participants.json volumes: - # 호스트의 participants.json 변경 즉시 반영 (재배정 시) + # roster.json 호스트 편집 즉시 반영 (앱이 매 요청 reload) + - ./roster.json:/app/roster.json:ro + # legacy participants.json fallback - ./participants.json:/app/participants.json:ro - # 투표 DB는 호스트에 영속 (컨테이너 재시작해도 유지) + # 투표 DB 영속 - vote-data:/data restart: unless-stopped diff --git a/roster.json b/roster.json new file mode 100644 index 0000000..95eda74 --- /dev/null +++ b/roster.json @@ -0,0 +1,242 @@ +{ + "people": [ + { + "name": "김호승", + "team": "팀1", + "dept": "AI Efficiency Tech", + "senior": false, + "notes": "" + }, + { + "name": "유준희", + "team": "팀1", + "dept": "AI Efficiency Tech", + "senior": false, + "notes": "" + }, + { + "name": "이준석", + "team": "팀1", + "dept": "MLOps HPC", + "senior": false, + "notes": "" + }, + { + "name": "장다현", + "team": "팀1", + "dept": "MLOps System", + "senior": false, + "notes": "" + }, + { + "name": "강승형", + "team": "팀1", + "dept": "MLOps Data", + "senior": true, + "notes": "" + }, + { + "name": "서한배", + "team": "팀2", + "dept": "AI Efficiency Tech", + "senior": false, + "notes": "" + }, + { + "name": "김민섭", + "team": "팀2", + "dept": "AI Efficiency Tech", + "senior": false, + "notes": "" + }, + { + "name": "유용혁", + "team": "팀2", + "dept": "MLOps HPC", + "senior": false, + "notes": "" + }, + { + "name": "박영훈", + "team": "팀2", + "dept": "MLOps System", + "senior": true, + "notes": "" + }, + { + "name": "박재호", + "team": "팀2", + "dept": "MLOps Data", + "senior": false, + "notes": "" + }, + { + "name": "이성재", + "team": "팀3", + "dept": "AI Efficiency Tech", + "senior": false, + "notes": "" + }, + { + "name": "이재광", + "team": "팀3", + "dept": "AI Efficiency Tech", + "senior": false, + "notes": "" + }, + { + "name": "김영관", + "team": "팀3", + "dept": "MLOps HPC", + "senior": true, + "notes": "" + }, + { + "name": "정채윤", + "team": "팀3", + "dept": "MLOps System", + "senior": false, + "notes": "" + }, + { + "name": "변수민", + "team": "팀3", + "dept": "MLOps Data", + "senior": true, + "notes": "" + }, + { + "name": "심성환", + "team": "팀4", + "dept": "AI Efficiency Tech", + "senior": false, + "notes": "" + }, + { + "name": "유지원", + "team": "팀4", + "dept": "AI Efficiency Tech", + "senior": false, + "notes": "" + }, + { + "name": "오근현", + "team": "팀4", + "dept": "MLOps HPC", + "senior": false, + "notes": "" + }, + { + "name": "장혁진", + "team": "팀4", + "dept": "MLOps System", + "senior": false, + "notes": "" + }, + { + "name": "손현준", + "team": "팀4", + "dept": "MLOps Data", + "senior": true, + "notes": "" + }, + { + "name": "정현준", + "team": "팀5", + "dept": "AI Efficiency Tech", + "senior": false, + "notes": "" + }, + { + "name": "조민정", + "team": "팀5", + "dept": "AI Efficiency Tech", + "senior": false, + "notes": "" + }, + { + "name": "김재현", + "team": "팀5", + "dept": "MLOps HPC", + "senior": false, + "notes": "최주니어" + }, + { + "name": "김병훈", + "team": "팀5", + "dept": "MLOps System", + "senior": true, + "notes": "" + }, + { + "name": "한지승", + "team": "팀5", + "dept": "MLOps Platform", + "senior": true, + "notes": "지각 가능" + }, + { + "name": "이정태", + "team": "팀6", + "dept": "AI Efficiency Tech", + "senior": false, + "notes": "" + }, + { + "name": "최호진", + "team": "팀6", + "dept": "MLOps HPC", + "senior": false, + "notes": "" + }, + { + "name": "김정명", + "team": "팀6", + "dept": "MLOps HPC", + "senior": true, + "notes": "" + }, + { + "name": "길주현", + "team": "팀6", + "dept": "MLOps System", + "senior": false, + "notes": "" + }, + { + "name": "서희", + "team": "팀6", + "dept": "MLOps System", + "senior": true, + "notes": "" + }, + { + "name": "이준형", + "team": "팀7", + "dept": "AI Efficiency Tech", + "senior": false, + "notes": "" + }, + { + "name": "전효준", + "team": "팀7", + "dept": "MLOps HPC", + "senior": true, + "notes": "" + }, + { + "name": "이지환", + "team": "팀7", + "dept": "MLOps System", + "senior": false, + "notes": "" + }, + { + "name": "김동국", + "team": "팀7", + "dept": "MLOps Data", + "senior": false, + "notes": "" + } + ] +} \ No newline at end of file