guardia-itsm/routers/institutions.py
DESKTOP-TKLFCPRython 64c27c3509 feat(itsm): G-1~G-12 확장 기능 + 하네스/봇/설치스크립트 구현
G-1: 메신저 Webhook Relay + _send_to_room 실제 httpx 호출 구현
G-2: POST /api/tasks/bulk SR 대량작업 엔드포인트 (최대 100건)
G-3: 라이선스 만료 알림 스케줄러 (매일 09:00 KST)
G-4: 체험판 upgrade_banner 필드 + license.py 배너 로직
G-5: core/auto_rca.py + incidents/problem auto-rca 엔드포인트
G-6: core/deploy_impact.py + vibe impact-analysis 엔드포인트
G-7: core/ticket_classifier.py + SR 생성 시 AI 분류 + ai-suggestion API
G-8: VulnPatchRecord 모델 + vuln_scan 패치추적 4개 엔드포인트
G-9: core/jira_sync.py + gateway Jira/Confluence 연동 엔드포인트
G-10: core/push_notify.py + routers/push.py + PushSubscription 모델
G-11: approvals 다중승인 (위임/서명/기한초과/마감연장)
G-12: alembic.ini + migrations/ + cicd/migrate_to_postgres.sh

하네스: guardia-orchestrator 확장기능 Phase 반영
봇명령어: /sr /status /license /bulk 슬래시 명령어 추가
설치스크립트: setup/ (Ubuntu, CentOS, RHEL, Windows) --test 옵션 포함

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 18:18:52 +09:00

252 lines
8.6 KiB
Python

"""기관(사이트) + 담당자 등록·관리 엔드포인트."""
from datetime import datetime
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from core.auth import get_current_user
from database import get_db
from middleware.license_guard import check_institution_limit
from models import (
Institution, InstitutionCreate, InstitutionOut, InstitutionUpdate,
InstContact, InstContactCreate, InstContactOut, InstContactUpdate,
Server, User, UserRole,
)
router = APIRouter(prefix="/api/institutions", tags=["institutions"])
def _require_admin_or_pm(user: User):
if user.role not in (UserRole.ADMIN, UserRole.PM):
raise HTTPException(403, "ADMIN 또는 PM 권한이 필요합니다.")
# ── 기관 CRUD ──────────────────────────────────────────────────────────────────
@router.get("", response_model=List[InstitutionOut])
async def list_institutions(
keyword: Optional[str] = Query(None),
region: Optional[str] = Query(None),
is_active: Optional[bool] = Query(None),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
q = select(Institution).order_by(Institution.inst_name)
if keyword:
q = q.where(Institution.inst_name.contains(keyword))
if region:
q = q.where(Institution.region == region)
if is_active is not None:
q = q.where(Institution.is_active == is_active)
result = await db.execute(q)
return result.scalars().all()
@router.get("/{inst_code}", response_model=InstitutionOut)
async def get_institution(
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, "기관을 찾을 수 없습니다.")
return inst
@router.post("", response_model=InstitutionOut, status_code=201)
async def create_institution(
payload: InstitutionCreate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
_: None = Depends(check_institution_limit),
):
_require_admin_or_pm(current_user)
# 중복 코드 확인
dup = await db.execute(
select(Institution).where(Institution.inst_code == payload.inst_code)
)
if dup.scalars().first():
raise HTTPException(409, f"기관 코드 '{payload.inst_code}'가 이미 존재합니다.")
inst = Institution(**payload.model_dump())
db.add(inst)
await db.commit()
await db.refresh(inst)
return inst
@router.patch("/{inst_code}", response_model=InstitutionOut)
async def update_institution(
inst_code: str,
payload: InstitutionUpdate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
_require_admin_or_pm(current_user)
r = await db.execute(select(Institution).where(Institution.inst_code == inst_code))
inst = r.scalars().first()
if not inst:
raise HTTPException(404, "기관을 찾을 수 없습니다.")
for k, v in payload.model_dump(exclude_unset=True).items():
setattr(inst, k, v)
inst.updated_at = datetime.now()
await db.commit()
await db.refresh(inst)
return inst
@router.delete("/{inst_code}", status_code=204)
async def deactivate_institution(
inst_code: str,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
if current_user.role != UserRole.ADMIN:
raise HTTPException(403, "ADMIN 권한이 필요합니다.")
r = await db.execute(select(Institution).where(Institution.inst_code == inst_code))
inst = r.scalars().first()
if not inst:
raise HTTPException(404, "기관을 찾을 수 없습니다.")
inst.is_active = False
inst.updated_at = datetime.now()
await db.commit()
# ── 담당자 CRUD ────────────────────────────────────────────────────────────────
async def _get_inst(db: AsyncSession, inst_code: str) -> Institution:
r = await db.execute(select(Institution).where(Institution.inst_code == inst_code))
inst = r.scalars().first()
if not inst:
raise HTTPException(404, "기관을 찾을 수 없습니다.")
return inst
@router.get("/{inst_code}/contacts", response_model=List[InstContactOut])
async def list_contacts(
inst_code: str,
db: AsyncSession = Depends(get_db),
_u: User = Depends(get_current_user),
):
inst = await _get_inst(db, inst_code)
result = await db.execute(
select(InstContact)
.where(InstContact.inst_id == inst.id)
.order_by(InstContact.is_primary.desc(), InstContact.contact_name)
)
return result.scalars().all()
@router.post("/{inst_code}/contacts", response_model=InstContactOut, status_code=201)
async def add_contact(
inst_code: str,
payload: InstContactCreate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
_require_admin_or_pm(current_user)
inst = await _get_inst(db, inst_code)
# 주 담당자로 설정 시 기존 주 담당자 해제
if payload.is_primary:
await db.execute(
select(InstContact).where(
InstContact.inst_id == inst.id,
InstContact.is_primary == True
)
)
prev_primary = (await db.execute(
select(InstContact).where(
InstContact.inst_id == inst.id,
InstContact.is_primary == True
)
)).scalars().first()
if prev_primary:
prev_primary.is_primary = False
contact = InstContact(inst_id=inst.id, **payload.model_dump())
db.add(contact)
await db.commit()
await db.refresh(contact)
return contact
@router.patch("/{inst_code}/contacts/{contact_id}", response_model=InstContactOut)
async def update_contact(
inst_code: str,
contact_id: int,
payload: InstContactUpdate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
_require_admin_or_pm(current_user)
inst = await _get_inst(db, inst_code)
r = await db.execute(
select(InstContact).where(
InstContact.id == contact_id,
InstContact.inst_id == inst.id
)
)
contact = r.scalars().first()
if not contact:
raise HTTPException(404, "담당자를 찾을 수 없습니다.")
# 주 담당자로 변경 시 기존 주 담당자 해제
if payload.is_primary:
prev = (await db.execute(
select(InstContact).where(
InstContact.inst_id == inst.id,
InstContact.is_primary == True,
InstContact.id != contact_id
)
)).scalars().first()
if prev:
prev.is_primary = False
for k, v in payload.model_dump(exclude_unset=True).items():
setattr(contact, k, v)
await db.commit()
await db.refresh(contact)
return contact
@router.delete("/{inst_code}/contacts/{contact_id}", status_code=204)
async def delete_contact(
inst_code: str,
contact_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
if current_user.role != UserRole.ADMIN:
raise HTTPException(403, "ADMIN 권한이 필요합니다.")
inst = await _get_inst(db, inst_code)
r = await db.execute(
select(InstContact).where(
InstContact.id == contact_id,
InstContact.inst_id == inst.id
)
)
contact = r.scalars().first()
if not contact:
raise HTTPException(404, "담당자를 찾을 수 없습니다.")
contact.is_active = False
await db.commit()
# ── 기관 서버 목록 (기존 cmdb 대체) ───────────────────────────────────────────
@router.get("/{inst_code}/servers")
async def list_inst_servers(
inst_code: str,
db: AsyncSession = Depends(get_db),
_u: User = Depends(get_current_user),
):
from models import ServerOut
inst = await _get_inst(db, inst_code)
result = await db.execute(
select(Server)
.where(Server.inst_id == inst.id)
.order_by(Server.server_role, Server.server_name)
)
servers = result.scalars().all()
return [ServerOut.model_validate(s) for s in servers]