""" CMDB: Institution + Server 관리 + C-1 CI 형상 관리 확장 엔드포인트: 기존: /api/cmdb/institutions, /api/cmdb/servers C-1: POST /api/cmdb/ci — CI 생성 GET /api/cmdb/ci — CI 목록 GET /api/cmdb/ci/{ci_id} — CI 상세 + 관계 + 변경 이력 PATCH /api/cmdb/ci/{ci_id} — CI 수정 DELETE /api/cmdb/ci/{ci_id} — CI 폐기(RETIRED 처리) POST /api/cmdb/ci/relations — 관계 추가 DELETE /api/cmdb/ci/relations/{id} — 관계 삭제 GET /api/cmdb/ci/{ci_id}/history — 변경 이력 GET /api/cmdb/ci/stats — CI 통계 POST /api/cmdb/ci/import-servers — 기존 서버 → CI 일괄 등록 """ from datetime import datetime, date from typing import Dict, List, Optional import json from fastapi import APIRouter, Depends, HTTPException, Query from sqlalchemy import select, desc, func, and_ from sqlalchemy.ext.asyncio import AsyncSession from core.auth import get_current_user from database import get_db from middleware.license_guard import check_server_limit from models import ( Institution, InstitutionOut, Server, ServerOut, ServerUpdate, User, UserRole, ConfigItem, ConfigItemOut, ConfigItemCreate, ConfigItemUpdate, CIRelation, CIRelationOut, CIRelationCreate, CIChangeLog, CIChangeLogOut, CIChangeType, ) router = APIRouter(prefix="/api/cmdb", tags=["cmdb"]) @router.get("/institutions", response_model=List[InstitutionOut]) async def list_institutions(db: AsyncSession = Depends(get_db), _u: User = Depends(get_current_user)): result = await db.execute(select(Institution).order_by(Institution.inst_name)) return result.scalars().all() @router.get("/institutions/{inst_code}/servers", response_model=List[ServerOut]) async def list_servers(inst_code: str, db: AsyncSession = Depends(get_db), _u: User = Depends(get_current_user)): r = await db.execute( select(Institution).where(Institution.inst_code == inst_code) ) inst = r.scalars().first() if not inst: raise HTTPException(404, detail="기관을 찾을 수 없습니다.") result = await db.execute( select(Server).where(Server.inst_id == inst.id, Server.is_active == True) .order_by(Server.server_role, Server.server_name) ) return result.scalars().all() @router.get("/servers", response_model=List[ServerOut]) async def list_all_servers( role: Optional[str] = Query(None), inst_code: Optional[str] = Query(None), db: AsyncSession = Depends(get_db), _u: User = Depends(get_current_user), ): q = select(Server).where(Server.is_active == True) if role: q = q.where(Server.server_role == role) if inst_code: r = await db.execute(select(Institution).where(Institution.inst_code == inst_code)) inst = r.scalars().first() if inst: q = q.where(Server.inst_id == inst.id) q = q.order_by(Server.server_role, Server.server_name) result = await db.execute(q) return result.scalars().all() @router.get("/servers/{server_id}", response_model=ServerOut) async def get_server( server_id: int, db: AsyncSession = Depends(get_db), _u: User = Depends(get_current_user), ): r = await db.execute(select(Server).where(Server.id == server_id)) server = r.scalars().first() if not server: raise HTTPException(404, "서버를 찾을 수 없습니다.") return server @router.patch("/servers/{server_id}", response_model=ServerOut) async def update_server( server_id: int, payload: ServerUpdate, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): if current_user.role not in (UserRole.ADMIN, UserRole.PM, UserRole.ENGINEER): raise HTTPException(403, "권한이 없습니다.") r = await db.execute(select(Server).where(Server.id == server_id)) server = r.scalars().first() if not server: raise HTTPException(404, "서버를 찾을 수 없습니다.") for k, v in payload.model_dump(exclude_unset=True).items(): setattr(server, k, v) server.updated_at = datetime.now() await db.commit() await db.refresh(server) return server @router.post("/servers", response_model=ServerOut, status_code=201) async def create_server( payload: dict, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), _: None = Depends(check_server_limit), ): """새 서버 자산 등록.""" if current_user.role not in (UserRole.ADMIN, UserRole.PM): raise HTTPException(403, "ADMIN 또는 PM 권한이 필요합니다.") # inst_code → inst_id 변환 inst_code = payload.pop("inst_code", None) inst_id = None if inst_code: r = await db.execute( select(Institution).where(Institution.inst_code == inst_code) ) inst = r.scalars().first() if inst: inst_id = inst.id # sensitive fields: if ip_addr/ssh_user/os_pw_enc provided, store them (encryption handled elsewhere) server = Server(inst_id=inst_id, **{k: v for k, v in payload.items() if hasattr(Server, k)}) db.add(server) await db.commit() await db.refresh(server) return server # ════════════════════════════════════════════════════════════════════════════════ # C-1: CI 형상 관리 (Configuration Item Management) # ════════════════════════════════════════════════════════════════════════════════ async def _next_ci_id(db: AsyncSession) -> str: """CI-YYYYMMDD-NNNN 형식 CI ID 생성.""" today = datetime.utcnow().strftime("%Y%m%d") prefix = f"CI-{today}-" last = (await db.execute( select(ConfigItem.ci_id) .where(ConfigItem.ci_id.like(f"{prefix}%")) .order_by(desc(ConfigItem.ci_id)) .limit(1) )).scalar() seq = 1 if last: try: seq = int(last.split("-")[-1]) + 1 except ValueError: seq = 1 return f"{prefix}{seq:04d}" async def _log_ci_change( db: AsyncSession, ci: ConfigItem, change_type: str, changed_by: str, field_name: Optional[str] = None, old_value: Optional[str] = None, new_value: Optional[str] = None, sr_id: Optional[str] = None, note: Optional[str] = None, ): """CI 변경 이력 기록.""" log = CIChangeLog( ci_id_fk = ci.id, ci_id_str = ci.ci_id, change_type = change_type, field_name = field_name, old_value = old_value, new_value = new_value, changed_by = changed_by, sr_id = sr_id, note = note, ) db.add(log) # ── CI CRUD ─────────────────────────────────────────────────────────────────── @router.post("/ci", response_model=ConfigItemOut, status_code=201) async def create_ci( body: ConfigItemCreate, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): """새 형상 항목(CI) 등록.""" ci_id = await _next_ci_id(db) ci = ConfigItem( ci_id = ci_id, name = body.name, ci_type = body.ci_type.upper(), category = body.category, status = body.status.upper(), version = body.version, owner = body.owner, location = body.location, vendor = body.vendor, model = body.model, serial_number = body.serial_number, install_date = body.install_date, retire_date = body.retire_date, linked_server_id = body.linked_server_id, inst_id = body.inst_id, sr_id = body.sr_id, attributes_json = json.dumps(body.attributes, ensure_ascii=False) if body.attributes else None, description = body.description, created_by = current_user.username, ) db.add(ci) await db.flush() await _log_ci_change( db, ci, change_type = CIChangeType.CREATE.value, changed_by = current_user.username, new_value = body.name, note = "CI 신규 등록", ) await db.commit() await db.refresh(ci) return ci @router.get("/ci", response_model=List[ConfigItemOut]) async def list_ci( ci_type: Optional[str] = Query(None), status: Optional[str] = Query(None), inst_id: Optional[int] = Query(None), keyword: Optional[str] = Query(None), limit: int = Query(50, ge=1, le=200), offset: int = Query(0, ge=0), db: AsyncSession = Depends(get_db), _u: User = Depends(get_current_user), ): """CI 목록 조회.""" conditions = [] if ci_type: conditions.append(ConfigItem.ci_type == ci_type.upper()) if status: conditions.append(ConfigItem.status == status.upper()) if inst_id: conditions.append(ConfigItem.inst_id == inst_id) if keyword: conditions.append(ConfigItem.name.ilike(f"%{keyword}%")) q = select(ConfigItem) if conditions: q = q.where(and_(*conditions)) q = q.order_by(desc(ConfigItem.created_at)).limit(limit).offset(offset) rows = (await db.execute(q)).scalars().all() return rows @router.get("/ci/stats") async def ci_stats( db: AsyncSession = Depends(get_db), _u: User = Depends(get_current_user), ): """CI 현황 통계.""" total = (await db.execute( select(func.count()).select_from(ConfigItem) )).scalar() or 0 by_type = {} type_rows = (await db.execute( select(ConfigItem.ci_type, func.count()) .group_by(ConfigItem.ci_type) )).all() for ci_type, cnt in type_rows: by_type[ci_type] = cnt by_status = {} status_rows = (await db.execute( select(ConfigItem.status, func.count()) .group_by(ConfigItem.status) )).all() for st, cnt in status_rows: by_status[st] = cnt relation_count = (await db.execute( select(func.count()).select_from(CIRelation) )).scalar() or 0 change_count = (await db.execute( select(func.count()).select_from(CIChangeLog) )).scalar() or 0 return { "total_ci": total, "by_type": by_type, "by_status": by_status, "total_relations": relation_count, "total_changes": change_count, } @router.get("/ci/{ci_id}", response_model=ConfigItemOut) async def get_ci( ci_id: str, db: AsyncSession = Depends(get_db), _u: User = Depends(get_current_user), ): """CI 상세 조회.""" ci = (await db.execute( select(ConfigItem).where(ConfigItem.ci_id == ci_id) )).scalars().first() if not ci: raise HTTPException(404, f"CI {ci_id}를 찾을 수 없습니다.") return ci @router.patch("/ci/{ci_id}", response_model=ConfigItemOut) async def update_ci( ci_id: str, body: ConfigItemUpdate, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): """CI 수정 — 변경된 필드마다 이력 기록.""" ci = (await db.execute( select(ConfigItem).where(ConfigItem.ci_id == ci_id) )).scalars().first() if not ci: raise HTTPException(404, f"CI {ci_id}를 찾을 수 없습니다.") update_data = body.model_dump(exclude_unset=True, exclude_none=True) sr_id_for_log = update_data.pop("sr_id", None) for field, new_val in update_data.items(): if field == "attributes": old_v = ci.attributes_json ci.attributes_json = json.dumps(new_val, ensure_ascii=False) if new_val else None new_v = ci.attributes_json else: old_v = str(getattr(ci, field, "")) if getattr(ci, field, None) is not None else None setattr(ci, field, new_val) new_v = str(new_val) # 상태 변경은 별도 change_type 사용 ct = CIChangeType.STATUS_CHANGE.value if field == "status" else CIChangeType.UPDATE.value await _log_ci_change( db, ci, ct, changed_by = current_user.username, field_name = field, old_value = old_v, new_value = new_v, sr_id = sr_id_for_log, ) await db.commit() await db.refresh(ci) return ci @router.delete("/ci/{ci_id}", status_code=204) async def retire_ci( ci_id: str, note: Optional[str] = Query(None, description="폐기 사유"), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): """CI 폐기 처리 (RETIRED 상태 전환 — 데이터 보존).""" ci = (await db.execute( select(ConfigItem).where(ConfigItem.ci_id == ci_id) )).scalars().first() if not ci: raise HTTPException(404, f"CI {ci_id}를 찾을 수 없습니다.") if ci.status == "RETIRED": raise HTTPException(400, "이미 폐기된 CI입니다.") old_status = ci.status ci.status = "RETIRED" ci.retire_date = datetime.utcnow().date() await _log_ci_change( db, ci, change_type = CIChangeType.RETIRE.value, changed_by = current_user.username, field_name = "status", old_value = old_status, new_value = "RETIRED", note = note or "폐기 처리", ) await db.commit() # ── CI 관계 관리 ───────────────────────────────────────────────────────────── @router.post("/ci/relations", response_model=CIRelationOut, status_code=201) async def add_ci_relation( body: CIRelationCreate, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): """CI 간 관계 추가.""" from_ci = (await db.execute(select(ConfigItem).where(ConfigItem.id == body.from_ci_id))).scalars().first() to_ci = (await db.execute(select(ConfigItem).where(ConfigItem.id == body.to_ci_id))).scalars().first() if not from_ci: raise HTTPException(404, f"출발 CI id={body.from_ci_id} 없음") if not to_ci: raise HTTPException(404, f"도착 CI id={body.to_ci_id} 없음") # 중복 검사 existing = (await db.execute( select(CIRelation).where( and_( CIRelation.from_ci_id == body.from_ci_id, CIRelation.to_ci_id == body.to_ci_id, CIRelation.relation_type == body.relation_type.upper(), ) ) )).scalars().first() if existing: raise HTTPException(409, "동일 관계가 이미 존재합니다.") relation = CIRelation( from_ci_id = body.from_ci_id, to_ci_id = body.to_ci_id, relation_type = body.relation_type.upper(), description = body.description, created_by = current_user.username, ) db.add(relation) await db.flush() # 양쪽 CI에 이력 기록 for ci, note in [ (from_ci, f"{body.relation_type} → CI#{body.to_ci_id}({to_ci.name}) 관계 추가"), (to_ci, f"CI#{body.from_ci_id}({from_ci.name}) ← {body.relation_type} 관계 추가"), ]: await _log_ci_change( db, ci, change_type = CIChangeType.RELATION_ADD.value, changed_by = current_user.username, note = note, ) await db.commit() await db.refresh(relation) return relation @router.delete("/ci/relations/{relation_id}", status_code=204) async def remove_ci_relation( relation_id: int, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): """CI 관계 삭제.""" rel = (await db.execute(select(CIRelation).where(CIRelation.id == relation_id))).scalars().first() if not rel: raise HTTPException(404, "관계를 찾을 수 없습니다.") from_ci = (await db.execute(select(ConfigItem).where(ConfigItem.id == rel.from_ci_id))).scalars().first() to_ci = (await db.execute(select(ConfigItem).where(ConfigItem.id == rel.to_ci_id))).scalars().first() for ci in [c for c in [from_ci, to_ci] if c]: await _log_ci_change( db, ci, change_type = CIChangeType.RELATION_DEL.value, changed_by = current_user.username, note = f"관계(id={relation_id}, type={rel.relation_type}) 삭제", ) await db.delete(rel) await db.commit() @router.get("/ci/{ci_id}/relations") async def get_ci_relations( ci_id: str, db: AsyncSession = Depends(get_db), _u: User = Depends(get_current_user), ): """CI의 모든 관계 조회 (출발+도착 모두).""" ci = (await db.execute(select(ConfigItem).where(ConfigItem.ci_id == ci_id))).scalars().first() if not ci: raise HTTPException(404, f"CI {ci_id} 없음") # 출발 관계 out_rels = (await db.execute( select(CIRelation).where(CIRelation.from_ci_id == ci.id) )).scalars().all() # 도착 관계 in_rels = (await db.execute( select(CIRelation).where(CIRelation.to_ci_id == ci.id) )).scalars().all() # 관련 CI 이름 보강 async def enrich(rel: CIRelation, direction: str): other_id = rel.to_ci_id if direction == "outbound" else rel.from_ci_id other_ci = (await db.execute(select(ConfigItem).where(ConfigItem.id == other_id))).scalars().first() return { "id": rel.id, "direction": direction, "relation_type": rel.relation_type, "from_ci_id": rel.from_ci_id, "to_ci_id": rel.to_ci_id, "other_ci_id": other_ci.ci_id if other_ci else None, "other_ci_name": other_ci.name if other_ci else None, "description": rel.description, "created_at": rel.created_at.isoformat(), } result = [] for r in out_rels: result.append(await enrich(r, "outbound")) for r in in_rels: result.append(await enrich(r, "inbound")) return {"ci_id": ci_id, "relations": result} # ── CI 변경 이력 ───────────────────────────────────────────────────────────── @router.get("/ci/{ci_id}/history", response_model=List[CIChangeLogOut]) async def get_ci_history( ci_id: str, limit: int = Query(50, ge=1, le=200), db: AsyncSession = Depends(get_db), _u: User = Depends(get_current_user), ): """CI 변경 이력 조회.""" ci = (await db.execute(select(ConfigItem).where(ConfigItem.ci_id == ci_id))).scalars().first() if not ci: raise HTTPException(404, f"CI {ci_id} 없음") logs = (await db.execute( select(CIChangeLog) .where(CIChangeLog.ci_id_fk == ci.id) .order_by(desc(CIChangeLog.changed_at)) .limit(limit) )).scalars().all() return logs # ── 서버 → CI 일괄 등록 ─────────────────────────────────────────────────────── @router.post("/ci/import-servers") async def import_servers_as_ci( inst_id: Optional[int] = Query(None, description="특정 기관만 가져오기"), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): """ 기존 등록된 서버를 CI로 일괄 등록 (이미 CI가 있는 서버는 스킵). """ if current_user.role not in (UserRole.ADMIN, UserRole.PM, UserRole.ENGINEER): raise HTTPException(403, "권한 부족") q = select(Server).where(Server.is_active == True) if inst_id: q = q.where(Server.inst_id == inst_id) servers = (await db.execute(q)).scalars().all() created, skipped = 0, 0 for srv in servers: # 이미 linked_server_id로 CI가 있으면 스킵 existing = (await db.execute( select(ConfigItem).where(ConfigItem.linked_server_id == srv.id) )).scalars().first() if existing: skipped += 1 continue ci_id = await _next_ci_id(db) # server_role → ci_type 매핑 type_map = { "WEB": "SERVER", "WAS": "SERVER", "DB": "DATABASE", "ESB": "MIDDLEWARE", "BATCH": "SERVER", } srv_role = getattr(srv, "server_role", "") or "" ci_type = type_map.get(srv_role.upper(), "SERVER") ci = ConfigItem( ci_id = ci_id, name = srv.server_name, ci_type = ci_type, category = srv_role or "SERVER", status = "ACTIVE" if srv.is_active else "INACTIVE", owner = srv.maintenance_contact, install_date = srv.install_date, retire_date = srv.eol_date, linked_server_id = srv.id, inst_id = srv.inst_id, description = f"서버 자동 등록: {srv.server_name} ({srv.os_type or ''})", created_by = current_user.username, ) db.add(ci) await db.flush() await _log_ci_change( db, ci, change_type = CIChangeType.CREATE.value, changed_by = current_user.username, note = f"서버 일괄 등록 (server_id={srv.id})", ) created += 1 await db.commit() return { "created": created, "skipped": skipped, "total_servers": len(servers), }