From 6e517be918f133b82ad87938338d260f14307530 Mon Sep 17 00:00:00 2001 From: th-kim0823 Date: Sun, 26 Apr 2026 18:25:46 +0900 Subject: [PATCH] =?UTF-8?q?refactor:=20DB=20=EC=A0=9C=EA=B1=B0=20=E2=86=92?= =?UTF-8?q?=20=EB=8B=A8=EC=9D=BC=20hackathon.json=20(JSON=20only)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DB(sqlite + WAL) 제거. 모든 state를 단일 JSON 파일로 통합. 일회용/내부용이라 유지보수성/확장성보다 단순성 우선. 변경: - app.py: sqlite3 import 제거. load_data/save_data + threading.RLock + atomic write - votes: list of dict - titles, tie_breaks, settings: dict - people: roster (assign_teams가 채움) - 누락 키 자동 보강 - assign_teams.py: hackathon.json 단일 출력. 기존 votes/titles 보존 - Dockerfile/compose: votes.db volume 제거. hackathon.json read-write mount - tests/e2e.py: 12개 (12/12 통과). load/save/insert_vote/clear_votes/atomic 추가 - README: 새 데이터 구조 문서화 - roster.json/participants.json 제거 (hackathon.json으로 통합) 호스트 편집 워크플로: - jq/vi로 hackathon.json 직접 편집 - 앱 매 요청 reload — 컨테이너 재시작 불필요 Co-Authored-By: Claude Opus 4.7 (1M context) --- Dockerfile | 7 +- README.md | 115 ++++---- app.py | 534 ++++++++++++++-------------------- assign_teams.py | 35 ++- docker-compose.yml | 16 +- roster.json => hackathon.json | 6 + participants.json | 36 --- tests/e2e.py | 194 ++++++------ 8 files changed, 413 insertions(+), 530 deletions(-) rename roster.json => hackathon.json (97%) delete mode 100644 participants.json diff --git a/Dockerfile b/Dockerfile index 5683d93..e8aaf2a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,7 +2,6 @@ FROM python:3.12-slim WORKDIR /app -# 한국 시간대 (감사 로그 정확성) RUN apt-get update && \ apt-get install -y --no-install-recommends tzdata && \ rm -rf /var/lib/apt/lists/* @@ -12,13 +11,11 @@ COPY requirements.txt ./ RUN pip install --no-cache-dir -r requirements.txt COPY app.py assign_teams.py ./ -COPY roster.json participants.json ./ +COPY hackathon.json ./ EXPOSE 8501 -ENV VOTE_DB=/data/votes.db \ - ROSTER=/app/roster.json \ - PARTICIPANTS=/app/participants.json +ENV DATA_PATH=/app/hackathon.json CMD ["streamlit", "run", "app.py", \ "--server.address=0.0.0.0", \ diff --git a/README.md b/README.md index bb32c46..fb65ba3 100644 --- a/README.md +++ b/README.md @@ -1,82 +1,87 @@ # 해커톤 투표 -35명 / 7팀 / 3분야 (재미·완성도·실용성) 투표 앱. 본인 팀 제외 투표. +35명 (34명 참가 + 1명 진행요원) / 7팀 / 3분야 (재미·완성도·실용성) 투표 앱. +**DB 없이 단일 JSON 파일** (`hackathon.json`)에 모든 데이터. ## 흐름 -1. `assign_teams.py` 실행 → `participants.json` 생성 (이름→팀 매핑) -2. `app.py` 실행 → 참가자가 본인 이름 선택 → 자동 본인 팀 매핑 → 다른 6팀에 3분야 투표 -3. 어드민 페이지에서 분야별 1위와 2위 차이만 공개 (하위 표수는 expander 내부) +1. `assign_teams.py` 실행 → `hackathon.json` 생성 (people 배정) +2. `app.py` 실행 → 본인 이름 + 사번 입력 → 다른 6팀에 3분야 투표 +3. 어드민에서 마감 → 시상식 reveal +4. 결과 자동 archive (`results_.json`) -## 실행 — Docker (권장) +## 실행 — Docker ```bash -# 1. 팀 배정 (호스트에서 1회, participants.json 생성) +# 1. 팀 배정 (호스트에서 1회) python3 assign_teams.py -# 2. .env 작성 (1회) +# 2. .env (1회) cp .env.example .env -# .env 파일을 열어 ADMIN_TOKEN을 강한 랜덤 토큰으로 변경 +# ADMIN_TOKEN을 강한 토큰으로 변경 # 빠르게: python3 -c "import secrets; print(secrets.token_urlsafe(16))" -# 3. 컨테이너 실행 +# 3. 컨테이너 docker compose up -d --build -# 로그 -docker compose logs -f - # 종료 docker compose down - -# DB 영속 데이터까지 삭제 -docker compose down -v -``` - -`.env`는 git ignore. token 노출 방지. - -- 투표 DB는 docker volume `vote-data`에 영속 → 컨테이너 재시작해도 유지 -- `participants.json`은 호스트→컨테이너 read-only mount → 재배정 시 호스트에서 변경하고 컨테이너만 재시작 - -## 실행 — 로컬 (Docker 없이) - -```bash -pip install -r requirements.txt -python3 assign_teams.py -export ADMIN_TOKEN="강한-토큰-아무거나" -streamlit run app.py --server.address 0.0.0.0 --server.port 8501 ``` ## URL -- 참가자: `http://<홈서버-IP>:8501/` -- 진행자: `http://<홈서버-IP>:8501/?mode=admin&token=` +- 투표: `http://<서버>:8501/` +- 어드민: `http://<서버>:8501/?mode=admin&token=` +- 시상식: `http://<서버>:8501/?mode=ceremony&token=` -## 흐름 +## 데이터 파일 — `hackathon.json` -1. 참가자 — 이름 입력 → 본인 팀 선택 → 본인 팀 빼고 3분야 라디오 → 제출 -2. 중복 방지 — 같은 이름은 한 번만 투표 가능 (UNIQUE 제약) -3. 진행자 — 어드민 페이지에서 분야별 집계 확인 -4. 시상식 — 어드민 페이지 하단 "시상식 발표용" 박스 복사 (1위와 2위 차이만 표시, 하위 팀 표수 비공개) +```json +{ + "people": [ + {"name": "홍길동", "team": "팀1", "dept": "MLOps Data", "senior": true, "notes": ""}, + ... + ], + "settings": {"voting_open": true}, + "titles": {"팀1": "Slack 자동 분류기"}, + "tie_breaks": { + "utility_team": {"winner_team": "팀1", "method": "random", "decided_at": "..."} + }, + "votes": [ + {"voter_name": "...", "employee_id": "...", "voter_team": "...", "fun_team": "...", + "polish_team": "...", "utility_team": "...", "created_at": "..."} + ] +} +``` -## 데이터 +- 호스트에서 직접 편집 가능 (jq, vi 등). 앱이 매 요청 reload — 핫리로드. +- 단일 파일 read-write mount. atomic write (tmp + rename). +- 행사 전 명단 변경: `people[*].team` 값만 바꾸면 즉시 반영. +- `assign_teams.py` 재실행 시 `people`만 갱신. votes/titles/tie_breaks 보존. -- `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` 테이블 +## 운영 흐름 -## 운영 팁 +1. 투표 시작 (기본 open) +2. 모두 투표 → 어드민 "🛑 투표 마감" +3. 동률 있으면 어드민에서 추첨/선택 +4. "팀별 결과물 제목" 입력 (또는 발표 직후) +5. ceremony URL 띄움 → 시상 진행 -- 행사 끝나면 `votes.db` 백업 후 보관 또는 삭제 -- 부정 투표 의심 시 어드민 → 위험 작업 → 전체 삭제 후 재투표 진행 가능 -- ADMIN_TOKEN은 `change-me` 기본값 — 반드시 변경 +## 테스트 + +```bash +docker cp tests/e2e.py hackathon-vote:/tmp/e2e.py +docker exec hackathon-vote python3 /tmp/e2e.py +``` + +12개 시나리오 검증 (로드, 마감 토글, winner, priority, 동률, 추첨, UNIQUE, 제목, archive, atomic, clear). + +## 시상 매핑 + +| 상 | 상품 | 평가 | +|---|---|---| +| 🛠 실용성상 | 팜레스트 5개 (최고가) | 실제 쓸 만함 | +| 🏆 완성도상 | 양우산 5개 | 동작 / 시연 안정성 | +| 🎉 재미상 | 손선풍기 5개 | 발표장 임팩트 | + +수상 우선순위: 실용성 > 완성도 > 재미. 발표 순서: 재미 → 완성도 → 실용성 (긴장감). diff --git a/app.py b/app.py index 45ddaa2..2e1e7da 100644 --- a/app.py +++ b/app.py @@ -1,225 +1,180 @@ """ -해커톤 투표 앱 +해커톤 투표 앱 — DB 없이 단일 JSON. - 참가자: 이름 선택 → 본인 팀 자동 → 본인 팀 제외 3분야 투표 - 진행자: ?mode=admin&token=XXX 로 결과 확인 +- 시상: ?mode=ceremony&token=XXX """ +import csv +import io import json import os -import sqlite3 +import random as _rand +import threading from datetime import datetime from pathlib import Path import streamlit as st -DB_PATH = os.environ.get("VOTE_DB", "votes.db") +DATA_PATH = os.environ.get( + "DATA_PATH", str(Path(__file__).parent / "hackathon.json") +) ADMIN_TOKEN = os.environ.get("ADMIN_TOKEN", "change-me") -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_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": []} - - -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개"), ("polish_team", "🏆 완성도상", "양우산 5개"), ("utility_team", "🛠 실용성상", "팜레스트 5개"), ] - -# 수상 결정 우선순위 (높을수록 먼저 결정, 후순위 상에서 그 팀 제외) -# 팜레스트(실용성) > 양우산(완성도) > 손선풍기(재미) PRIZE_PRIORITY = ["utility_team", "polish_team", "fun_team"] +_lock = threading.RLock() -def get_conn(): - conn = sqlite3.connect(DB_PATH, timeout=10) - # WAL 모드: 동시 read/write 충돌 ↓ - conn.execute("PRAGMA journal_mode = WAL") - conn.execute("PRAGMA synchronous = NORMAL") - conn.execute( - """ - CREATE TABLE IF NOT EXISTS votes ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - voter_name TEXT NOT NULL UNIQUE, - employee_id TEXT, - voter_team TEXT NOT NULL, - fun_team TEXT NOT NULL, - polish_team TEXT NOT NULL, - utility_team TEXT NOT NULL, - created_at TEXT NOT NULL + +def _empty_state(): + return { + "people": [], + "settings": {"voting_open": True}, + "titles": {}, + "tie_breaks": {}, + "votes": [], + } + + +def load_data(): + """매 호출 디스크 read. 호스트 편집 즉시 반영.""" + with _lock: + p = Path(DATA_PATH) + if not p.exists(): + return _empty_state() + try: + data = json.loads(p.read_text(encoding="utf-8")) + except json.JSONDecodeError: + return _empty_state() + # 누락 키 채움 + base = _empty_state() + base.update(data) + for k, v in _empty_state().items(): + base.setdefault(k, v) + return base + + +def save_data(data): + """원자적 write — tmp 파일 + rename.""" + with _lock: + tmp = DATA_PATH + ".tmp" + Path(tmp).write_text( + json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8" ) - """ - ) - # 기존 DB 마이그레이션 - cols = [r[1] for r in conn.execute("PRAGMA table_info(votes)").fetchall()] - if "employee_id" not in cols: - conn.execute("ALTER TABLE votes ADD COLUMN employee_id TEXT") - conn.execute( - """ - CREATE TABLE IF NOT EXISTS tie_breaks ( - category TEXT PRIMARY KEY, - winner_team TEXT NOT NULL, - method TEXT NOT NULL, -- 'random' or 'manual' - decided_at TEXT NOT NULL - ) - """ - ) - conn.execute( - """ - CREATE TABLE IF NOT EXISTS settings ( - key TEXT PRIMARY KEY, - value TEXT NOT NULL - ) - """ - ) - conn.execute( - """ - CREATE TABLE IF NOT EXISTS team_titles ( - team_name TEXT PRIMARY KEY, - title TEXT NOT NULL DEFAULT '' - ) - """ - ) - conn.commit() - return conn + os.replace(tmp, DATA_PATH) + + +def update_data(fn): + """load → modify → save 원자적 묶음.""" + with _lock: + data = load_data() + result = fn(data) + save_data(data) + return result + + +def get_participants(): + return {p["name"]: p["team"] for p in load_data().get("people", [])} + + +def get_teams(): + parts = get_participants() + return sorted(set(parts.values())) if parts else [f"팀{i}" for i in range(1, 8)] def get_titles(): - """팀명 → 결과물 제목 dict. 매 호출 DB 조회 (라이브 반영).""" - conn = get_conn() - rows = conn.execute("SELECT team_name, title FROM team_titles").fetchall() - conn.close() - return dict(rows) + return load_data().get("titles", {}) def set_title(team, title): - conn = get_conn() - conn.execute( - "INSERT INTO team_titles (team_name, title) VALUES (?, ?) " - "ON CONFLICT(team_name) DO UPDATE SET title = excluded.title", - (team, title.strip()), - ) - conn.commit() - conn.close() + def _fn(data): + data["titles"][team] = title.strip() + update_data(_fn) + + +def is_voting_open(): + return load_data().get("settings", {}).get("voting_open", True) + + +def set_voting_open(flag): + def _fn(data): + data["settings"]["voting_open"] = bool(flag) + update_data(_fn) + + +def get_tie_breaks(): + return load_data().get("tie_breaks", {}) + + +def save_tie_break(category, winner, method): + def _fn(data): + data["tie_breaks"][category] = { + "winner_team": winner, + "method": method, + "decided_at": datetime.now().isoformat(timespec="seconds"), + } + update_data(_fn) + + +def clear_tie_break(category): + def _fn(data): + data["tie_breaks"].pop(category, None) + update_data(_fn) + + +def insert_vote(voter_name, employee_id, voter_team, picks): + """UNIQUE on voter_name. 이미 있으면 ValueError.""" + def _fn(data): + existing = {v["voter_name"] for v in data["votes"]} + if voter_name in existing: + raise ValueError("DUPLICATE_VOTER") + data["votes"].append( + { + "voter_name": voter_name, + "employee_id": employee_id, + "voter_team": voter_team, + "fun_team": picks["fun_team"], + "polish_team": picks["polish_team"], + "utility_team": picks["utility_team"], + "created_at": datetime.now().isoformat(timespec="seconds"), + } + ) + update_data(_fn) + + +def list_votes(): + return load_data().get("votes", []) + + +def clear_votes(): + def _fn(data): + data["votes"] = [] + update_data(_fn) def fmt_team(team, titles): - """팀 라벨 — 제목 있으면 'N팀 — 제목' 없으면 'N팀'.""" t = titles.get(team, "") return f"{team} — {t}" if t else team -def is_voting_open(): - conn = get_conn() - row = conn.execute( - "SELECT value FROM settings WHERE key = 'voting_open'" - ).fetchone() - conn.close() - return row is None or row[0] == "1" - - -def set_voting_open(flag): - conn = get_conn() - conn.execute( - "INSERT INTO settings (key, value) VALUES ('voting_open', ?) " - "ON CONFLICT(key) DO UPDATE SET value = excluded.value", - ("1" if flag else "0",), - ) - conn.commit() - conn.close() - - -def get_tie_breaks(): - conn = get_conn() - rows = conn.execute("SELECT category, winner_team FROM tie_breaks").fetchall() - conn.close() - return dict(rows) - - -def save_tie_break(category, winner, method): - conn = get_conn() - conn.execute( - "INSERT INTO tie_breaks (category, winner_team, method, decided_at) " - "VALUES (?, ?, ?, ?) " - "ON CONFLICT(category) DO UPDATE SET " - "winner_team = excluded.winner_team, " - "method = excluded.method, " - "decided_at = excluded.decided_at", - (category, winner, method, datetime.now().isoformat(timespec="seconds")), - ) - conn.commit() - conn.close() - - -def clear_tie_break(category): - conn = get_conn() - conn.execute("DELETE FROM tie_breaks WHERE category = ?", (category,)) - conn.commit() - conn.close() - - def compute_winners(): - """ - 우선순위 기반 1팀 1상 + 동률 처리. - 동률 발견 시 tie_breaks 저장 결정 사용. 미결정이면 status='tie' 반환. - return: (winners dict, rankings dict) - winners[col] = { - "status": "ok"|"tie"|"empty", - "team": str, # ok 시 - "votes": int, - "diff": int, # ok 시 2위와 차이 - "tied": [str], # tie 시 동률 후보들 - "method": str|None, # tie_break 적용 방식 - } - """ - conn = get_conn() + """우선순위 기반 1팀 1상 + 동률 처리.""" + data = load_data() + votes = data.get("votes", []) + tie_decisions = data.get("tie_breaks", {}) + rankings = {} for col, _, _ in CATEGORIES: - rankings[col] = conn.execute( - f"SELECT {col} AS team, COUNT(*) AS c FROM votes " - f"GROUP BY {col} ORDER BY c DESC, team ASC" - ).fetchall() - tie_decisions = dict( - conn.execute( - "SELECT category, winner_team || '||' || method FROM tie_breaks" - ).fetchall() - ) - conn.close() + counts = {} + for v in votes: + t = v.get(col) + if t: + counts[t] = counts.get(t, 0) + 1 + rankings[col] = sorted(counts.items(), key=lambda x: (-x[1], x[0])) winners = {} excluded = set() @@ -234,10 +189,10 @@ def compute_winners(): tied = [t for t, c in filtered if c == top_votes] if len(tied) > 1: - decision = tie_decisions.get(col, "") - chosen, _, method = decision.partition("||") - if chosen and chosen in tied: - winner = chosen + decision = tie_decisions.get(col) + if decision and decision.get("winner_team") in tied: + winner = decision["winner_team"] + method = decision.get("method", "") else: winners[col] = { "status": "tie", @@ -262,6 +217,42 @@ def compute_winners(): return winners, rankings +def archive_results(): + """결과 timestamped JSON. 모든 winners ok일 때만.""" + winners, _ = compute_winners() + if any(w.get("status") != "ok" for w in winners.values()): + return None + titles = get_titles() + data = { + "timestamp": datetime.now().isoformat(timespec="seconds"), + "results": [], + } + for col, label, prize in CATEGORIES: + w = winners.get(col) + if w and w.get("status") == "ok": + data["results"].append( + { + "category": label, + "prize": prize, + "team": w["team"], + "title": titles.get(w["team"], ""), + "votes": w["votes"], + "diff_2nd": w["diff"], + "method": w.get("method") or "majority", + } + ) + archive_dir = os.path.dirname(DATA_PATH) or "." + ts = datetime.now().strftime("%Y%m%d_%H%M%S") + path = os.path.join(archive_dir, f"results_{ts}.json") + Path(path).write_text( + json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8" + ) + return path + + +# --- UI --- + + def render_voter(): st.title("🗳 해커톤 투표") @@ -277,33 +268,25 @@ def render_voter(): - 본인 팀 자동 매핑 - 본인 팀 **제외**, 다른 6팀 중 각 분야별 1팀씩 투표 (총 3표) - **한 번만 제출 가능** (이름 unique) -- 사번은 사칭 의심 시 추적용으로 기록됨 (본인 이름이 아닌데 투표한 경우 사번 주인에게 확인) +- 사번은 사칭 의심 시 추적용으로 기록됨 **수상 결정 — 1팀 1상 한정** - 우선순위: 🛠 **실용성상 (팜레스트)** > 🏆 **완성도상 (양우산)** > 🎉 **재미상 (손선풍기)** -- 실용성 1위 팀이 팜레스트 수상 → 그 팀은 다른 상 후보에서 자동 제외 -- 완성도상도 같은 방식, 마지막 재미상까지 결정 -- → 한 팀이 모든 분야 1위여도 **가장 비싼 상 1개**만 받음 +- 상위상 수상 팀은 후순위 상에서 자동 제외 +- 한 팀이 모든 분야 1위여도 **가장 비싼 상 1개**만 받음 **시상 발표 순서** -- 🎉 재미상 → 🏆 완성도상 → 🛠 실용성상 (긴장감 build-up, 최고가 마무리) +- 🎉 재미상 → 🏆 완성도상 → 🛠 실용성상 (긴장감 build-up) """ ) - st.caption("이름 선택 → 본인 팀 자동 매핑 → 본인 팀 제외 3분야 투표. 한 번만 제출 가능.") - PARTS = get_participants() TEAMS = get_teams() if not PARTS: st.error("참가자 명단이 없습니다. `assign_teams.py` 먼저 실행하세요.") return - # 이미 투표한 사람 미리 조회 → selectbox에 ✅ 표시 - conn = get_conn() - voted_set = { - r[0] for r in conn.execute("SELECT voter_name FROM votes").fetchall() - } - conn.close() + voted_set = {v["voter_name"] for v in list_votes()} def fmt_name(n): return f"{n} ✅ (이미 투표함)" if n in voted_set else n @@ -356,32 +339,12 @@ def render_voter(): if any(picks.get(col) is None for col, _, _ in CATEGORIES): st.error("3분야 모두 선택하세요.") return - - conn = get_conn() try: - conn.execute( - """ - INSERT INTO votes - (voter_name, employee_id, voter_team, fun_team, polish_team, utility_team, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?) - """, - ( - name, - employee_id.strip(), - my_team, - picks["fun_team"], - picks["polish_team"], - picks["utility_team"], - datetime.now().isoformat(timespec="seconds"), - ), - ) - conn.commit() + insert_vote(name, employee_id.strip(), my_team, picks) st.success(f"✅ {name}님 ({my_team}) 투표 완료. 창 닫아도 됩니다.") st.balloons() - except sqlite3.IntegrityError: + except ValueError: st.error(f"❌ '{name}' 이미 투표함. 진행자에게 문의하세요.") - finally: - conn.close() def render_admin(): @@ -392,7 +355,6 @@ def render_admin(): st.title("🔐 진행자 콘솔") - # 투표 마감 토글 voting_open = is_voting_open() cur_label = "🟢 투표 진행 중" if voting_open else "🔴 투표 마감됨" st.markdown(f"### 투표 상태: {cur_label}") @@ -410,10 +372,10 @@ def render_admin(): PARTS = get_participants() TEAMS = get_teams() - conn = get_conn() - total = conn.execute("SELECT COUNT(*) FROM votes").fetchone()[0] - + votes = list_votes() + total = len(votes) 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)}팀") @@ -422,7 +384,7 @@ def render_admin(): 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()} + voted = {v["voter_name"] for v in votes} 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): @@ -430,7 +392,6 @@ def render_admin(): st.divider() st.subheader("📝 팀별 결과물 제목 입력") - st.caption("발표 직후 입력하면 투표 페이지에 즉시 반영됩니다.") titles = get_titles() with st.form("titles_form"): new_titles = {} @@ -460,7 +421,6 @@ def render_admin(): } public_lines = [] - # 동률 미해결 부문 먼저 표시 (진행자 액션 필요) pending_ties = [ (col, label, prize) for col, label, prize in CATEGORIES @@ -487,14 +447,13 @@ def render_admin(): if status == "tie": tied = result["tied"] - votes = result["votes"] + votes_n = result["votes"] tied_labels = ", ".join(fmt_team(t, titles) for t in tied) - st.warning(f"🟰 동률 ({votes}표): {tied_labels}") + st.warning(f"🟰 동률 ({votes_n}표): {tied_labels}") ca, cb = st.columns([1, 2]) with ca: if st.button("🎲 즉석 추첨", key=f"draw_{col}"): - import random as _r - chosen = _r.choice(tied) + chosen = _rand.choice(tied) save_tie_break(col, chosen, "random") st.rerun() with cb: @@ -511,7 +470,6 @@ def render_admin(): st.rerun() continue - # status == "ok" winner_team = result["team"] winner_votes = result["votes"] diff = result["diff"] @@ -527,7 +485,7 @@ def render_admin(): f"(2위와 {diff}표 차이)" ) if method: - if st.button(f"동률 결정 취소 (재추첨)", key=f"clear_{col}"): + if st.button("동률 결정 취소 (재추첨)", key=f"clear_{col}"): clear_tie_break(col) st.rerun() public_lines.append( @@ -548,22 +506,24 @@ def render_admin(): st.divider() with st.expander("🔍 감사 로그 (사칭 추적용)"): - st.caption("이름, 사번, 시각, 투표 내역. 의심 시 사번 주인에게 확인.") - rows = conn.execute( - "SELECT created_at, voter_name, employee_id, voter_team, " - "fun_team, polish_team, utility_team FROM votes ORDER BY created_at" - ).fetchall() - if not rows: + if not votes: st.caption("투표 없음") else: - import io - import csv buf = io.StringIO() w = csv.writer(buf) w.writerow(["시각", "이름", "사번", "본인팀", "재미", "완성도", "실용성"]) - for r in rows: - w.writerow(r) - # UTF-8 BOM → Excel 한글 호환 + for v in votes: + w.writerow( + [ + v["created_at"], + v["voter_name"], + v.get("employee_id") or "", + v["voter_team"], + v["fun_team"], + v["polish_team"], + v["utility_team"], + ] + ) csv_data = "" + buf.getvalue() st.download_button( "CSV 내려받기", @@ -573,24 +533,23 @@ def render_admin(): ) st.dataframe( { - "시각": [r[0] for r in rows], - "이름": [r[1] for r in rows], - "사번": [r[2] or "" for r in rows], - "본인팀": [r[3] for r in rows], - "재미": [r[4] for r in rows], - "완성도": [r[5] for r in rows], - "실용성": [r[6] for r in rows], + "시각": [v["created_at"] for v in votes], + "이름": [v["voter_name"] for v in votes], + "사번": [v.get("employee_id") or "" for v in votes], + "본인팀": [v["voter_team"] for v in votes], + "재미": [v["fun_team"] for v in votes], + "완성도": [v["polish_team"] for v in votes], + "실용성": [v["utility_team"] for v in votes], }, use_container_width=True, hide_index=True, ) - # 사번 중복 의심 (같은 사번이 여러 이름으로 투표) from collections import defaultdict by_emp = defaultdict(list) - for r in rows: - if r[2]: - by_emp[r[2]].append(r[1]) + for v in votes: + if v.get("employee_id"): + by_emp[v["employee_id"]].append(v["voter_name"]) dups = {emp: names for emp, names in by_emp.items() if len(set(names)) > 1} if dups: st.error("⚠️ 같은 사번이 여러 이름으로 투표한 케이스:") @@ -600,74 +559,29 @@ def render_admin(): st.divider() with st.expander("⚠️ 위험 작업"): if st.button("모든 투표 삭제 (되돌릴 수 없음)"): - conn.execute("DELETE FROM votes") - conn.commit() + clear_votes() st.warning("전체 삭제됨. 새로고침하세요.") - conn.close() - - -def archive_results(): - """결과를 timestamped JSON 파일로 저장 (DB 손실 보험).""" - winners, _ = compute_winners() - if any(w.get("status") != "ok" for w in winners.values()): - return None - titles = get_titles() - data = { - "timestamp": datetime.now().isoformat(timespec="seconds"), - "results": [], - } - for col, label, prize in CATEGORIES: - w = winners.get(col) - if w and w.get("status") == "ok": - data["results"].append( - { - "category": label, - "prize": prize, - "team": w["team"], - "title": titles.get(w["team"], ""), - "votes": w["votes"], - "diff_2nd": w["diff"], - "method": w.get("method") or "majority", - } - ) - archive_dir = os.path.dirname(DB_PATH) or "." - ts = datetime.now().strftime("%Y%m%d_%H%M%S") - path = os.path.join(archive_dir, f"results_{ts}.json") - Path(path).write_text( - json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8" - ) - return path - def render_ceremony(): - """시상식 reveal 페이지. 진행자가 클릭으로 단계별 공개.""" token = st.query_params.get("token", "") if token != ADMIN_TOKEN: st.error("권한 없음. ?mode=ceremony&token=... 형식 필요.") return titles = get_titles() - - # 투표 0건이면 ceremony 진입 불가 - conn = get_conn() - total_votes = conn.execute("SELECT COUNT(*) FROM votes").fetchone()[0] - conn.close() - if total_votes == 0: + votes = list_votes() + if not votes: st.warning("📊 아직 투표가 없습니다. 투표 종료 후 시상식을 시작하세요.") return - # 투표가 아직 열려 있으면 경고 (시상 도중 결과 변경 위험) if is_voting_open(): st.error( - "⚠️ 투표가 아직 진행 중입니다. 어드민에서 '투표 마감' 후 시상하세요. " - "(현재 결과가 시상 중 바뀔 수 있음)" + "⚠️ 투표가 아직 진행 중입니다. 어드민에서 '투표 마감' 후 시상하세요." ) return winners, _ = compute_winners() - - # 동률 미해결 있으면 ceremony 차단 (부문/후보 정보는 노출 X — 발표 spoiler 방지) pending_count = sum( 1 for col, _, _ in CATEGORIES if winners.get(col, {}).get("status") == "tie" @@ -676,13 +590,11 @@ def render_ceremony(): st.warning("⏳ 시상 준비 중입니다. 잠시만 기다려주세요.") return - # 진입 시 1회 archive (DB 손실 보험) if not st.session_state.get("ceremony_archived"): archived_path = archive_results() if archived_path: st.session_state.ceremony_archived = archived_path - # CATEGORIES 순서로 reveal (손선풍기 → 양우산 → 팜레스트) results = [] for col, label, prize in CATEGORIES: result = winners.get(col, {}) @@ -740,7 +652,7 @@ def render_ceremony(): st.rerun() else: - label, prize, winner, votes, diff = results[step - 1] + label, prize, winner, votes_n, diff = results[step - 1] st.markdown(f'
{label}
', unsafe_allow_html=True) st.markdown(f'
🎁 상품: {prize}
', unsafe_allow_html=True) @@ -757,7 +669,7 @@ def render_ceremony(): unsafe_allow_html=True, ) st.markdown( - f'
{votes}표 (2위와 {diff}표 차이)
', + f'
{votes_n}표 (2위와 {diff}표 차이)
', unsafe_allow_html=True, ) next_label = "다음 부문 →" if step < len(results) else "마무리 →" diff --git a/assign_teams.py b/assign_teams.py index b3e7d5a..cba18c9 100644 --- a/assign_teams.py +++ b/assign_teams.py @@ -240,7 +240,7 @@ def main(): mapping[name] = team_name print() - # roster.json (단일 파일, 호스트에서 직접 편집 가능) + # 단일 hackathon.json (모든 state 포함). 기존 파일 있으면 votes/titles 등 보존. people_records = [] for i, team in enumerate(teams, 1): team_name = f"팀{i}" @@ -260,20 +260,31 @@ def main(): } ) - 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}") + data_path = Path(__file__).parent / "hackathon.json" + if data_path.exists(): + data = json.loads(data_path.read_text(encoding="utf-8")) + else: + data = { + "settings": {"voting_open": True}, + "titles": {}, + "tie_breaks": {}, + "votes": [], + } + data["people"] = people_records + # 누락 키 보강 + for k, v in [ + ("settings", {"voting_open": True}), + ("titles", {}), + ("tie_breaks", {}), + ("votes", []), + ]: + data.setdefault(k, v) - # 구 형식 호환 (legacy participants.json 도 함께 저장 — 마이그레이션 기간) - out = Path(__file__).parent / "participants.json" - out.write_text( - json.dumps(mapping, ensure_ascii=False, indent=2), + data_path.write_text( + json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8", ) - print(f"저장: {out} (legacy)") + print(f"저장: {data_path}") # teams.md 박제 (진행자 인쇄/공유용) md_lines = ["# 해커톤 팀 배정 (확정)\n"] diff --git a/docker-compose.yml b/docker-compose.yml index 29aecb5..8147f02 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,17 +7,9 @@ services: - "${PORT:-8501}:8501" environment: ADMIN_TOKEN: ${ADMIN_TOKEN:-change-me} - VOTE_DB: /data/votes.db - ROSTER: /app/roster.json - PARTICIPANTS: /app/participants.json + DATA_PATH: /app/hackathon.json volumes: - # roster.json 호스트 편집 즉시 반영 (앱이 매 요청 reload) - - ./roster.json:/app/roster.json:ro - # legacy participants.json fallback - - ./participants.json:/app/participants.json:ro - # 투표 DB 영속 - - vote-data:/data + # 단일 데이터 파일. 호스트 ↔ 컨테이너 read-write mount. + # 호스트에서 jq/vi 편집 가능, 앱이 votes 추가 시 그대로 반영. + - ./hackathon.json:/app/hackathon.json restart: unless-stopped - -volumes: - vote-data: diff --git a/roster.json b/hackathon.json similarity index 97% rename from roster.json rename to hackathon.json index 95eda74..92189b8 100644 --- a/roster.json +++ b/hackathon.json @@ -1,4 +1,10 @@ { + "settings": { + "voting_open": true + }, + "titles": {}, + "tie_breaks": {}, + "votes": [], "people": [ { "name": "김호승", diff --git a/participants.json b/participants.json deleted file mode 100644 index 25324df..0000000 --- a/participants.json +++ /dev/null @@ -1,36 +0,0 @@ -{ - "김호승": "팀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" -} \ No newline at end of file diff --git a/tests/e2e.py b/tests/e2e.py index 9e47cc1..cf166e6 100644 --- a/tests/e2e.py +++ b/tests/e2e.py @@ -1,21 +1,26 @@ """ -E2E 테스트 — 컨테이너에서 직접 실행. -사용: docker exec hackathon-vote python3 /tmp/e2e.py +E2E 테스트 — JSON 단일 파일 기반. +실행: docker exec hackathon-vote python3 /tmp/e2e.py """ import os import sys import tempfile +import shutil -# 테스트용 격리 DB -TEST_DB = tempfile.mktemp(suffix=".db") -os.environ["VOTE_DB"] = TEST_DB +# 격리 데이터 파일 (실제 hackathon.json과 분리) +TEST_DATA = tempfile.mktemp(suffix=".json") +shutil.copy("/app/hackathon.json", TEST_DATA) +os.environ["DATA_PATH"] = TEST_DATA os.environ["ADMIN_TOKEN"] = "test" sys.path.insert(0, "/app") -# import after env set from app import ( # noqa: E402 - get_conn, + load_data, + save_data, + insert_vote, + list_votes, + clear_votes, get_participants, get_teams, compute_winners, @@ -29,37 +34,24 @@ from app import ( # noqa: E402 ) -# ---- helpers ---- - -def reset_db(): - """테스트 DB 초기화.""" - if os.path.exists(TEST_DB): - os.unlink(TEST_DB) - conn = get_conn() - conn.close() +def reset_votes(): + """투표만 초기화 (people, settings 보존).""" + data = load_data() + data["votes"] = [] + data["tie_breaks"] = {} + data["titles"] = {} + data["settings"]["voting_open"] = True + save_data(data) -def insert_vote(name, emp_id, team, fun, polish, utility): - conn = get_conn() - conn.execute( - "INSERT INTO votes (voter_name, employee_id, voter_team, " - "fun_team, polish_team, utility_team, created_at) " - "VALUES (?, ?, ?, ?, ?, ?, datetime('now'))", - (name, emp_id, team, fun, polish, utility), - ) - conn.commit() - conn.close() +def add_vote(name, emp, team, fun, polish, utility): + insert_vote(name, emp, team, { + "fun_team": fun, + "polish_team": polish, + "utility_team": utility, + }) -def vote_count(): - conn = get_conn() - n = conn.execute("SELECT COUNT(*) FROM votes").fetchone()[0] - conn.close() - return n - - -# ---- tests ---- - PASS = "✅" FAIL = "❌" results = [] @@ -78,30 +70,28 @@ def test(name, fn): print(f"{FAIL} {name}: UNEXPECTED {type(e).__name__}: {e}") -def t_roster_load(): +def t_load(): parts = get_participants() teams = get_teams() assert len(parts) == 34, f"기대 34명, 실제 {len(parts)}" - assert len(teams) == 7, f"기대 7팀, 실제 {len(teams)}" - assert "한지승" in parts, "한지승 명단에 없음" - assert "김태현" not in parts, "김태현 진행요원이 명단에 있음" + assert len(teams) == 7 + assert "한지승" in parts + assert "김태현" not in parts def t_voting_toggle(): - reset_db() - assert is_voting_open() is True, "기본 open" + reset_votes() + assert is_voting_open() is True set_voting_open(False) - assert is_voting_open() is False, "마감 적용 안 됨" + assert is_voting_open() is False set_voting_open(True) - assert is_voting_open() is True, "재개 적용 안 됨" + assert is_voting_open() is True -def t_simple_winners_no_priority_collision(): - """팀1=fun, 팀2=polish, 팀3=utility 각각 명확한 1위. priority 충돌 없음.""" - reset_db() - # 팀1에 5표 (fun), 팀2에 5표 (polish), 팀3에 5표 (utility) +def t_simple_winners(): + reset_votes() for i in range(5): - insert_vote(f"V{i}", f"E{i}", "팀7", "팀1", "팀2", "팀3") + add_vote(f"V{i}", f"E{i}", "팀7", "팀1", "팀2", "팀3") winners, _ = compute_winners() assert winners["fun_team"]["status"] == "ok" assert winners["fun_team"]["team"] == "팀1" @@ -110,44 +100,36 @@ def t_simple_winners_no_priority_collision(): def t_priority_one_team_all_first(): - """팀1이 모든 분야 1위 → 팀1은 utility만, polish/fun은 다음 팀.""" - reset_db() - # 팀1에 모든 분야 5표, 팀2에 polish/fun 3표, 팀3에 fun 2표 + reset_votes() for i in range(5): - insert_vote(f"V{i}", f"E{i}", "팀7", "팀1", "팀1", "팀1") + add_vote(f"V{i}", f"E{i}", "팀7", "팀1", "팀1", "팀1") for i in range(5, 8): - insert_vote(f"V{i}", f"E{i}", "팀7", "팀2", "팀2", "팀1") + add_vote(f"V{i}", f"E{i}", "팀7", "팀2", "팀2", "팀1") for i in range(8, 10): - insert_vote(f"V{i}", f"E{i}", "팀7", "팀3", "팀3", "팀2") + add_vote(f"V{i}", f"E{i}", "팀7", "팀3", "팀3", "팀2") winners, _ = compute_winners() - # 팀1이 utility 1위 → 팀1 수상 - assert winners["utility_team"]["team"] == "팀1", winners["utility_team"] - # 팀1 제외 → polish 1위 = 팀2 (3+5표=8 wait, 팀1=5, 팀2=3+...) 다시: - # polish 분포: 팀1=5, 팀2=3, 팀3=2 → 팀1 제외 후 팀2 - assert winners["polish_team"]["team"] == "팀2", winners["polish_team"] - # fun: 팀1=5, 팀2=3, 팀3=2 → 팀1, 팀2 제외 후 팀3 - assert winners["fun_team"]["team"] == "팀3", winners["fun_team"] + assert winners["utility_team"]["team"] == "팀1" + assert winners["polish_team"]["team"] == "팀2" + assert winners["fun_team"]["team"] == "팀3" def t_tie_pending(): - """팀1, 팀2 utility 동률 → status='tie'.""" - reset_db() + reset_votes() for i in range(3): - insert_vote(f"V{i}", f"E{i}", "팀7", "팀5", "팀6", "팀1") + add_vote(f"V{i}", f"E{i}", "팀7", "팀5", "팀6", "팀1") for i in range(3, 6): - insert_vote(f"V{i}", f"E{i}", "팀7", "팀5", "팀6", "팀2") + add_vote(f"V{i}", f"E{i}", "팀7", "팀5", "팀6", "팀2") winners, _ = compute_winners() - assert winners["utility_team"]["status"] == "tie", winners["utility_team"] + assert winners["utility_team"]["status"] == "tie" assert set(winners["utility_team"]["tied"]) == {"팀1", "팀2"} def t_tie_break_random(): - """동률 → save_tie_break으로 결정 → status='ok'.""" - reset_db() + reset_votes() for i in range(3): - insert_vote(f"V{i}", f"E{i}", "팀7", "팀5", "팀6", "팀1") + add_vote(f"V{i}", f"E{i}", "팀7", "팀5", "팀6", "팀1") for i in range(3, 6): - insert_vote(f"V{i}", f"E{i}", "팀7", "팀5", "팀6", "팀2") + add_vote(f"V{i}", f"E{i}", "팀7", "팀5", "팀6", "팀2") save_tie_break("utility_team", "팀1", "random") winners, _ = compute_winners() assert winners["utility_team"]["status"] == "ok" @@ -157,67 +139,81 @@ def t_tie_break_random(): def t_unique_voter(): - """같은 voter_name 두 번 INSERT 차단.""" - reset_db() - insert_vote("홍길동", "E1", "팀1", "팀2", "팀3", "팀4") + reset_votes() + add_vote("홍길동", "E1", "팀1", "팀2", "팀3", "팀4") try: - insert_vote("홍길동", "E2", "팀1", "팀5", "팀6", "팀7") - raise AssertionError("중복 INSERT 통과 (UNIQUE 위반 예상)") - except Exception as e: - # IntegrityError 기대 - assert "UNIQUE" in str(e) or "unique" in str(e).lower() + add_vote("홍길동", "E2", "팀1", "팀5", "팀6", "팀7") + raise AssertionError("중복 INSERT 통과") + except ValueError as e: + assert str(e) == "DUPLICATE_VOTER" def t_titles_persist(): - reset_db() + reset_votes() set_title("팀1", "Slack 자동 분류기") titles = get_titles() assert titles["팀1"] == "Slack 자동 분류기" def t_archive_writes_file(): - """ceremony archive — 모든 winners ok 시 파일 생성.""" - reset_db() - # 명확한 1위 만들기 + reset_votes() for i in range(5): - insert_vote(f"V{i}", f"E{i}", "팀7", "팀1", "팀2", "팀3") - archive_dir = os.path.dirname(TEST_DB) or "." + add_vote(f"V{i}", f"E{i}", "팀7", "팀1", "팀2", "팀3") + archive_dir = os.path.dirname(TEST_DATA) or "." before = set(f for f in os.listdir(archive_dir) if f.startswith("results_")) path = archive_results() - assert path is not None, "archive 결과 None" + assert path is not None, "archive None 반환" assert os.path.exists(path) after = set(f for f in os.listdir(archive_dir) if f.startswith("results_")) - assert len(after) > len(before), "archive 파일 생성 안 됨" - # 정리 + assert len(after) > len(before) os.unlink(path) -def t_archive_skips_when_pending(): - """동률 미해결 시 archive 안 함.""" - reset_db() +def t_archive_skip_pending(): + reset_votes() for i in range(3): - insert_vote(f"V{i}", f"E{i}", "팀7", "팀5", "팀6", "팀1") + add_vote(f"V{i}", f"E{i}", "팀7", "팀5", "팀6", "팀1") for i in range(3, 6): - insert_vote(f"V{i}", f"E{i}", "팀7", "팀5", "팀6", "팀2") + add_vote(f"V{i}", f"E{i}", "팀7", "팀5", "팀6", "팀2") path = archive_results() - assert path is None, f"동률 미해결인데 archive 됨: {path}" + assert path is None -# ---- run ---- +def t_atomic_write(): + """save_data 후 load_data가 동일 데이터 반환.""" + reset_votes() + data = load_data() + data["titles"]["팀1"] = "test atomic" + save_data(data) + fresh = load_data() + assert fresh["titles"]["팀1"] == "test atomic" + + +def t_vote_count(): + reset_votes() + assert len(list_votes()) == 0 + add_vote("A", "1", "팀1", "팀2", "팀3", "팀4") + assert len(list_votes()) == 1 + clear_votes() + assert len(list_votes()) == 0 + if __name__ == "__main__": - print(f"# E2E 테스트 (DB={TEST_DB})\n") - test("roster.json 로드 (34명, 7팀)", t_roster_load) + print(f"# E2E (data={TEST_DATA})\n") + test("hackathon.json 로드 (34명, 7팀)", t_load) test("투표 마감 토글", t_voting_toggle) - test("기본 winner (priority 충돌 없음)", t_simple_winners_no_priority_collision) + test("단순 winner", t_simple_winners) test("priority 1팀 모든 분야 1위", t_priority_one_team_all_first) test("동률 status='tie'", t_tie_pending) - test("tie_break 적용 시 status='ok'", t_tie_break_random) + test("tie_break 적용", t_tie_break_random) test("voter_name UNIQUE 강제", t_unique_voter) test("팀 제목 영속", t_titles_persist) test("archive 파일 생성", t_archive_writes_file) - test("archive 동률 미해결 시 skip", t_archive_skips_when_pending) + test("archive 동률 skip", t_archive_skip_pending) + test("atomic write", t_atomic_write) + test("clear_votes", t_vote_count) fails = sum(1 for r, _ in results if r == FAIL) - print(f"\n# 합계: {len(results)} 중 통과 {len(results) - fails}, 실패 {fails}") + print(f"\n# {len(results)} 중 통과 {len(results) - fails}, 실패 {fails}") + os.unlink(TEST_DATA) sys.exit(0 if fails == 0 else 1)