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:
@@ -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
77
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(
|
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 방지)
|
||||||
|
|||||||
Reference in New Issue
Block a user