bmc_hub/app/modules/hardware/backend/inventory.py

128 lines
6.8 KiB
Python

"""Read-only Gateway inventory lookup for reviewed asset creation."""
import asyncio
import json
from typing import Literal
from urllib.parse import quote
import aiohttp
from fastapi import APIRouter, HTTPException, Query
from pydantic import BaseModel, Field
from psycopg2.extras import Json
from app.core.database import execute_query
from app.core.config import settings
from app.products.backend.router import _apigw_base_url, _apigw_headers
router = APIRouter(prefix='/hardware-intake/inventory')
def inventory_item(record):
raw = record.get('json_data', record.get('hardware_data')) or {}
if isinstance(raw, str):
raw = json.loads(raw)
hardware = raw.get('hardware') or {}
return dict(id=record['id'], name=record.get('computer_name') or '',
brand=record.get('manufacturer') or hardware.get('manufacturer') or '',
model=record.get('model') or hardware.get('model') or '',
serial_number=record.get('serial_number') or '',
os=record.get('os_caption') or (raw.get('os') or {}).get('caption') or '',
cpu=record.get('cpu_name') or (hardware.get('cpu') or {}).get('name') or '',
ram_gb=record.get('ram_gb', hardware.get('ram_gb')),
last_seen=record.get('last_seen') or record.get('collected_at'),
hardware_specs={'gateway_inventory': {'id':record['id'], 'collected_at':record.get('last_seen') or record.get('collected_at'), 'data':raw, 'name':record.get('computer_name'), 'os':record.get('os_caption') or (raw.get('os') or {}).get('caption'), 'cpu':record.get('cpu_name') or (hardware.get('cpu') or {}).get('name'), 'ram_gb':record.get('ram_gb', hardware.get('ram_gb'))}})
@router.get('')
async def search_inventory(q: str = Query('', max_length=160), by: Literal['name', 'serial'] = 'name', page: int = Query(1, ge=1)):
path = '/api/inventory'
params = {'per_page': 20, 'page': page}
if by == 'serial':
if not q.strip():
raise HTTPException(422, 'Indtast et serienummer')
path += '/serial/' + quote(q.strip(), safe='')
params = {}
elif q.strip():
params['computer_name'] = q.strip()
headers = _apigw_headers()
try:
async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=settings.APIGW_TIMEOUT_SECONDS)) as session:
async with session.get(_apigw_base_url() + path, headers=headers, params=params, allow_redirects=False) as response:
if response.status == 404 and by == 'serial':
record = await find_inventory_in_list(session, headers, serial=q.strip())
return {'items': [inventory_item(record)] if record else [], 'pagination': {}}
if response.status != 200:
raise HTTPException(502, 'API Gateway kunne ikke levere inventory. Kontrollér adgang og tilgængelighed.')
payload = await response.json()
records = [payload] if by == 'serial' else payload['data']
return {'items': [inventory_item(record) for record in records], 'pagination': payload.get('pagination', {})}
except (aiohttp.ClientError, asyncio.TimeoutError, ValueError, KeyError, TypeError, AttributeError):
raise HTTPException(502, 'Inventory kunne ikke læses fra API Gateway') from None
class InventoryPairInput(BaseModel):
inventory_id: int = Field(gt=0)
async def find_inventory_in_list(session, headers, *, inventory_id=None, serial=None):
"""Some deployed Gateways expose the list but lack working detail routes."""
page = 1
while page <= 1000:
async with session.get(f'{_apigw_base_url()}/api/inventory', headers=headers,
params={'per_page': 100, 'page': page}, allow_redirects=False) as response:
if response.status != 200:
raise HTTPException(502, 'Inventory-listen kunne ikke hentes fra API Gateway')
payload = await response.json()
for record in payload['data']:
if inventory_id is not None and str(record['id']) == str(inventory_id):
return record
if serial is not None and str(record.get('serial_number') or '').strip().casefold() == serial.casefold():
return record
total_pages = int(payload['pagination']['total_pages'])
if page >= total_pages:
return None
page += 1
raise HTTPException(502, 'Inventory-listen er for stor til at gennemføre opslaget')
async def fetch_inventory_record(inventory_id: int):
headers = _apigw_headers()
try:
async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=settings.APIGW_TIMEOUT_SECONDS)) as session:
async with session.get(f'{_apigw_base_url()}/api/inventory/{inventory_id}', headers=headers, allow_redirects=False) as response:
if response.status == 404:
record = await find_inventory_in_list(session, headers, inventory_id=inventory_id)
if record is None:
raise HTTPException(404, 'Inventory-maskinen findes ikke længere. Søg igen for at opdatere listen.')
return inventory_item(record)
if response.status != 200:
raise HTTPException(502, 'Inventory-maskinen kunne ikke hentes fra API Gateway')
return inventory_item(await response.json())
except (aiohttp.ClientError, asyncio.TimeoutError, ValueError, KeyError, TypeError, AttributeError):
raise HTTPException(502, 'Inventory kunne ikke læses fra API Gateway') from None
@router.post('/{hardware_id}/pair')
async def pair_inventory(hardware_id: int, payload: InventoryPairInput):
if not execute_query('SELECT id FROM hardware_assets WHERE id=%s AND deleted_at IS NULL', (hardware_id,)):
raise HTTPException(404, 'Hardware findes ikke')
item = await fetch_inventory_record(payload.inventory_id)
# Merge in PostgreSQL so concurrent updates and acquisition/ESET/UISP data survive.
rows = execute_query("""UPDATE hardware_assets
SET hardware_specs = COALESCE(hardware_specs, '{}'::jsonb) || %s::jsonb,
updated_at = NOW()
WHERE id=%s AND deleted_at IS NULL RETURNING id""",
(Json(item['hardware_specs']), hardware_id))
if not rows:
raise HTTPException(404, 'Hardware findes ikke')
return {'hardware_id': hardware_id, 'inventory': item}
@router.delete('/{hardware_id}/pair')
async def unpair_inventory(hardware_id: int):
rows = execute_query("""UPDATE hardware_assets
SET hardware_specs = COALESCE(hardware_specs, '{}'::jsonb) - 'gateway_inventory',
updated_at = NOW()
WHERE id=%s AND deleted_at IS NULL RETURNING id""", (hardware_id,))
if not rows:
raise HTTPException(404, 'Hardware findes ikke')
return {'hardware_id': hardware_id, 'unpaired': True}