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