From c3afc0c77265189d12bf03d49d9f88a047b8aeca Mon Sep 17 00:00:00 2001 From: hyunho Date: Wed, 1 Apr 2026 16:58:51 +0900 Subject: [PATCH] refactor: split backend route domains --- backend/app/auth_routes.py | 172 +++++++++ backend/app/integration_routes.py | 50 +++ backend/app/main.py | 589 +++--------------------------- backend/app/member_routes.py | 140 +++++++ backend/app/seatmap_routes.py | 224 ++++++++++++ 5 files changed, 643 insertions(+), 532 deletions(-) create mode 100644 backend/app/auth_routes.py create mode 100644 backend/app/integration_routes.py create mode 100644 backend/app/member_routes.py create mode 100644 backend/app/seatmap_routes.py diff --git a/backend/app/auth_routes.py b/backend/app/auth_routes.py new file mode 100644 index 0000000..3108a40 --- /dev/null +++ b/backend/app/auth_routes.py @@ -0,0 +1,172 @@ +from __future__ import annotations + +from datetime import datetime, timedelta, timezone +import uuid +from typing import Callable + +from fastapi import FastAPI, Form, Header, HTTPException, Request + + +def register_auth_routes( + app: FastAPI, + *, + get_conn, + verify_password: Callable[[str, str], bool], + build_auth_session_payload: Callable[[dict[str, object], uuid.UUID, datetime], dict[str, object]], + extract_bearer_token: Callable[[str | None], str | None], + mock_login_enabled: bool, + auth_session_hours: int, +) -> None: + @app.post("/api/auth/login") + def auth_login( + request: Request, + username: str = Form(...), + password: str = Form(...), + ) -> dict[str, object]: + normalized_username = username.strip().lower() + if not normalized_username or not password.strip(): + raise HTTPException(status_code=400, detail="사번과 비밀번호를 입력해주세요.") + + ip_address = request.client.host if request.client else None + user_agent = request.headers.get("user-agent", "") + + with get_conn() as conn: + with conn.cursor() as cur: + cur.execute( + """ + SELECT u.id, u.username, u.password_hash, u.display_name, u.role, u.member_id, u.is_active, + m.rank + FROM auth.users u + LEFT JOIN members m ON m.id = u.member_id + WHERE LOWER(u.username) = %s + """, + (normalized_username,), + ) + user = cur.fetchone() + + if user is None: + cur.execute( + """ + INSERT INTO auth.login_audit_logs (username, success, failure_reason, ip_address, user_agent) + VALUES (%s, FALSE, %s, %s, %s) + """, + (normalized_username, "unknown_user", ip_address, user_agent), + ) + conn.commit() + raise HTTPException(status_code=401, detail="사번 또는 비밀번호가 올바르지 않습니다.") + + if not bool(user.get("is_active")): + cur.execute( + """ + INSERT INTO auth.login_audit_logs (username, user_id, success, failure_reason, ip_address, user_agent) + VALUES (%s, %s, FALSE, %s, %s, %s) + """, + (normalized_username, int(user["id"]), "inactive_user", ip_address, user_agent), + ) + conn.commit() + raise HTTPException(status_code=403, detail="비활성화된 계정입니다.") + + if not verify_password(password, str(user.get("password_hash") or "")): + cur.execute( + """ + INSERT INTO auth.login_audit_logs (username, user_id, success, failure_reason, ip_address, user_agent) + VALUES (%s, %s, FALSE, %s, %s, %s) + """, + (normalized_username, int(user["id"]), "invalid_password", ip_address, user_agent), + ) + conn.commit() + raise HTTPException(status_code=401, detail="사번 또는 비밀번호가 올바르지 않습니다.") + + expires_at = datetime.now(timezone.utc) + timedelta(hours=auth_session_hours) + session_id = uuid.uuid4() + cur.execute( + """ + INSERT INTO auth.sessions (id, user_id, expires_at, ip_address, user_agent) + VALUES (%s, %s, %s, %s, %s) + """, + (session_id, int(user["id"]), expires_at, ip_address, user_agent), + ) + cur.execute( + "UPDATE auth.users SET last_login_at = NOW(), updated_at = NOW() WHERE id = %s", + (int(user["id"]),), + ) + cur.execute( + """ + INSERT INTO auth.login_audit_logs (username, user_id, success, failure_reason, ip_address, user_agent) + VALUES (%s, %s, TRUE, NULL, %s, %s) + """, + (normalized_username, int(user["id"]), ip_address, user_agent), + ) + conn.commit() + + return build_auth_session_payload(user, session_id, expires_at) + + @app.post("/api/auth/logout") + def auth_logout(authorization: str | None = Header(default=None)) -> dict[str, bool]: + token = extract_bearer_token(authorization) + if not token: + return {"ok": True} + + with get_conn() as conn: + with conn.cursor() as cur: + cur.execute( + """ + UPDATE auth.sessions + SET revoked_at = NOW() + WHERE id = %s + AND revoked_at IS NULL + """, + (token,), + ) + conn.commit() + return {"ok": True} + + @app.get("/api/auth/me") + def auth_me(authorization: str | None = Header(default=None)) -> dict[str, object]: + token = extract_bearer_token(authorization) + if not token: + raise HTTPException(status_code=401, detail="인증 정보가 없습니다.") + + with get_conn() as conn: + with conn.cursor() as cur: + cur.execute( + """ + SELECT s.id AS session_id, s.expires_at, s.revoked_at, + u.id, u.username, u.display_name, u.role, u.member_id, u.is_active, + m.rank + FROM auth.sessions s + JOIN auth.users u ON u.id = s.user_id + LEFT JOIN members m ON m.id = u.member_id + WHERE s.id = %s + """, + (token,), + ) + row = cur.fetchone() + + if row is None or row.get("revoked_at") is not None: + raise HTTPException(status_code=401, detail="세션이 유효하지 않습니다.") + + expires_at = row["expires_at"] + now_utc = datetime.now(timezone.utc) + if expires_at is None or expires_at <= now_utc: + raise HTTPException(status_code=401, detail="세션이 만료되었습니다.") + + if not bool(row.get("is_active")): + raise HTTPException(status_code=403, detail="비활성화된 계정입니다.") + + return build_auth_session_payload(row, uuid.UUID(str(row["session_id"])), expires_at) + + @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", + } diff --git a/backend/app/integration_routes.py b/backend/app/integration_routes.py new file mode 100644 index 0000000..cd0cfe2 --- /dev/null +++ b/backend/app/integration_routes.py @@ -0,0 +1,50 @@ +from __future__ import annotations + +from fastapi import FastAPI + + +def register_integration_routes( + app: FastAPI, + *, + import_integration_sources, + fetch_integration_summary, + fetch_project_metrics, + fetch_member_metrics, + fetch_team_metrics, + fetch_project_breakdowns, + fetch_payment_source_rows, + fetch_mh_source_rows, +) -> None: + @app.post("/api/integration/import") + def import_integration_data() -> dict[str, object]: + return import_integration_sources() + + @app.get("/api/integration/summary") + def integration_summary() -> dict[str, object]: + return fetch_integration_summary() + + @app.get("/api/integration/projects") + def integration_projects(limit: int = 500, start_date: str | None = None, end_date: str | None = None) -> dict[str, list[dict[str, object]]]: + safe_limit = max(1, min(limit, 5000)) + return {"items": fetch_project_metrics(safe_limit, start_date=start_date, end_date=end_date)} + + @app.get("/api/integration/members") + def integration_members(limit: int = 500, start_date: str | None = None, end_date: str | None = None) -> dict[str, list[dict[str, object]]]: + safe_limit = max(1, min(limit, 5000)) + return {"items": fetch_member_metrics(safe_limit, start_date=start_date, end_date=end_date)} + + @app.get("/api/integration/teams") + def integration_teams(start_date: str | None = None, end_date: str | None = None) -> dict[str, list[dict[str, object]]]: + return {"items": fetch_team_metrics(start_date=start_date, end_date=end_date)} + + @app.get("/api/integration/project-breakdowns") + def integration_project_breakdowns(start_date: str | None = None, end_date: str | None = None) -> dict[str, list[dict[str, object]]]: + return fetch_project_breakdowns(start_date=start_date, end_date=end_date) + + @app.get("/api/integration/payment-source") + def integration_payment_source() -> dict[str, object]: + return fetch_payment_source_rows() + + @app.get("/api/integration/mh-source") + def integration_mh_source() -> dict[str, object]: + return fetch_mh_source_rows() diff --git a/backend/app/main.py b/backend/app/main.py index ac844b1..be4afcf 100755 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -19,20 +19,24 @@ import uuid import ezdxf from ezdxf import recover -from fastapi import FastAPI, File, Form, Header, HTTPException, Request, UploadFile +from fastapi import FastAPI, File, Form, HTTPException, UploadFile from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import HTMLResponse from fastapi.staticfiles import StaticFiles from openpyxl import load_workbook from pydantic import BaseModel, Field +from .auth_routes import register_auth_routes from .config import BASE_DIR, LEGACY_DIR, MOCK_LOGIN_ENABLED, UPLOAD_DIR from .db import get_conn, init_db +from .integration_routes import register_integration_routes from .ledger_runtime import ( build_business_ledger_default_response, build_ledger_index_response, sync_default_business_ledger_source, ) +from .member_routes import register_member_routes +from .seatmap_routes import register_seatmap_routes from .system_routes import register_system_routes @@ -3947,534 +3951,55 @@ register_system_routes( build_business_ledger_default_response=build_business_ledger_default_response, build_ledger_index_response=build_ledger_index_response, ) - - -@app.post("/api/auth/login") -def auth_login( - request: Request, - username: str = Form(...), - password: str = Form(...), -) -> dict[str, object]: - normalized_username = username.strip().lower() - if not normalized_username or not password.strip(): - raise HTTPException(status_code=400, detail="사번과 비밀번호를 입력해주세요.") - - ip_address = request.client.host if request.client else None - user_agent = request.headers.get("user-agent", "") - - with get_conn() as conn: - with conn.cursor() as cur: - cur.execute( - """ - SELECT u.id, u.username, u.password_hash, u.display_name, u.role, u.member_id, u.is_active, - m.rank - FROM auth.users u - LEFT JOIN members m ON m.id = u.member_id - WHERE LOWER(u.username) = %s - """, - (normalized_username,), - ) - user = cur.fetchone() - - if user is None: - cur.execute( - """ - INSERT INTO auth.login_audit_logs (username, success, failure_reason, ip_address, user_agent) - VALUES (%s, FALSE, %s, %s, %s) - """, - (normalized_username, "unknown_user", ip_address, user_agent), - ) - conn.commit() - raise HTTPException(status_code=401, detail="사번 또는 비밀번호가 올바르지 않습니다.") - - if not bool(user.get("is_active")): - cur.execute( - """ - INSERT INTO auth.login_audit_logs (username, user_id, success, failure_reason, ip_address, user_agent) - VALUES (%s, %s, FALSE, %s, %s, %s) - """, - (normalized_username, int(user["id"]), "inactive_user", ip_address, user_agent), - ) - conn.commit() - raise HTTPException(status_code=403, detail="비활성화된 계정입니다.") - - if not verify_password(password, str(user.get("password_hash") or "")): - cur.execute( - """ - INSERT INTO auth.login_audit_logs (username, user_id, success, failure_reason, ip_address, user_agent) - VALUES (%s, %s, FALSE, %s, %s, %s) - """, - (normalized_username, int(user["id"]), "invalid_password", ip_address, user_agent), - ) - conn.commit() - raise HTTPException(status_code=401, detail="사번 또는 비밀번호가 올바르지 않습니다.") - - expires_at = datetime.now(timezone.utc) + timedelta(hours=AUTH_SESSION_HOURS) - session_id = uuid.uuid4() - cur.execute( - """ - INSERT INTO auth.sessions (id, user_id, expires_at, ip_address, user_agent) - VALUES (%s, %s, %s, %s, %s) - """, - (session_id, int(user["id"]), expires_at, ip_address, user_agent), - ) - cur.execute( - "UPDATE auth.users SET last_login_at = NOW(), updated_at = NOW() WHERE id = %s", - (int(user["id"]),), - ) - cur.execute( - """ - INSERT INTO auth.login_audit_logs (username, user_id, success, failure_reason, ip_address, user_agent) - VALUES (%s, %s, TRUE, NULL, %s, %s) - """, - (normalized_username, int(user["id"]), ip_address, user_agent), - ) - conn.commit() - - return build_auth_session_payload(user, session_id, expires_at) - - -@app.post("/api/auth/logout") -def auth_logout(authorization: str | None = Header(default=None)) -> dict[str, bool]: - token = extract_bearer_token(authorization) - if not token: - return {"ok": True} - - with get_conn() as conn: - with conn.cursor() as cur: - cur.execute( - """ - UPDATE auth.sessions - SET revoked_at = NOW() - WHERE id = %s - AND revoked_at IS NULL - """, - (token,), - ) - conn.commit() - return {"ok": True} - - -@app.get("/api/auth/me") -def auth_me(authorization: str | None = Header(default=None)) -> dict[str, object]: - token = extract_bearer_token(authorization) - if not token: - raise HTTPException(status_code=401, detail="인증 정보가 없습니다.") - - with get_conn() as conn: - with conn.cursor() as cur: - cur.execute( - """ - SELECT s.id AS session_id, s.expires_at, s.revoked_at, - u.id, u.username, u.display_name, u.role, u.member_id, u.is_active, - m.rank - FROM auth.sessions s - JOIN auth.users u ON u.id = s.user_id - LEFT JOIN members m ON m.id = u.member_id - WHERE s.id = %s - """, - (token,), - ) - row = cur.fetchone() - - if row is None or row.get("revoked_at") is not None: - raise HTTPException(status_code=401, detail="세션이 유효하지 않습니다.") - - expires_at = row["expires_at"] - now_utc = datetime.now(timezone.utc) - if expires_at is None or expires_at <= now_utc: - raise HTTPException(status_code=401, detail="세션이 만료되었습니다.") - - if not bool(row.get("is_active")): - raise HTTPException(status_code=403, detail="비활성화된 계정입니다.") - - return build_auth_session_payload(row, uuid.UUID(str(row["session_id"])), expires_at) - - -@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(as_of: str | None = None) -> dict[str, list[dict[str, object]]]: - parsed_as_of = parse_as_of(as_of) - if parsed_as_of is None: - return {"items": fetch_members()} - with get_conn() as conn: - with conn.cursor() as cur: - return {"items": fetch_members_as_of(cur, parsed_as_of)} - - -@app.get("/api/history/members/compare") -def compare_members_history(from_date: str, to_date: str) -> dict[str, list[dict[str, object]]]: - parsed_from = parse_as_of(from_date) - parsed_to = parse_as_of(to_date) - if parsed_from is None or parsed_to is None: - raise HTTPException(status_code=400, detail="from_date and to_date are required.") - with get_conn() as conn: - with conn.cursor() as cur: - from_items = fetch_members_as_of(cur, parsed_from) - to_items = fetch_members_as_of(cur, parsed_to) - return {"items": build_member_compare_items(from_items, to_items)} - - -@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, employee_id, 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, %s) - RETURNING id, name, employee_id, 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() - sync_auth_users_from_members(cur) - revision_no = create_history_revision(cur, "member-create", f"Member created id={int(member['id'])}") - sync_member_versions(cur, [int(member["id"])], "member-create", revision_no) - conn.commit() - return {"item": member} - - -@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.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, - employee_id = %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, employee_id, 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.") - sync_auth_users_from_members(cur) - revision_no = create_history_revision(cur, "member-update", f"Member updated id={member_id}") - sync_member_versions(cur, [member_id], "member-update", revision_no) - sync_seat_assignment_versions(cur, [member_id], "member-update", revision_no) - 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 - if deleted: - sync_auth_users_from_members(cur) - revision_no = create_history_revision(cur, "member-delete", f"Member deleted id={member_id}") - sync_member_versions(cur, [member_id], "member-delete", revision_no) - sync_seat_assignment_versions(cur, [member_id], "member-delete", revision_no) - conn.commit() - if not deleted: - raise HTTPException(status_code=404, detail="Member not found.") - return {"ok": True} - - -@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/integration/import") -def import_integration_data() -> dict[str, object]: - return import_integration_sources() - - -@app.get("/api/integration/summary") -def integration_summary() -> dict[str, object]: - return fetch_integration_summary() - - -@app.get("/api/integration/projects") -def integration_projects(limit: int = 500, start_date: str | None = None, end_date: str | None = None) -> dict[str, list[dict[str, object]]]: - safe_limit = max(1, min(limit, 5000)) - return {"items": fetch_project_metrics(safe_limit, start_date=start_date, end_date=end_date)} - - -@app.get("/api/integration/members") -def integration_members(limit: int = 500, start_date: str | None = None, end_date: str | None = None) -> dict[str, list[dict[str, object]]]: - safe_limit = max(1, min(limit, 5000)) - return {"items": fetch_member_metrics(safe_limit, start_date=start_date, end_date=end_date)} - - -@app.get("/api/integration/teams") -def integration_teams(start_date: str | None = None, end_date: str | None = None) -> dict[str, list[dict[str, object]]]: - return {"items": fetch_team_metrics(start_date=start_date, end_date=end_date)} - - -@app.get("/api/integration/project-breakdowns") -def integration_project_breakdowns(start_date: str | None = None, end_date: str | None = None) -> dict[str, list[dict[str, object]]]: - return fetch_project_breakdowns(start_date=start_date, end_date=end_date) - - -@app.get("/api/integration/payment-source") -def integration_payment_source() -> dict[str, object]: - return fetch_payment_source_rows() - - -@app.get("/api/integration/mh-source") -def integration_mh_source() -> dict[str, object]: - return fetch_mh_source_rows() - - -@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/uploads/seat-map-image") -def upload_seat_map_image(file: UploadFile = File(...), seat_map_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 = seat_map_name.strip().replace(" ", "-") or "seat-map" - filename = f"seat-map-{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/seat-maps/dxf") -async def create_dxf_seat_map(file: UploadFile = File(...), name: str = Form(...)) -> dict[str, object]: - suffix = Path(file.filename or "").suffix.lower() - if suffix != ".dxf": - raise HTTPException(status_code=400, detail="DXF 파일만 업로드할 수 있습니다.") - - stem = name.strip().replace(" ", "-") or "seat-map" - filename = f"seat-map-{datetime.utcnow().strftime('%Y%m%d%H%M%S')}-{stem}-{uuid.uuid4().hex[:8]}{suffix}" - target = UPLOAD_DIR / filename - content = await file.read() - with target.open("wb") as out_file: - out_file.write(content) - - try: - metadata, slots = parse_dxf_layout(target) - except Exception: - raise - - payload = SeatMapPayload( - name=name.strip(), - source_type="dxf", - source_url=f"/uploads/{filename}", - image_url="", - preview_svg=metadata["preview_svg"], - view_box_min_x=metadata["view_box_min_x"], - view_box_min_y=metadata["view_box_min_y"], - view_box_width=metadata["view_box_width"], - view_box_height=metadata["view_box_height"], - image_width=None, - image_height=None, - grid_rows=1, - grid_cols=1, - cell_gap=0, - is_active=True, - ) - - with get_conn() as conn: - with conn.cursor() as cur: - cur.execute("UPDATE seat_maps SET is_active = FALSE, updated_at = NOW() WHERE is_active = TRUE") - cur.execute( - """ - INSERT INTO seat_maps ( - name, source_type, source_url, preview_svg, - view_box_min_x, view_box_min_y, view_box_width, view_box_height, - image_url, image_width, image_height, grid_rows, grid_cols, cell_gap, is_active - ) - VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) - RETURNING id, name, source_type, source_url, preview_svg, - view_box_min_x, view_box_min_y, view_box_width, view_box_height, - image_url, image_width, image_height, grid_rows, grid_cols, - cell_gap, is_active, created_at, updated_at - """, - serialize_seat_map_payload(payload), - ) - seat_map = cur.fetchone() - for slot in slots: - cur.execute( - """ - INSERT INTO seat_slots (seat_map_id, slot_key, label, x, y, rotation, layer_name) - VALUES (%s, %s, %s, %s, %s, %s, %s) - """, - ( - seat_map["id"], - slot["slot_key"], - slot["label"], - slot["x"], - slot["y"], - slot["rotation"], - slot["layer_name"], - ), - ) - conn.commit() - - return fetch_seat_layout(int(seat_map["id"])) - - -@app.get("/api/seat-maps/active") -def get_active_seat_map(office_key: str | None = None) -> dict[str, dict[str, object]]: - requested_key = (office_key or "").strip() or FIXED_OFFICE_SOURCE_KEY - seat_map = ensure_fixed_office_seat_map(requested_key, activate=requested_key == FIXED_OFFICE_SOURCE_KEY) - if seat_map is None: - raise HTTPException(status_code=404, detail="Active seat map not found.") - return {"item": seat_map} - - -@app.post("/api/seat-maps") -def create_seat_map(payload: SeatMapPayload) -> dict[str, dict[str, object]]: - with get_conn() as conn: - with conn.cursor() as cur: - if payload.is_active: - cur.execute("UPDATE seat_maps SET is_active = FALSE, updated_at = NOW() WHERE is_active = TRUE") - cur.execute( - """ - INSERT INTO seat_maps ( - name, source_type, source_url, preview_svg, - view_box_min_x, view_box_min_y, view_box_width, view_box_height, - image_url, image_width, image_height, grid_rows, grid_cols, cell_gap, is_active - ) - VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) - RETURNING id, name, source_type, source_url, preview_svg, - view_box_min_x, view_box_min_y, view_box_width, view_box_height, - image_url, image_width, image_height, grid_rows, grid_cols, - cell_gap, is_active, created_at, updated_at - """, - serialize_seat_map_payload(payload), - ) - seat_map = cur.fetchone() - conn.commit() - return {"item": seat_map} - - -@app.put("/api/seat-maps/{seat_map_id}") -def update_seat_map(seat_map_id: int, payload: SeatMapPayload) -> dict[str, dict[str, object]]: - with get_conn() as conn: - with conn.cursor() as cur: - if payload.source_type != "dxf": - cur.execute( - """ - SELECT COUNT(*) AS count - FROM seat_positions - WHERE seat_map_id = %s - AND (row_index >= %s OR col_index >= %s) - """, - (seat_map_id, payload.grid_rows, payload.grid_cols), - ) - out_of_bounds_count = int(cur.fetchone()["count"]) - if out_of_bounds_count > 0: - raise HTTPException(status_code=400, detail="현재 배치된 좌석이 새 그리드 범위를 벗어납니다. 먼저 좌석 배치를 정리하세요.") - if payload.is_active: - cur.execute("UPDATE seat_maps SET is_active = FALSE, updated_at = NOW() WHERE is_active = TRUE AND id <> %s", (seat_map_id,)) - cur.execute( - """ - UPDATE seat_maps - SET name = %s, - source_type = %s, - source_url = %s, - preview_svg = %s, - view_box_min_x = %s, - view_box_min_y = %s, - view_box_width = %s, - view_box_height = %s, - image_url = %s, - image_width = %s, - image_height = %s, - grid_rows = %s, - grid_cols = %s, - cell_gap = %s, - is_active = %s, - updated_at = NOW() - WHERE id = %s - RETURNING id, name, source_type, source_url, preview_svg, - view_box_min_x, view_box_min_y, view_box_width, view_box_height, - image_url, image_width, image_height, grid_rows, grid_cols, - cell_gap, is_active, created_at, updated_at - """, - (*serialize_seat_map_payload(payload), seat_map_id), - ) - seat_map = cur.fetchone() - if seat_map is None: - raise HTTPException(status_code=404, detail="Seat map not found.") - conn.commit() - return {"item": seat_map} - - -@app.get("/api/seat-maps/{seat_map_id}/layout") -def get_seat_layout(seat_map_id: int, as_of: str | None = None) -> dict[str, object]: - return fetch_seat_layout(seat_map_id, parse_as_of(as_of)) - - -@app.get("/api/seat-maps/{seat_map_id}/viewer") -def get_seat_map_viewer(seat_map_id: int, as_of: str | None = None) -> HTMLResponse: - layout = fetch_seat_layout(seat_map_id, parse_as_of(as_of)) - seat_map = layout.get("seat_map") or {} - if seat_map.get("source_type") not in {"dxf", "fixed_html"}: - raise HTTPException(status_code=400, detail="Viewer is only available for supported seat maps.") - return HTMLResponse(build_center_chair_viewer_html(layout)) - - -@app.put("/api/seat-maps/{seat_map_id}/layout") -def update_seat_layout(seat_map_id: int, payload: SeatLayoutPayload) -> dict[str, list[dict[str, object]]]: - return {"items": save_seat_layout(seat_map_id, payload)} +register_auth_routes( + app, + get_conn=get_conn, + verify_password=verify_password, + build_auth_session_payload=build_auth_session_payload, + extract_bearer_token=extract_bearer_token, + mock_login_enabled=MOCK_LOGIN_ENABLED, + auth_session_hours=AUTH_SESSION_HOURS, +) +register_member_routes( + app, + get_conn=get_conn, + member_payload_cls=MemberPayload, + member_bulk_payload_cls=MemberBulkPayload, + parse_as_of=parse_as_of, + fetch_members=fetch_members, + fetch_members_as_of=fetch_members_as_of, + build_member_compare_items=build_member_compare_items, + serialize_member_payload=serialize_member_payload, + sync_auth_users_from_members=sync_auth_users_from_members, + create_history_revision=create_history_revision, + sync_member_versions=sync_member_versions, + sync_seat_assignment_versions=sync_seat_assignment_versions, + replace_members=replace_members, + parse_import_rows=parse_import_rows, +) +register_integration_routes( + app, + import_integration_sources=import_integration_sources, + fetch_integration_summary=fetch_integration_summary, + fetch_project_metrics=fetch_project_metrics, + fetch_member_metrics=fetch_member_metrics, + fetch_team_metrics=fetch_team_metrics, + fetch_project_breakdowns=fetch_project_breakdowns, + fetch_payment_source_rows=fetch_payment_source_rows, + fetch_mh_source_rows=fetch_mh_source_rows, +) +register_seatmap_routes( + app, + upload_dir=UPLOAD_DIR, + get_conn=get_conn, + seat_map_payload_cls=SeatMapPayload, + seat_layout_payload_cls=SeatLayoutPayload, + fixed_office_source_key=FIXED_OFFICE_SOURCE_KEY, + parse_dxf_layout=parse_dxf_layout, + serialize_seat_map_payload=serialize_seat_map_payload, + fetch_seat_layout=fetch_seat_layout, + parse_as_of=parse_as_of, + ensure_fixed_office_seat_map=ensure_fixed_office_seat_map, + build_center_chair_viewer_html=build_center_chair_viewer_html, + save_seat_layout=save_seat_layout, +) diff --git a/backend/app/member_routes.py b/backend/app/member_routes.py new file mode 100644 index 0000000..27a3780 --- /dev/null +++ b/backend/app/member_routes.py @@ -0,0 +1,140 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Callable + +from fastapi import FastAPI, File, HTTPException, UploadFile + + +def register_member_routes( + app: FastAPI, + *, + get_conn, + member_payload_cls, + member_bulk_payload_cls, + parse_as_of: Callable[[str | None], datetime | None], + fetch_members: Callable[[], list[dict[str, object]]], + fetch_members_as_of: Callable[[object, datetime], list[dict[str, object]]], + build_member_compare_items: Callable[[list[dict[str, object]], list[dict[str, object]]], list[dict[str, object]]], + serialize_member_payload: Callable[[object, int], tuple[object, ...]], + sync_auth_users_from_members: Callable[[object], None], + create_history_revision: Callable[[object, str, str], int], + sync_member_versions: Callable[[object, list[int], str, int], None], + sync_seat_assignment_versions: Callable[[object, list[int], str, int], None], + replace_members: Callable[[list[object]], list[dict[str, object]]], + parse_import_rows: Callable[[UploadFile, bytes], list[object]], +) -> None: + @app.get("/api/members") + def list_members(as_of: str | None = None) -> dict[str, list[dict[str, object]]]: + parsed_as_of = parse_as_of(as_of) + if parsed_as_of is None: + return {"items": fetch_members()} + with get_conn() as conn: + with conn.cursor() as cur: + return {"items": fetch_members_as_of(cur, parsed_as_of)} + + @app.get("/api/history/members/compare") + def compare_members_history(from_date: str, to_date: str) -> dict[str, list[dict[str, object]]]: + parsed_from = parse_as_of(from_date) + parsed_to = parse_as_of(to_date) + if parsed_from is None or parsed_to is None: + raise HTTPException(status_code=400, detail="from_date and to_date are required.") + with get_conn() as conn: + with conn.cursor() as cur: + from_items = fetch_members_as_of(cur, parsed_from) + to_items = fetch_members_as_of(cur, parsed_to) + return {"items": build_member_compare_items(from_items, to_items)} + + @app.post("/api/members") + def create_member(payload: member_payload_cls) -> 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, employee_id, 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, %s) + RETURNING id, name, employee_id, 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() + sync_auth_users_from_members(cur) + revision_no = create_history_revision(cur, "member-create", f"Member created id={int(member['id'])}") + sync_member_versions(cur, [int(member["id"])], "member-create", revision_no) + conn.commit() + return {"item": member} + + @app.put("/api/members/bulk-sync") + def bulk_sync_members(payload: member_bulk_payload_cls) -> dict[str, list[dict[str, object]]]: + return {"items": replace_members(payload.items)} + + @app.put("/api/members/{member_id}") + def update_member(member_id: int, payload: member_payload_cls) -> dict[str, object]: + with get_conn() as conn: + with conn.cursor() as cur: + cur.execute( + """ + UPDATE members + SET name = %s, + employee_id = %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, employee_id, 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.") + sync_auth_users_from_members(cur) + revision_no = create_history_revision(cur, "member-update", f"Member updated id={member_id}") + sync_member_versions(cur, [member_id], "member-update", revision_no) + sync_seat_assignment_versions(cur, [member_id], "member-update", revision_no) + 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 + if deleted: + sync_auth_users_from_members(cur) + revision_no = create_history_revision(cur, "member-delete", f"Member deleted id={member_id}") + sync_member_versions(cur, [member_id], "member-delete", revision_no) + sync_seat_assignment_versions(cur, [member_id], "member-delete", revision_no) + conn.commit() + if not deleted: + raise HTTPException(status_code=404, detail="Member not found.") + return {"ok": True} + + @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)} diff --git a/backend/app/seatmap_routes.py b/backend/app/seatmap_routes.py new file mode 100644 index 0000000..46b9674 --- /dev/null +++ b/backend/app/seatmap_routes.py @@ -0,0 +1,224 @@ +from __future__ import annotations + +from datetime import datetime +from pathlib import Path +import shutil +import uuid +from typing import Callable + +from fastapi import FastAPI, File, Form, HTTPException, UploadFile +from fastapi.responses import HTMLResponse + + +def register_seatmap_routes( + app: FastAPI, + *, + upload_dir: Path, + get_conn, + seat_map_payload_cls, + seat_layout_payload_cls, + fixed_office_source_key: str, + parse_dxf_layout: Callable[[Path], tuple[dict[str, object], list[dict[str, object]]]], + serialize_seat_map_payload: Callable[[object], tuple[object, ...]], + fetch_seat_layout: Callable[[int, object], dict[str, object]], + parse_as_of: Callable[[str | None], object], + ensure_fixed_office_seat_map: Callable[[str, bool], dict[str, object] | None], + build_center_chair_viewer_html: Callable[[dict[str, object]], str], + save_seat_layout: Callable[[int, object], list[dict[str, object]]], +) -> None: + @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/uploads/seat-map-image") + def upload_seat_map_image(file: UploadFile = File(...), seat_map_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 = seat_map_name.strip().replace(" ", "-") or "seat-map" + filename = f"seat-map-{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/seat-maps/dxf") + async def create_dxf_seat_map(file: UploadFile = File(...), name: str = Form(...)) -> dict[str, object]: + suffix = Path(file.filename or "").suffix.lower() + if suffix != ".dxf": + raise HTTPException(status_code=400, detail="DXF 파일만 업로드할 수 있습니다.") + + stem = name.strip().replace(" ", "-") or "seat-map" + filename = f"seat-map-{datetime.utcnow().strftime('%Y%m%d%H%M%S')}-{stem}-{uuid.uuid4().hex[:8]}{suffix}" + target = upload_dir / filename + content = await file.read() + with target.open("wb") as out_file: + out_file.write(content) + + metadata, slots = parse_dxf_layout(target) + + payload = seat_map_payload_cls( + name=name.strip(), + source_type="dxf", + source_url=f"/uploads/{filename}", + image_url="", + preview_svg=metadata["preview_svg"], + view_box_min_x=metadata["view_box_min_x"], + view_box_min_y=metadata["view_box_min_y"], + view_box_width=metadata["view_box_width"], + view_box_height=metadata["view_box_height"], + image_width=None, + image_height=None, + grid_rows=1, + grid_cols=1, + cell_gap=0, + is_active=True, + ) + + with get_conn() as conn: + with conn.cursor() as cur: + cur.execute("UPDATE seat_maps SET is_active = FALSE, updated_at = NOW() WHERE is_active = TRUE") + cur.execute( + """ + INSERT INTO seat_maps ( + name, source_type, source_url, preview_svg, + view_box_min_x, view_box_min_y, view_box_width, view_box_height, + image_url, image_width, image_height, grid_rows, grid_cols, cell_gap, is_active + ) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + RETURNING id, name, source_type, source_url, preview_svg, + view_box_min_x, view_box_min_y, view_box_width, view_box_height, + image_url, image_width, image_height, grid_rows, grid_cols, + cell_gap, is_active, created_at, updated_at + """, + serialize_seat_map_payload(payload), + ) + seat_map = cur.fetchone() + for slot in slots: + cur.execute( + """ + INSERT INTO seat_slots (seat_map_id, slot_key, label, x, y, rotation, layer_name) + VALUES (%s, %s, %s, %s, %s, %s, %s) + """, + ( + seat_map["id"], + slot["slot_key"], + slot["label"], + slot["x"], + slot["y"], + slot["rotation"], + slot["layer_name"], + ), + ) + conn.commit() + + return fetch_seat_layout(int(seat_map["id"])) + + @app.get("/api/seat-maps/active") + def get_active_seat_map(office_key: str | None = None) -> dict[str, dict[str, object]]: + requested_key = (office_key or "").strip() or fixed_office_source_key + seat_map = ensure_fixed_office_seat_map(requested_key, activate=requested_key == fixed_office_source_key) + if seat_map is None: + raise HTTPException(status_code=404, detail="Active seat map not found.") + return {"item": seat_map} + + @app.post("/api/seat-maps") + def create_seat_map(payload: seat_map_payload_cls) -> dict[str, dict[str, object]]: + with get_conn() as conn: + with conn.cursor() as cur: + if payload.is_active: + cur.execute("UPDATE seat_maps SET is_active = FALSE, updated_at = NOW() WHERE is_active = TRUE") + cur.execute( + """ + INSERT INTO seat_maps ( + name, source_type, source_url, preview_svg, + view_box_min_x, view_box_min_y, view_box_width, view_box_height, + image_url, image_width, image_height, grid_rows, grid_cols, cell_gap, is_active + ) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + RETURNING id, name, source_type, source_url, preview_svg, + view_box_min_x, view_box_min_y, view_box_width, view_box_height, + image_url, image_width, image_height, grid_rows, grid_cols, + cell_gap, is_active, created_at, updated_at + """, + serialize_seat_map_payload(payload), + ) + seat_map = cur.fetchone() + conn.commit() + return {"item": seat_map} + + @app.put("/api/seat-maps/{seat_map_id}") + def update_seat_map(seat_map_id: int, payload: seat_map_payload_cls) -> dict[str, dict[str, object]]: + with get_conn() as conn: + with conn.cursor() as cur: + if payload.source_type != "dxf": + cur.execute( + """ + SELECT COUNT(*) AS count + FROM seat_positions + WHERE seat_map_id = %s + AND (row_index >= %s OR col_index >= %s) + """, + (seat_map_id, payload.grid_rows, payload.grid_cols), + ) + out_of_bounds_count = int(cur.fetchone()["count"]) + if out_of_bounds_count > 0: + raise HTTPException(status_code=400, detail="현재 배치된 좌석이 새 그리드 범위를 벗어납니다. 먼저 좌석 배치를 정리하세요.") + if payload.is_active: + cur.execute("UPDATE seat_maps SET is_active = FALSE, updated_at = NOW() WHERE is_active = TRUE AND id <> %s", (seat_map_id,)) + cur.execute( + """ + UPDATE seat_maps + SET name = %s, + source_type = %s, + source_url = %s, + preview_svg = %s, + view_box_min_x = %s, + view_box_min_y = %s, + view_box_width = %s, + view_box_height = %s, + image_url = %s, + image_width = %s, + image_height = %s, + grid_rows = %s, + grid_cols = %s, + cell_gap = %s, + is_active = %s, + updated_at = NOW() + WHERE id = %s + RETURNING id, name, source_type, source_url, preview_svg, + view_box_min_x, view_box_min_y, view_box_width, view_box_height, + image_url, image_width, image_height, grid_rows, grid_cols, + cell_gap, is_active, created_at, updated_at + """, + (*serialize_seat_map_payload(payload), seat_map_id), + ) + seat_map = cur.fetchone() + if seat_map is None: + raise HTTPException(status_code=404, detail="Seat map not found.") + conn.commit() + return {"item": seat_map} + + @app.get("/api/seat-maps/{seat_map_id}/layout") + def get_seat_layout(seat_map_id: int, as_of: str | None = None) -> dict[str, object]: + return fetch_seat_layout(seat_map_id, parse_as_of(as_of)) + + @app.get("/api/seat-maps/{seat_map_id}/viewer") + def get_seat_map_viewer(seat_map_id: int, as_of: str | None = None) -> HTMLResponse: + layout = fetch_seat_layout(seat_map_id, parse_as_of(as_of)) + seat_map = layout.get("seat_map") or {} + if seat_map.get("source_type") not in {"dxf", "fixed_html"}: + raise HTTPException(status_code=400, detail="Viewer is only available for supported seat maps.") + return HTMLResponse(build_center_chair_viewer_html(layout)) + + @app.put("/api/seat-maps/{seat_map_id}/layout") + def update_seat_layout(seat_map_id: int, payload: seat_layout_payload_cls) -> dict[str, list[dict[str, object]]]: + return {"items": save_seat_layout(seat_map_id, payload)}