""" 자산 QR 태그 — 서버·장비에 QR 부착 → 스캔 → CMDB 조회 엔드포인트: POST /api/asset-qr/generate/{server_id} — QR 코드 생성 GET /api/asset-qr/scan/{qr_token} — QR 스캔 → 자산 정보 POST /api/asset-qr/checkin/{qr_token} — 실사 체크인 GET /api/asset-qr/print/{server_id} — 인쇄용 QR 라벨 HTML GET /api/asset-qr/batch-print — 다수 자산 일괄 인쇄 GET /api/asset-qr/list — QR 발행 목록 GET /api/asset-qr/audit-log/{server_id}— 스캔 이력 """ from __future__ import annotations import base64 import io import uuid from datetime import datetime from typing import Optional from fastapi import APIRouter, Depends, HTTPException, Request from fastapi.responses import HTMLResponse from pydantic import BaseModel from sqlalchemy import select, desc from sqlalchemy.ext.asyncio import AsyncSession from core.auth import get_current_user, require_admin_role from database import get_db from models import User, Server, AssetQRToken, AssetQRScanLog router = APIRouter(prefix="/api/asset-qr", tags=["자산 QR 태그"]) BASE_URL = "https://zioinfo.co.kr:8443" def _gen_qr(url: str) -> str: """QR base64 PNG 생성.""" try: import qrcode qr = qrcode.QRCode(version=1, box_size=8, border=3) qr.add_data(url) qr.make(fit=True) img = qr.make_image(fill_color="#003366", back_color="white") buf = io.BytesIO() img.save(buf, format='PNG') return base64.b64encode(buf.getvalue()).decode() except ImportError: return "" class CheckinRequest(BaseModel): note: Optional[str] = None location: Optional[str] = None @router.post("/generate/{server_id}") async def generate_qr( server_id: int, db: AsyncSession = Depends(get_db), user: User = Depends(require_admin_role), ): srv_row = await db.execute(select(Server).where(Server.id == server_id)) server = srv_row.scalar_one_or_none() if not server: raise HTTPException(404, "서버 없음") # 기존 QR 확인 existing = await db.execute(select(AssetQRToken).where(AssetQRToken.server_id == server_id)) token_obj = existing.scalar_one_or_none() if not token_obj: token = uuid.uuid4().hex scan_url = f"{BASE_URL}/api/asset-qr/scan/{token}" qr_b64 = _gen_qr(scan_url) token_obj = AssetQRToken( qr_token=token, server_id=server_id, qr_data=qr_b64, scan_count=0, created_at=datetime.utcnow(), ) db.add(token_obj) await db.commit() await db.refresh(token_obj) return { "ok": True, "qr_token": token_obj.qr_token, "scan_url": f"{BASE_URL}/api/asset-qr/scan/{token_obj.qr_token}", "print_url": f"{BASE_URL}/api/asset-qr/print/{server_id}", "qr_image_b64": token_obj.qr_data, } @router.get("/scan/{qr_token}") async def scan_qr( qr_token: str, request: Request, db: AsyncSession = Depends(get_db), ): """QR 스캔 → 자산 정보 반환. 인증 불필요 (공개 기본 정보).""" row = await db.execute(select(AssetQRToken).where(AssetQRToken.qr_token == qr_token)) token_obj = row.scalar_one_or_none() if not token_obj: raise HTTPException(404, "유효하지 않은 QR 코드") srv_row = await db.execute(select(Server).where(Server.id == token_obj.server_id)) server = srv_row.scalar_one_or_none() if not server: raise HTTPException(404, "서버 정보 없음") # 스캔 횟수 증가 token_obj.scan_count = (token_obj.scan_count or 0) + 1 token_obj.last_scan_at = datetime.utcnow() # 스캔 로그 log = AssetQRScanLog( qr_token=qr_token, scan_type="VIEW", user_agent=request.headers.get("User-Agent", "")[:200], scanned_at=datetime.utcnow(), ) db.add(log) await db.commit() return { "server_id": server.id, "hostname": server.server_name or "미설정", "ip_addr": "***.***.***.**", # 공개 응답에서 IP 마스킹 "os_type": server.os_type or "미상", "cpu_cores": server.cpu_cores, "memory_gb": round((server.memory_mb or 0) / 1024, 1), "scan_count": token_obj.scan_count, "last_scan_at": token_obj.last_scan_at, "checkin_url": f"{BASE_URL}/api/asset-qr/checkin/{qr_token}", } @router.post("/checkin/{qr_token}") async def checkin( qr_token: str, req: CheckinRequest, request: Request, db: AsyncSession = Depends(get_db), ): """실사 체크인 — 장비 위치·상태 기록.""" row = await db.execute(select(AssetQRToken).where(AssetQRToken.qr_token == qr_token)) token_obj = row.scalar_one_or_none() if not token_obj: raise HTTPException(404) log = AssetQRScanLog( qr_token=qr_token, scan_type="CHECKIN", user_agent=request.headers.get("User-Agent", "")[:200], location=req.location, note=req.note, scanned_at=datetime.utcnow(), ) db.add(log) await db.commit() return {"ok": True, "message": "실사 체크인 완료", "checked_at": log.scanned_at} @router.get("/print/{server_id}", response_class=HTMLResponse) async def print_label( server_id: int, db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user), ): """인쇄용 QR 라벨 HTML (50×30mm 라벨 크기).""" srv_row = await db.execute(select(Server).where(Server.id == server_id)) server = srv_row.scalar_one_or_none() if not server: raise HTTPException(404) token_row = await db.execute(select(AssetQRToken).where(AssetQRToken.server_id == server_id)) token_obj = token_row.scalar_one_or_none() if not token_obj: raise HTTPException(400, "먼저 QR을 생성하세요. POST /api/asset-qr/generate/{server_id}") qr_img = f'' if token_obj.qr_data else '' html = f""" QR 라벨 — {server.server_name}
{qr_img}
{server.server_name or '미설정'}
ID: {server.id}
{server.os_type or ''}
GUARDiA ITSM
""" return HTMLResponse(html) @router.get("/batch-print", response_class=HTMLResponse) async def batch_print( server_ids: str, # 쉼표 구분 "1,2,3" db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user), ): """다수 서버 QR 라벨 일괄 인쇄.""" ids = [int(x) for x in server_ids.split(",") if x.strip().isdigit()] labels = [] for sid in ids[:50]: srv_row = await db.execute(select(Server).where(Server.id == sid)) server = srv_row.scalar_one_or_none() if not server: continue token_row = await db.execute(select(AssetQRToken).where(AssetQRToken.server_id == sid)) token_obj = token_row.scalar_one_or_none() if not token_obj: continue qr_img = f'' if token_obj.qr_data else '' labels.append(f"""
{qr_img}
{server.server_name or '미설정'}
ID: {server.id}
{server.os_type or ''}
""") html = f"""
{''.join(labels)}
""" return HTMLResponse(html) @router.get("/list") async def list_qr_tokens( db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user), ): rows = await db.execute( select(AssetQRToken, Server.server_name, Server.ip_addr).join( Server, AssetQRToken.server_id == Server.id ).order_by(desc(AssetQRToken.created_at)).limit(100) ) return [ { "server_id": r.AssetQRToken.server_id, "hostname": r.server_name or "미설정", "ip": r.ip_addr, "scan_count": r.AssetQRToken.scan_count, "last_scan": r.AssetQRToken.last_scan_at, "qr_url": f"{BASE_URL}/api/asset-qr/scan/{r.AssetQRToken.qr_token}", "print_url": f"{BASE_URL}/api/asset-qr/print/{r.AssetQRToken.server_id}", } for r in rows.all() ] @router.get("/audit-log/{server_id}") async def get_scan_log( server_id: int, db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user), ): token_row = await db.execute(select(AssetQRToken).where(AssetQRToken.server_id == server_id)) token_obj = token_row.scalar_one_or_none() if not token_obj: return {"logs": []} rows = await db.execute( select(AssetQRScanLog).where(AssetQRScanLog.qr_token == token_obj.qr_token) .order_by(desc(AssetQRScanLog.scanned_at)).limit(50) ) logs = rows.scalars().all() return {"logs": [ {"type": l.scan_type, "location": l.location, "note": l.note, "scanned_at": l.scanned_at} for l in logs ]}