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 방지)