feat: 사번 입력 + 감사 로그 (사칭 추적)

- 투표 폼에 사번 입력 필수 추가
- DB votes 테이블에 employee_id 컬럼 (마이그레이션 자동)
- 어드민 감사 로그 expander: 시각/이름/사번/본인팀/투표내역 표
- CSV 내려받기 버튼
- 같은 사번이 여러 이름으로 투표 시 자동 의심 마크
- 안내 expander에 사번 입력/추적 설명 추가

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
th-kim0823
2026-04-25 20:07:02 +09:00
parent 602e4779fe
commit aac609eb59

65
app.py
View File

@@ -49,6 +49,7 @@ def get_conn():
CREATE TABLE IF NOT EXISTS votes ( CREATE TABLE IF NOT EXISTS votes (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
voter_name TEXT NOT NULL UNIQUE, voter_name TEXT NOT NULL UNIQUE,
employee_id TEXT,
voter_team TEXT NOT NULL, voter_team TEXT NOT NULL,
fun_team TEXT NOT NULL, fun_team TEXT NOT NULL,
polish_team TEXT NOT NULL, polish_team TEXT NOT NULL,
@@ -57,6 +58,10 @@ def get_conn():
) )
""" """
) )
# 기존 DB 마이그레이션
cols = [r[1] for r in conn.execute("PRAGMA table_info(votes)").fetchall()]
if "employee_id" not in cols:
conn.execute("ALTER TABLE votes ADD COLUMN employee_id TEXT")
conn.execute( conn.execute(
""" """
CREATE TABLE IF NOT EXISTS team_titles ( CREATE TABLE IF NOT EXISTS team_titles (
@@ -131,9 +136,11 @@ def render_voter():
st.markdown( st.markdown(
""" """
**투표 방식** **투표 방식**
- 본인 이름 선택 → 본인 팀 자동 매핑 - 본인 이름 선택 + **본인 사번 입력**
- 본인 팀 자동 매핑
- 본인 팀 **제외**, 다른 6팀 중 각 분야별 1팀씩 투표 (총 3표) - 본인 팀 **제외**, 다른 6팀 중 각 분야별 1팀씩 투표 (총 3표)
- **한 번만 제출 가능** (이름 unique) - **한 번만 제출 가능** (이름 unique)
- 사번은 사칭 의심 시 추적용으로 기록됨 (본인 이름이 아닌데 투표한 경우 사번 주인에게 확인)
**수상 결정 — 1팀 1상 한정** **수상 결정 — 1팀 1상 한정**
- 우선순위: 🛠 **실용성상 (팜레스트)** > 🏆 **완성도상 (양우산)** > 🎉 **재미상 (손선풍기)** - 우선순위: 🛠 **실용성상 (팜레스트)** > 🏆 **완성도상 (양우산)** > 🎉 **재미상 (손선풍기)**
@@ -158,10 +165,17 @@ def render_voter():
index=None, index=None,
placeholder="이름 선택", placeholder="이름 선택",
) )
employee_id = st.text_input(
"사번",
placeholder="본인 사번 입력 (사칭 추적용으로 기록됨)",
)
if not name: if not name:
st.info("이름을 선택하면 본인 팀이 자동 표시됩니다.") st.info("이름을 선택하면 본인 팀이 자동 표시됩니다.")
return return
if not employee_id.strip():
st.warning("사번 입력 후 진행하세요.")
return
my_team = PARTICIPANTS[name] my_team = PARTICIPANTS[name]
titles = get_titles() titles = get_titles()
@@ -191,11 +205,12 @@ def render_voter():
conn.execute( conn.execute(
""" """
INSERT INTO votes INSERT INTO votes
(voter_name, voter_team, fun_team, polish_team, utility_team, created_at) (voter_name, employee_id, voter_team, fun_team, polish_team, utility_team, created_at)
VALUES (?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?)
""", """,
( (
name, name,
employee_id.strip(),
my_team, my_team,
picks["fun_team"], picks["fun_team"],
picks["polish_team"], picks["polish_team"],
@@ -300,6 +315,50 @@ def render_admin():
st.subheader("🎤 시상식 발표용 (복사해서 화면 공유)") st.subheader("🎤 시상식 발표용 (복사해서 화면 공유)")
st.code("\n".join(public_lines), language="markdown") st.code("\n".join(public_lines), language="markdown")
st.divider()
with st.expander("🔍 감사 로그 (사칭 추적용)"):
st.caption("이름, 사번, 시각, 투표 내역. 의심 시 사번 주인에게 확인.")
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("투표 없음")
else:
import io
import csv
buf = io.StringIO()
w = csv.writer(buf)
w.writerow(["시각", "이름", "사번", "본인팀", "재미", "완성도", "실용성"])
for r in rows:
w.writerow(r)
st.download_button("CSV 내려받기", buf.getvalue(), file_name="audit.csv")
st.dataframe(
{
"시각": [r[0] for r in rows],
"이름": [r[1] for r in rows],
"사번": [r[2] or "" for r in rows],
"본인팀": [r[3] for r in rows],
"재미": [r[4] for r in rows],
"완성도": [r[5] for r in rows],
"실용성": [r[6] for r in rows],
},
use_container_width=True,
hide_index=True,
)
# 사번 중복 의심 (같은 사번이 여러 이름으로 투표)
from collections import defaultdict
by_emp = defaultdict(list)
for r in rows:
if r[2]:
by_emp[r[2]].append(r[1])
dups = {emp: names for emp, names in by_emp.items() if len(set(names)) > 1}
if dups:
st.error("⚠️ 같은 사번이 여러 이름으로 투표한 케이스:")
for emp, names in dups.items():
st.write(f"- 사번 `{emp}`: {', '.join(names)}")
st.divider() st.divider()
with st.expander("⚠️ 위험 작업"): with st.expander("⚠️ 위험 작업"):
if st.button("모든 투표 삭제 (되돌릴 수 없음)"): if st.button("모든 투표 삭제 (되돌릴 수 없음)"):