Initial dashboard organization setup

This commit is contained in:
hyunho
2026-03-25 10:26:33 +09:00
commit d9023abed6
22 changed files with 5340 additions and 0 deletions

1
backend/app/__init__.py Executable file
View File

@@ -0,0 +1 @@

14
backend/app/config.py Executable file
View File

@@ -0,0 +1,14 @@
from pathlib import Path
import os
BASE_DIR = Path("/app")
LEGACY_DIR = BASE_DIR / "legacy"
UPLOAD_DIR = Path(os.getenv("UPLOAD_DIR", "/data/uploads"))
SNAPSHOT_DIR = Path(os.getenv("SNAPSHOT_DIR", "/data/snapshots"))
DATABASE_URL = os.getenv(
"DATABASE_URL",
"postgresql://orgapp:change-me@db:5432/orgdb",
)
MOCK_LOGIN_ENABLED = os.getenv("MOCK_LOGIN_ENABLED", "true").lower() == "true"

75
backend/app/db.py Executable file
View File

@@ -0,0 +1,75 @@
from contextlib import contextmanager
import time
from typing import Iterator
from psycopg.rows import dict_row
import psycopg
from .config import DATABASE_URL
SCHEMA_SQL = """
CREATE TABLE IF NOT EXISTS members (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
company TEXT,
rank TEXT,
role TEXT,
department TEXT,
grp TEXT,
division TEXT,
team TEXT,
cell TEXT,
work_status TEXT,
work_time TEXT,
phone TEXT,
email TEXT,
seat_label TEXT,
photo_url TEXT,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS seat_positions (
member_id INTEGER PRIMARY KEY REFERENCES members(id) ON DELETE CASCADE,
x INTEGER NOT NULL DEFAULT 0,
y INTEGER NOT NULL DEFAULT 0,
floor_label TEXT,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS snapshots (
id SERIAL PRIMARY KEY,
snapshot_month TEXT NOT NULL,
file_path TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
"""
MIGRATION_SQL = """
ALTER TABLE members ADD COLUMN IF NOT EXISTS sort_order INTEGER NOT NULL DEFAULT 0;
"""
@contextmanager
def get_conn() -> Iterator[psycopg.Connection]:
with psycopg.connect(DATABASE_URL, row_factory=dict_row) as conn:
yield conn
def init_db(max_retries: int = 20, retry_delay: float = 2.0) -> None:
last_error: Exception | None = None
for _ in range(max_retries):
try:
with get_conn() as conn:
with conn.cursor() as cur:
cur.execute(SCHEMA_SQL)
cur.execute(MIGRATION_SQL)
conn.commit()
return
except psycopg.OperationalError as exc:
last_error = exc
time.sleep(retry_delay)
if last_error is not None:
raise last_error

404
backend/app/main.py Executable file
View File

@@ -0,0 +1,404 @@
from __future__ import annotations
from datetime import datetime
from pathlib import Path
import csv
from io import BytesIO, StringIO
import shutil
import uuid
from fastapi import FastAPI, File, Form, HTTPException, UploadFile
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
from openpyxl import load_workbook
from pydantic import BaseModel, Field
from .config import LEGACY_DIR, MOCK_LOGIN_ENABLED, SNAPSHOT_DIR, UPLOAD_DIR
from .db import get_conn, init_db
app = FastAPI(title="MH Dashboard Organization API")
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
LEGACY_STATIC_DIR = LEGACY_DIR / "static"
class MemberPayload(BaseModel):
id: int | None = None
name: str = Field(min_length=1)
company: str = ""
rank: str = ""
role: str = ""
department: str = ""
grp: str = ""
division: str = ""
team: str = ""
cell: str = ""
work_status: str = ""
work_time: str = ""
phone: str = ""
email: str = ""
seat_label: str = ""
photo_url: str = ""
sort_order: int | None = None
class MemberBulkPayload(BaseModel):
items: list[MemberPayload]
LEGACY_HEADER_MAP = {
"이름": "name",
"소속회사": "company",
"직급": "rank",
"직책": "role",
"부서": "department",
"그룹": "grp",
"디비전": "division",
"": "team",
"": "cell",
"근무상태": "work_status",
"근무시간": "work_time",
"전화번호": "phone",
"이메일": "email",
"자리위치": "seat_label",
"사진": "photo_url",
}
def serialize_member_payload(item: MemberPayload, sort_order: int) -> tuple[object, ...]:
return (
item.name.strip(),
item.company.strip(),
item.rank.strip(),
item.role.strip(),
item.department.strip(),
item.grp.strip(),
item.division.strip(),
item.team.strip(),
item.cell.strip(),
item.work_status.strip(),
item.work_time.strip(),
item.phone.strip(),
item.email.strip(),
item.seat_label.strip(),
item.photo_url.strip(),
sort_order,
)
def fetch_members() -> list[dict[str, object]]:
with get_conn() as conn:
with conn.cursor() as cur:
cur.execute(
"""
SELECT id, name, company, rank, role, department, grp, division, team, cell,
work_status, work_time, phone, email, seat_label, photo_url,
sort_order, created_at, updated_at
FROM members
ORDER BY sort_order ASC, id ASC
"""
)
return cur.fetchall()
def replace_members(items: list[MemberPayload]) -> list[dict[str, object]]:
with get_conn() as conn:
with conn.cursor() as cur:
cur.execute("TRUNCATE TABLE members RESTART IDENTITY CASCADE")
for index, item in enumerate(items):
cur.execute(
"""
INSERT INTO members (
name, company, rank, role, department, grp, division, team, cell,
work_status, work_time, phone, email, seat_label, photo_url, sort_order
)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
""",
serialize_member_payload(item, index),
)
conn.commit()
return fetch_members()
def rows_to_member_payloads(rows: list[list[object]]) -> list[MemberPayload]:
header_idx = next(
(
idx
for idx, row in enumerate(rows)
if "이름" in row and "부서" in row
),
-1,
)
if header_idx < 0:
raise HTTPException(status_code=400, detail="지원하지 않는 파일 형식입니다. '이름''부서' 헤더를 찾지 못했습니다.")
headers = [str(value).strip() for value in rows[header_idx]]
payloads: list[MemberPayload] = []
for row in rows[header_idx + 1 :]:
if not any(str(value or "").strip() for value in row):
continue
record: dict[str, object] = {}
for col_idx, header in enumerate(headers):
mapped = LEGACY_HEADER_MAP.get(header)
if not mapped:
continue
record[mapped] = str(row[col_idx] if col_idx < len(row) and row[col_idx] is not None else "").strip()
if not str(record.get("name", "")).strip():
continue
payloads.append(MemberPayload(**record))
return payloads
def parse_import_rows(file: UploadFile, content: bytes) -> list[MemberPayload]:
suffix = Path(file.filename or "").suffix.lower()
if suffix == ".csv":
text = content.decode("utf-8-sig")
rows = list(csv.reader(StringIO(text)))
return rows_to_member_payloads(rows)
if suffix in {".xlsx", ".xlsm", ".xltx", ".xltm"}:
workbook = load_workbook(BytesIO(content), data_only=True)
sheet = workbook[workbook.sheetnames[0]]
rows = [list(row) for row in sheet.iter_rows(values_only=True)]
return rows_to_member_payloads(rows)
raise HTTPException(status_code=400, detail="xlsx 또는 csv 파일만 업로드할 수 있습니다.")
@app.on_event("startup")
def startup() -> None:
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
SNAPSHOT_DIR.mkdir(parents=True, exist_ok=True)
LEGACY_STATIC_DIR.mkdir(parents=True, exist_ok=True)
init_db()
app.mount("/legacy/static", StaticFiles(directory=LEGACY_STATIC_DIR, check_dir=False), name="legacy-static")
@app.get("/api/health")
def health() -> dict[str, str]:
return {"status": "ok"}
@app.post("/api/mock-login")
def mock_login(username: str = Form(...), password: str = Form(...)) -> dict[str, object]:
if not MOCK_LOGIN_ENABLED:
raise HTTPException(status_code=403, detail="Mock login is disabled.")
if not username.strip() or not password.strip():
raise HTTPException(status_code=400, detail="Username and password are required.")
return {
"user": {
"username": username.strip(),
"display_name": username.strip(),
"role": "admin",
},
"session_expires_at": datetime.utcnow().isoformat() + "Z",
}
@app.get("/api/members")
def list_members() -> dict[str, list[dict[str, object]]]:
return {"items": fetch_members()}
@app.post("/api/members")
def create_member(payload: MemberPayload) -> dict[str, object]:
with get_conn() as conn:
with conn.cursor() as cur:
cur.execute("SELECT COALESCE(MAX(sort_order), -1) + 1 AS next_order FROM members")
next_order = int(cur.fetchone()["next_order"])
cur.execute(
"""
INSERT INTO members (
name, company, rank, role, department, grp, division, team, cell,
work_status, work_time, phone, email, seat_label, photo_url, sort_order
)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
RETURNING id, name, company, rank, role, department, grp, division, team, cell,
work_status, work_time, phone, email, seat_label, photo_url,
sort_order, created_at, updated_at
""",
serialize_member_payload(payload, payload.sort_order if payload.sort_order is not None else next_order),
)
member = cur.fetchone()
conn.commit()
return {"item": member}
@app.put("/api/members/{member_id}")
def update_member(member_id: int, payload: MemberPayload) -> dict[str, object]:
with get_conn() as conn:
with conn.cursor() as cur:
cur.execute(
"""
UPDATE members
SET name = %s,
company = %s,
rank = %s,
role = %s,
department = %s,
grp = %s,
division = %s,
team = %s,
cell = %s,
work_status = %s,
work_time = %s,
phone = %s,
email = %s,
seat_label = %s,
photo_url = %s,
sort_order = COALESCE(%s, sort_order),
updated_at = NOW()
WHERE id = %s
RETURNING id, name, company, rank, role, department, grp, division, team, cell,
work_status, work_time, phone, email, seat_label, photo_url,
sort_order, created_at, updated_at
""",
(*serialize_member_payload(payload, payload.sort_order or 0)[:-1], payload.sort_order, member_id),
)
member = cur.fetchone()
if member is None:
raise HTTPException(status_code=404, detail="Member not found.")
conn.commit()
return {"item": member}
@app.delete("/api/members/{member_id}")
def delete_member(member_id: int) -> dict[str, bool]:
with get_conn() as conn:
with conn.cursor() as cur:
cur.execute("DELETE FROM members WHERE id = %s", (member_id,))
deleted = cur.rowcount > 0
conn.commit()
if not deleted:
raise HTTPException(status_code=404, detail="Member not found.")
return {"ok": True}
@app.put("/api/members/bulk-sync")
def bulk_sync_members(payload: MemberBulkPayload) -> dict[str, list[dict[str, object]]]:
return {"items": replace_members(payload.items)}
@app.post("/api/members/import")
async def import_members(file: UploadFile = File(...)) -> dict[str, list[dict[str, object]]]:
content = await file.read()
items = parse_import_rows(file, content)
return {"items": replace_members(items)}
@app.post("/api/uploads/profile-photo")
def upload_profile_photo(file: UploadFile = File(...), member_name: str = Form("")) -> dict[str, str]:
suffix = Path(file.filename or "").suffix.lower()
if suffix not in {".png", ".jpg", ".jpeg", ".webp", ".gif"}:
raise HTTPException(status_code=400, detail="Only image files are allowed.")
stem = member_name.strip().replace(" ", "-") or "member"
filename = f"{datetime.utcnow().strftime('%Y%m%d%H%M%S')}-{stem}-{uuid.uuid4().hex[:8]}{suffix}"
target = UPLOAD_DIR / filename
with target.open("wb") as out_file:
shutil.copyfileobj(file.file, out_file)
return {"url": f"/uploads/{filename}"}
@app.post("/api/snapshots/monthly")
def create_monthly_snapshot(snapshot_month: str = Form(...)) -> dict[str, str]:
filename = f"organization-snapshot-{snapshot_month}.csv"
target = SNAPSHOT_DIR / filename
with get_conn() as conn:
with conn.cursor() as cur:
cur.execute(
"""
SELECT name, company, rank, role, department, grp, division, team, cell,
work_status, work_time, phone, email, seat_label, photo_url, updated_at
FROM members
ORDER BY sort_order ASC, id ASC
"""
)
rows = cur.fetchall()
with target.open("w", newline="", encoding="utf-8-sig") as csv_file:
writer = csv.DictWriter(
csv_file,
fieldnames=[
"name",
"company",
"rank",
"role",
"department",
"grp",
"division",
"team",
"cell",
"work_status",
"work_time",
"phone",
"email",
"seat_label",
"photo_url",
"updated_at",
],
)
writer.writeheader()
writer.writerows(rows)
with conn.cursor() as cur:
cur.execute(
"INSERT INTO snapshots (snapshot_month, file_path) VALUES (%s, %s)",
(snapshot_month, str(target)),
)
conn.commit()
return {"file": f"/snapshots/{filename}"}
@app.get("/api/snapshots")
def list_snapshots() -> dict[str, list[dict[str, object]]]:
with get_conn() as conn:
with conn.cursor() as cur:
cur.execute(
"SELECT id, snapshot_month, file_path, created_at FROM snapshots ORDER BY created_at DESC"
)
snapshots = cur.fetchall()
return {"items": snapshots}
@app.get("/legacy/organization")
def legacy_organization() -> FileResponse:
target = LEGACY_DIR / "DashBoard-organization.html"
if not target.exists():
raise HTTPException(status_code=404, detail="Legacy dashboard file not found.")
return FileResponse(target)
@app.get("/legacy/organization-backup")
def legacy_organization_backup() -> FileResponse:
target = LEGACY_DIR / "DashBoard-organization-backup.html"
if not target.exists():
raise HTTPException(status_code=404, detail="Legacy dashboard backup not found.")
return FileResponse(target)
@app.get("/uploads/{filename}")
def get_upload(filename: str) -> FileResponse:
target = UPLOAD_DIR / filename
if not target.exists():
raise HTTPException(status_code=404, detail="Upload not found.")
return FileResponse(target)
@app.get("/snapshots/{filename}")
def get_snapshot(filename: str) -> FileResponse:
target = SNAPSHOT_DIR / filename
if not target.exists():
raise HTTPException(status_code=404, detail="Snapshot not found.")
return FileResponse(target, media_type="text/csv")