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:
65
app.py
65
app.py
@@ -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("모든 투표 삭제 (되돌릴 수 없음)"):
|
||||||
|
|||||||
Reference in New Issue
Block a user