From 638f0b36c84f2deb60a0281b2038ea73e17db8e3 Mon Sep 17 00:00:00 2001 From: th-kim0823 Date: Sat, 25 Apr 2026 20:19:12 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=97=A3=EC=A7=80=20=EC=BC=80=EC=9D=B4?= =?UTF-8?q?=EC=8A=A4=205=EA=B0=9C=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 투표 마감 락 (settings 테이블 + 어드민 토글) - 시상 도중 결과 바뀜 방지 - ceremony 진입 시 voting_open이면 경고 + 차단 2. 빈 결과 ceremony 차단 - 투표 0건이면 진입 불가 3. 타임존 KST (Dockerfile tzdata + TZ=Asia/Seoul) - 감사 로그 시각 정확 4. CSV UTF-8 BOM - Excel에서 한글 정상 표시 5. 사번 입력 안내 강화 - placeholder + help: 민감정보 입력 금지 Co-Authored-By: Claude Opus 4.7 (1M context) --- Dockerfile | 6 +++++ app.py | 77 ++++++++++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 81 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index 7e765d1..ab50db4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,6 +2,12 @@ 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/* +ENV TZ=Asia/Seoul + COPY requirements.txt ./ RUN pip install --no-cache-dir -r requirements.txt diff --git a/app.py b/app.py index c39a9cf..bf4f3c8 100644 --- a/app.py +++ b/app.py @@ -72,6 +72,14 @@ def get_conn(): ) """ ) + 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 ( @@ -109,6 +117,26 @@ def fmt_team(team, titles): 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() @@ -210,6 +238,10 @@ def compute_winners(): def render_voter(): st.title("🗳 해커톤 투표") + if not is_voting_open(): + st.error("⏹️ 투표가 마감되었습니다. 시상식을 기다려주세요.") + return + with st.expander("📖 투표 방식 / 수상 결정 (꼭 읽어주세요)", expanded=True): st.markdown( """ @@ -245,7 +277,8 @@ def render_voter(): ) employee_id = st.text_input( "사번", - placeholder="본인 사번 입력 (사칭 추적용으로 기록됨)", + placeholder="본인 사번만 (전화번호/주민번호 입력 금지)", + help="사칭 추적용으로 기록됨. 본인 사번 외 입력하지 마세요.", ) if not name: @@ -313,6 +346,22 @@ def render_admin(): st.title("🔐 진행자 콘솔") + # 투표 마감 토글 + voting_open = is_voting_open() + cur_label = "🟢 투표 진행 중" if voting_open else "🔴 투표 마감됨" + st.markdown(f"### 투표 상태: {cur_label}") + cc1, cc2 = st.columns(2) + with cc1: + if voting_open and st.button("🛑 투표 마감 (시상 시작 전 필수)", type="primary"): + set_voting_open(False) + st.rerun() + with cc2: + if not voting_open and st.button("🔓 투표 재개"): + set_voting_open(True) + st.rerun() + + st.divider() + conn = get_conn() total = conn.execute("SELECT COUNT(*) FROM votes").fetchone()[0] @@ -466,7 +515,14 @@ def render_admin(): w.writerow(["시각", "이름", "사번", "본인팀", "재미", "완성도", "실용성"]) for r in rows: w.writerow(r) - st.download_button("CSV 내려받기", buf.getvalue(), file_name="audit.csv") + # UTF-8 BOM → Excel 한글 호환 + csv_data = "" + buf.getvalue() + st.download_button( + "CSV 내려받기", + csv_data, + file_name="audit.csv", + mime="text/csv; charset=utf-8", + ) st.dataframe( { "시각": [r[0] for r in rows], @@ -511,6 +567,23 @@ def render_ceremony(): 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: + st.warning("📊 아직 투표가 없습니다. 투표 종료 후 시상식을 시작하세요.") + return + + # 투표가 아직 열려 있으면 경고 (시상 도중 결과 변경 위험) + if is_voting_open(): + st.error( + "⚠️ 투표가 아직 진행 중입니다. 어드민에서 '투표 마감' 후 시상하세요. " + "(현재 결과가 시상 중 바뀔 수 있음)" + ) + return + winners, _ = compute_winners() # 동률 미해결 있으면 ceremony 차단 (부문/후보 정보는 노출 X — 발표 spoiler 방지)