"""B01 대시보드 홈 API (PLAN 547) — 프로젝트 카드 요약 · 역할별 홈.""" from datetime import timedelta from typing import Any import aiomysql from fastapi import APIRouter, Depends, HTTPException, Query from common_util.common_util_auth import is_forest_email, verify_session from common_util.common_util_time import utc_now_naive, utc_text from config.config_db import get_db_pool from .B01_Dashboard_Repository import get_system_resources from .B01_Dashboard_Summary import project_summaries router = APIRouter(prefix="/api/dashboard", tags=["B01_Dashboard"]) SUMMARY_LIMIT = 200 # 홈 카드 썸네일은 앞 8 개만(9 넘음 꼴은 그림 대신 숫자 · 그래프) HOME_THUMBS = 8 EXPIRE_SOON_DAYS = 7 async def visible_project_ids(session: dict[str, Any]) -> list[str]: """보이는 프로젝트(최근 수정 순) — 시스템 관리자 = 전부 · 회사 = 회사 전체 · 없으면 내 것.""" where, args = "p.deleted_at IS NULL", () if session["role"] != "SYSTEM_ADMIN": if session.get("company_id"): where, args = where + " AND p.company_id = %s", (int(session["company_id"]),) else: where, args = where + " AND p.user_id = %s", (int(session["user_id"]),) return await _ids(where, args) async def _ids(where: str, args: tuple) -> list[str]: pool = get_db_pool() async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor: await cursor.execute( f"SELECT p.id FROM projects p WHERE {where} " "ORDER BY p.updated_at DESC, p.created_at DESC", args, ) return [str(r["id"]) for r in await cursor.fetchall()] @router.get("/projects/summary") async def projects_summary( ids: str | None = Query(None, max_length=8000, description="쉼표로 이은 프로젝트 id"), session: dict[str, Any] = Depends(verify_session), ): """카드 요약 — ids 없으면 보이는 프로젝트 전부(최근 수정 순) · 안 보이는 id 는 빠짐.""" visible = await visible_project_ids(session) if ids: allowed = set(visible) wanted = dict.fromkeys(pid.strip() for pid in ids.split(",")) visible = [pid for pid in wanted if pid in allowed] return {"status": "success", "projects": await project_summaries(visible[:SUMMARY_LIMIT])} def home_role(session: dict[str, Any]) -> str: """홈 역할 — 셸 등록부 `roleOf` 와 같은 차례(시스템 · 회사 관리자 · 산림청 *.go.kr · 그 밖).""" if session["role"] in ("SYSTEM_ADMIN", "ADMIN"): return session["role"] return "FOREST" if is_forest_email(session.get("email")) else "USER" async def home_project_ids(session: dict[str, Any], role: str) -> list[str]: """홈 프로젝트 — 시스템 관리자 = 전부 · 회사 관리자 = 회사 · 그 밖 = 내가 만든 · 참여한 것.""" if role in ("SYSTEM_ADMIN", "ADMIN") or not session.get("company_id"): return await visible_project_ids(session) me = int(session["user_id"]) return await _ids( "p.deleted_at IS NULL AND p.company_id = %s AND (p.user_id = %s OR EXISTS " "(SELECT 1 FROM project_members m WHERE m.project_id = p.id AND m.user_id = %s))", (int(session["company_id"]), me, me), ) async def _one(cursor: Any, sql: str, args: tuple = ()) -> dict[str, Any]: await cursor.execute(sql, args) return await cursor.fetchone() or {} async def _todos(cursor: Any, session: dict[str, Any], role: str) -> dict[str, Any]: """처리할 일 · 보관함 — 받은 예정노선(새로 · 업데이트) · 가입 요청 · 곧 만료 보관 파일.""" from D01_PlanRegister.D01_PlanRegister_Router import assigned_plan_routes company_id = session.get("company_id") plans = (await assigned_plan_routes(session))["routes"] if company_id else [] pick = ("project_id", "project_name", "year", "linked_project_id") todos: dict[str, Any] = { "new_plans": [ {k: p[k] for k in pick} for p in plans if not p["linked_project_id"] and p["confirmed_at"] ], "updated_plans": [{k: p[k] for k in pick} for p in plans if p["update_available"]], } if role == "SYSTEM_ADMIN" or (role == "ADMIN" and company_id): where = "" if role == "SYSTEM_ADMIN" else " AND company_id = %s" row = await _one( cursor, f"SELECT COUNT(*) AS n FROM join_requests WHERE status = 'PENDING'{where}", () if role == "SYSTEM_ADMIN" else (int(company_id),), ) todos["join_requests"] = int(row.get("n") or 0) await cursor.execute( """SELECT id, name, status, expires_at FROM temp_upload_batches WHERE user_id = %s AND status <> 'linked' AND (expires_at IS NULL OR expires_at > NOW()) ORDER BY expires_at IS NULL, expires_at""", (int(session["user_id"]),), ) batches = list(await cursor.fetchall()) soon = utc_now_naive() + timedelta(days=EXPIRE_SOON_DAYS) todos["expiring_temp"] = [ { "batch_id": str(b["id"]), "name": b["name"], "expires_at": utc_text(b["expires_at"]), "days_left": max(0, (b["expires_at"] - utc_now_naive()).days), } for b in batches if b["expires_at"] and b["expires_at"] <= soon ] todos["temp_count"] = len(batches) return todos async def _company(cursor: Any, company_id: int) -> dict[str, Any]: row = await _one( cursor, """SELECT c.name, (SELECT COUNT(*) FROM users u WHERE u.company_id = c.id AND u.deleted_at IS NULL) AS member_count FROM companies c WHERE c.id = %s""", (company_id,), ) return {"id": company_id, "name": row.get("name"), "member_count": row.get("member_count")} async def _forest(cursor: Any, company_id: int) -> dict[str, Any]: """예정노선 수 — 분석 전 = 노선을 아직 안 그은 프로젝트 · 분석됨 · 확정 = 연도(설계 내용 파일) · 업체 지정 대기 = 확정 연도 중 그 연도 세트에 업체가 하나도 없는 것(업체 줄 = 세트 열쇠 · 옛 조각 id = 든 세트).""" from D03_PlanAnalysis import D03_PlanAnalysis_Store as store await cursor.execute( """SELECT p.id, a.piece_id FROM projects p LEFT JOIN plan_route_assignees a ON a.project_id = p.id WHERE p.company_id = %s AND p.project_kind = 'PLAN' AND p.deleted_at IS NULL""", (company_id,), ) assigned: dict[str, set] = {} for r in await cursor.fetchall(): assigned.setdefault(str(r["id"]), set()).add(r["piece_id"]) status = {"draft": 0, "analyzed": 0, "confirmed": 0} waiting = 0 for project_id, pieces in assigned.items(): try: state = await store.plan_state(project_id) except HTTPException: # 노선 파일이 틀린 프로젝트 = 건너뜀 continue if not state["plan"]["lines"]: status["draft"] += 1 found = (store.find_unit(state["units"], key) for key in pieces) held = {unit["id"] for unit in found if unit} for year, row in state["years"].items(): key = "confirmed" if row["confirmed_at"] else "analyzed" status[key] += 1 ids = {u["id"] for u in state["units"] if u["year"] == year} waiting += bool(row["confirmed_at"] and not ids & held) return {"plan_status": status, "assign_waiting": waiting} async def _system(cursor: Any) -> dict[str, Any]: row = await _one( cursor, """SELECT (SELECT COUNT(*) FROM companies WHERE deleted_at IS NULL) AS companies, (SELECT COUNT(*) FROM users WHERE deleted_at IS NULL) AS users""", ) await cursor.execute("SELECT id, name FROM companies WHERE deleted_at IS NULL") names = {int(r["id"]): r["name"] for r in await cursor.fetchall()} current = (await get_system_resources(days=1))["current"] server = {k: current.get(f"{k}_usage_percent") for k in ("cpu", "memory", "disk")} return { "companies": int(row.get("companies") or 0), "users": int(row.get("users") or 0), "company_names": names, "server": server, } @router.get("/home") async def dashboard_home(session: dict[str, Any] = Depends(verify_session)): """역할별 홈 — 진행 중 프로젝트 카드(최근 수정 순) · 처리할 일 · 회사 · 산림청 · 시스템 요약. 단계 분포 · 팀원별 · 종류별 · 회사별 수는 화면이 `projects` 로 셈(같은 목록 한 벌). """ role = home_role(session) ids = await home_project_ids(session, role) cards = [s for s in await project_summaries(ids[:SUMMARY_LIMIT]) if not s["done"]] for card in cards[HOME_THUMBS:]: card["thumb"] = None company_id = session.get("company_id") pool = get_db_pool() async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor: body: dict[str, Any] = { "status": "success", "role": role, "projects": cards, "project_total": len(ids), "todos": await _todos(cursor, session, role), } if role == "ADMIN" and company_id: body["company"] = await _company(cursor, int(company_id)) if role == "FOREST" and company_id: body["forest"] = await _forest(cursor, int(company_id)) if role == "SYSTEM_ADMIN": body["system"] = await _system(cursor) return body