feat: 엣지 케이스 5개 처리

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) <noreply@anthropic.com>
This commit is contained in:
th-kim0823
2026-04-25 20:19:12 +09:00
parent 5189d27261
commit 638f0b36c8
2 changed files with 81 additions and 2 deletions

View File

@@ -2,6 +2,12 @@ FROM python:3.12-slim
WORKDIR /app 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 ./ COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt RUN pip install --no-cache-dir -r requirements.txt

77
app.py
View File

@@ -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( conn.execute(
""" """
CREATE TABLE IF NOT EXISTS team_titles ( CREATE TABLE IF NOT EXISTS team_titles (
@@ -109,6 +117,26 @@ def fmt_team(team, titles):
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(): def get_tie_breaks():
conn = get_conn() conn = get_conn()
rows = conn.execute("SELECT category, winner_team FROM tie_breaks").fetchall() rows = conn.execute("SELECT category, winner_team FROM tie_breaks").fetchall()
@@ -210,6 +238,10 @@ def compute_winners():
def render_voter(): def render_voter():
st.title("🗳 해커톤 투표") st.title("🗳 해커톤 투표")
if not is_voting_open():
st.error("⏹️ 투표가 마감되었습니다. 시상식을 기다려주세요.")
return
with st.expander("📖 투표 방식 / 수상 결정 (꼭 읽어주세요)", expanded=True): with st.expander("📖 투표 방식 / 수상 결정 (꼭 읽어주세요)", expanded=True):
st.markdown( st.markdown(
""" """
@@ -245,7 +277,8 @@ def render_voter():
) )
employee_id = st.text_input( employee_id = st.text_input(
"사번", "사번",
placeholder="본인 사번 입력 (사칭 추적용으로 기록됨)", placeholder="본인 사번만 (전화번호/주민번호 입력 금지)",
help="사칭 추적용으로 기록됨. 본인 사번 외 입력하지 마세요.",
) )
if not name: if not name:
@@ -313,6 +346,22 @@ def render_admin():
st.title("🔐 진행자 콘솔") 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() conn = get_conn()
total = conn.execute("SELECT COUNT(*) FROM votes").fetchone()[0] total = conn.execute("SELECT COUNT(*) FROM votes").fetchone()[0]
@@ -466,7 +515,14 @@ def render_admin():
w.writerow(["시각", "이름", "사번", "본인팀", "재미", "완성도", "실용성"]) w.writerow(["시각", "이름", "사번", "본인팀", "재미", "완성도", "실용성"])
for r in rows: for r in rows:
w.writerow(r) 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( st.dataframe(
{ {
"시각": [r[0] for r in rows], "시각": [r[0] for r in rows],
@@ -511,6 +567,23 @@ def render_ceremony():
return return
titles = get_titles() 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() winners, _ = compute_winners()
# 동률 미해결 있으면 ceremony 차단 (부문/후보 정보는 노출 X — 발표 spoiler 방지) # 동률 미해결 있으면 ceremony 차단 (부문/후보 정보는 노출 X — 발표 spoiler 방지)