feat: roster.json 단일 명단 파일 + 핫리로드

호스트에서 직접 편집 가능한 단일 JSON으로 명단 일원화.
앱이 매 요청마다 디스크에서 reload → 컨테이너 재시작 불필요.

변경:
- roster.json 새 형식: {"people": [{"name", "team", "dept", "senior", "notes"}, ...]}
- assign_teams.py: roster.json + legacy participants.json 둘 다 출력
- app.py: get_participants() / get_teams() 매 호출 reload
  - PARTS = get_participants() / TEAMS = get_teams() 함수 안에서 호출
  - 모듈 레벨 PARTICIPANTS/TEAMS 제거
  - load_roster() roster.json 우선, 없으면 legacy fallback
- docker-compose: roster.json + participants.json 둘 다 mount
- Dockerfile: ROSTER env + roster.json COPY

사용자 워크플로:
- 사람 다른 팀 옮기기: roster.json에서 그 사람 'team' 값만 변경
- 자동 배정 재실행: python3 assign_teams.py

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
th-kim0823
2026-04-26 17:43:16 +09:00
parent 3f40f3f47a
commit bf4d3e73cc
6 changed files with 337 additions and 23 deletions

View File

@@ -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", \

View File

@@ -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` 테이블
## 운영 팁

62
app.py
View File

@@ -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("📝 팀별 결과물 제목 입력")

View File

@@ -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"]

View File

@@ -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

242
roster.json Normal file
View File

@@ -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": ""
}
]
}