refactor: split backend route domains
This commit is contained in:
172
backend/app/auth_routes.py
Normal file
172
backend/app/auth_routes.py
Normal file
@@ -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",
|
||||
}
|
||||
50
backend/app/integration_routes.py
Normal file
50
backend/app/integration_routes.py
Normal file
@@ -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()
|
||||
@@ -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,),
|
||||
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,
|
||||
)
|
||||
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),
|
||||
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,
|
||||
)
|
||||
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),
|
||||
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,
|
||||
)
|
||||
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),
|
||||
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,
|
||||
)
|
||||
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)}
|
||||
|
||||
140
backend/app/member_routes.py
Normal file
140
backend/app/member_routes.py
Normal file
@@ -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)}
|
||||
224
backend/app/seatmap_routes.py
Normal file
224
backend/app/seatmap_routes.py
Normal file
@@ -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)}
|
||||
Reference in New Issue
Block a user