2026-01-31 23:16:24 +01:00
|
|
|
import logging
|
|
|
|
|
from typing import List, Optional
|
|
|
|
|
from fastapi import APIRouter, HTTPException, Query, UploadFile, File
|
|
|
|
|
from app.core.database import execute_query
|
2026-02-11 13:23:32 +01:00
|
|
|
from app.services.eset_service import eset_service
|
|
|
|
|
from psycopg2.extras import Json
|
2026-01-31 23:16:24 +01:00
|
|
|
from datetime import datetime, date
|
|
|
|
|
import os
|
|
|
|
|
import uuid
|
|
|
|
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
2026-02-11 13:23:32 +01:00
|
|
|
|
|
|
|
|
def _eset_extract_first_str(payload: dict, keys: List[str]) -> Optional[str]:
|
|
|
|
|
if payload is None:
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
key_set = {k.lower() for k in keys}
|
|
|
|
|
stack = [payload]
|
|
|
|
|
while stack:
|
|
|
|
|
current = stack.pop()
|
|
|
|
|
if isinstance(current, dict):
|
|
|
|
|
for k, v in current.items():
|
|
|
|
|
if k.lower() in key_set and isinstance(v, str) and v.strip():
|
|
|
|
|
return v.strip()
|
|
|
|
|
if isinstance(v, (dict, list)):
|
|
|
|
|
stack.append(v)
|
|
|
|
|
elif isinstance(current, list):
|
|
|
|
|
for item in current:
|
|
|
|
|
if isinstance(item, (dict, list)):
|
|
|
|
|
stack.append(item)
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _eset_extract_group_path(payload: dict) -> Optional[str]:
|
|
|
|
|
return _eset_extract_first_str(payload, ["parentGroup", "groupPath", "group", "path"])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _eset_extract_group_name(payload: dict) -> Optional[str]:
|
|
|
|
|
group_path = _eset_extract_group_path(payload)
|
|
|
|
|
if group_path and "/" in group_path:
|
|
|
|
|
name = group_path.split("/")[-1].strip()
|
|
|
|
|
return name or None
|
|
|
|
|
return group_path
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _eset_extract_company(payload: dict) -> Optional[str]:
|
|
|
|
|
company = _eset_extract_first_str(payload, ["company", "organization", "tenant", "customer", "userCompany"])
|
|
|
|
|
if company:
|
|
|
|
|
return company
|
|
|
|
|
group_path = _eset_extract_group_path(payload)
|
|
|
|
|
if group_path and "/" in group_path:
|
|
|
|
|
return group_path.split("/")[-1].strip() or None
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _eset_detect_asset_type(payload: dict) -> str:
|
|
|
|
|
device_type = _eset_extract_first_str(payload, ["deviceType", "type"])
|
|
|
|
|
if device_type:
|
|
|
|
|
val = device_type.lower()
|
|
|
|
|
if "server" in val:
|
|
|
|
|
return "server"
|
|
|
|
|
if "laptop" in val or "notebook" in val:
|
|
|
|
|
return "laptop"
|
|
|
|
|
return "pc"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _match_customer_exact(name: str) -> Optional[int]:
|
|
|
|
|
if not name:
|
|
|
|
|
return None
|
|
|
|
|
result = execute_query("SELECT id FROM customers WHERE LOWER(name) = LOWER(%s)", (name,))
|
|
|
|
|
if len(result or []) == 1:
|
|
|
|
|
return result[0]["id"]
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _get_contact_customer(contact_id: int) -> Optional[int]:
|
|
|
|
|
query = """
|
|
|
|
|
SELECT customer_id
|
|
|
|
|
FROM contact_companies
|
|
|
|
|
WHERE contact_id = %s
|
|
|
|
|
ORDER BY is_primary DESC, id ASC
|
|
|
|
|
LIMIT 1
|
|
|
|
|
"""
|
|
|
|
|
result = execute_query(query, (contact_id,))
|
|
|
|
|
if result:
|
|
|
|
|
return result[0]["customer_id"]
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _upsert_hardware_contact(hardware_id: int, contact_id: int) -> None:
|
|
|
|
|
query = """
|
|
|
|
|
INSERT INTO hardware_contacts (hardware_id, contact_id, role, source)
|
|
|
|
|
VALUES (%s, %s, %s, %s)
|
|
|
|
|
ON CONFLICT (hardware_id, contact_id) DO NOTHING
|
|
|
|
|
"""
|
|
|
|
|
execute_query(query, (hardware_id, contact_id, "primary", "eset"))
|
|
|
|
|
|
2026-01-31 23:16:24 +01:00
|
|
|
# ============================================================================
|
|
|
|
|
# CRUD Endpoints for Hardware Assets
|
|
|
|
|
# ============================================================================
|
|
|
|
|
|
|
|
|
|
@router.get("/hardware", response_model=List[dict])
|
|
|
|
|
async def list_hardware(
|
|
|
|
|
customer_id: Optional[int] = None,
|
|
|
|
|
status: Optional[str] = None,
|
|
|
|
|
asset_type: Optional[str] = None,
|
|
|
|
|
q: Optional[str] = None
|
|
|
|
|
):
|
|
|
|
|
"""List all hardware with optional filters."""
|
|
|
|
|
query = "SELECT * FROM hardware_assets WHERE deleted_at IS NULL"
|
|
|
|
|
params = []
|
|
|
|
|
|
|
|
|
|
if customer_id:
|
|
|
|
|
query += " AND current_owner_customer_id = %s"
|
|
|
|
|
params.append(customer_id)
|
|
|
|
|
if status:
|
|
|
|
|
query += " AND status = %s"
|
|
|
|
|
params.append(status)
|
|
|
|
|
if asset_type:
|
|
|
|
|
query += " AND asset_type = %s"
|
|
|
|
|
params.append(asset_type)
|
|
|
|
|
if q:
|
|
|
|
|
query += " AND (serial_number ILIKE %s OR model ILIKE %s OR brand ILIKE %s)"
|
|
|
|
|
search_param = f"%{q}%"
|
|
|
|
|
params.extend([search_param, search_param, search_param])
|
|
|
|
|
|
|
|
|
|
query += " ORDER BY created_at DESC"
|
|
|
|
|
|
|
|
|
|
result = execute_query(query, tuple(params))
|
|
|
|
|
logger.info(f"✅ Listed {len(result) if result else 0} hardware assets")
|
|
|
|
|
return result or []
|
|
|
|
|
|
|
|
|
|
|
2026-02-10 14:40:38 +01:00
|
|
|
@router.get("/hardware/by-customer/{customer_id}", response_model=List[dict])
|
|
|
|
|
async def list_hardware_by_customer(customer_id: int):
|
|
|
|
|
"""List hardware assets owned by a customer."""
|
|
|
|
|
query = """
|
|
|
|
|
SELECT * FROM hardware_assets
|
|
|
|
|
WHERE deleted_at IS NULL AND current_owner_customer_id = %s
|
|
|
|
|
ORDER BY created_at DESC
|
|
|
|
|
"""
|
|
|
|
|
result = execute_query(query, (customer_id,))
|
|
|
|
|
return result or []
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/hardware/by-contact/{contact_id}", response_model=List[dict])
|
|
|
|
|
async def list_hardware_by_contact(contact_id: int):
|
2026-02-11 23:51:21 +01:00
|
|
|
"""List hardware assets linked directly to a contact."""
|
2026-02-10 14:40:38 +01:00
|
|
|
query = """
|
2026-02-11 23:51:21 +01:00
|
|
|
SELECT DISTINCT h.*
|
2026-02-10 14:40:38 +01:00
|
|
|
FROM hardware_assets h
|
2026-02-11 23:51:21 +01:00
|
|
|
JOIN hardware_contacts hc ON hc.hardware_id = h.id
|
|
|
|
|
WHERE hc.contact_id = %s AND h.deleted_at IS NULL
|
2026-02-10 14:40:38 +01:00
|
|
|
ORDER BY h.created_at DESC
|
|
|
|
|
"""
|
|
|
|
|
result = execute_query(query, (contact_id,))
|
|
|
|
|
return result or []
|
|
|
|
|
|
|
|
|
|
|
2026-01-31 23:16:24 +01:00
|
|
|
@router.post("/hardware", response_model=dict)
|
|
|
|
|
async def create_hardware(data: dict):
|
|
|
|
|
"""Create a new hardware asset."""
|
|
|
|
|
try:
|
|
|
|
|
query = """
|
|
|
|
|
INSERT INTO hardware_assets (
|
|
|
|
|
asset_type, brand, model, serial_number, customer_asset_id,
|
|
|
|
|
internal_asset_id, notes, current_owner_type, current_owner_customer_id,
|
2026-02-10 14:40:38 +01:00
|
|
|
status, status_reason, warranty_until, end_of_life,
|
2026-02-11 13:23:32 +01:00
|
|
|
anydesk_id, anydesk_link,
|
|
|
|
|
eset_uuid, hardware_specs, eset_group
|
2026-01-31 23:16:24 +01:00
|
|
|
)
|
2026-02-11 13:23:32 +01:00
|
|
|
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
2026-01-31 23:16:24 +01:00
|
|
|
RETURNING *
|
|
|
|
|
"""
|
2026-02-11 13:23:32 +01:00
|
|
|
|
|
|
|
|
specs = data.get("hardware_specs")
|
|
|
|
|
if specs:
|
|
|
|
|
specs = Json(specs)
|
|
|
|
|
|
2026-01-31 23:16:24 +01:00
|
|
|
params = (
|
|
|
|
|
data.get("asset_type"),
|
|
|
|
|
data.get("brand"),
|
|
|
|
|
data.get("model"),
|
|
|
|
|
data.get("serial_number"),
|
|
|
|
|
data.get("customer_asset_id"),
|
|
|
|
|
data.get("internal_asset_id"),
|
|
|
|
|
data.get("notes"),
|
|
|
|
|
data.get("current_owner_type", "bmc"),
|
|
|
|
|
data.get("current_owner_customer_id"),
|
|
|
|
|
data.get("status", "active"),
|
|
|
|
|
data.get("status_reason"),
|
|
|
|
|
data.get("warranty_until"),
|
|
|
|
|
data.get("end_of_life"),
|
2026-02-10 14:40:38 +01:00
|
|
|
data.get("anydesk_id"),
|
|
|
|
|
data.get("anydesk_link"),
|
2026-02-11 13:23:32 +01:00
|
|
|
data.get("eset_uuid"),
|
|
|
|
|
specs,
|
|
|
|
|
data.get("eset_group")
|
2026-01-31 23:16:24 +01:00
|
|
|
)
|
|
|
|
|
result = execute_query(query, params)
|
|
|
|
|
if not result:
|
|
|
|
|
raise HTTPException(status_code=500, detail="Failed to create hardware")
|
|
|
|
|
|
|
|
|
|
hardware = result[0]
|
|
|
|
|
logger.info(f"✅ Created hardware asset: {hardware['id']} - {hardware['brand']} {hardware['model']}")
|
|
|
|
|
|
|
|
|
|
# Create initial ownership record if owner specified
|
|
|
|
|
if data.get("current_owner_type"):
|
|
|
|
|
ownership_query = """
|
|
|
|
|
INSERT INTO hardware_ownership_history (
|
|
|
|
|
hardware_id, owner_type, owner_customer_id, start_date, notes
|
|
|
|
|
)
|
|
|
|
|
VALUES (%s, %s, %s, %s, %s)
|
|
|
|
|
"""
|
|
|
|
|
ownership_params = (
|
|
|
|
|
hardware['id'],
|
|
|
|
|
data.get("current_owner_type"),
|
|
|
|
|
data.get("current_owner_customer_id"),
|
|
|
|
|
date.today(),
|
|
|
|
|
"Initial ownership record"
|
|
|
|
|
)
|
|
|
|
|
execute_query(ownership_query, ownership_params)
|
|
|
|
|
|
|
|
|
|
return hardware
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.error(f"❌ Failed to create hardware: {str(e)}")
|
|
|
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
|
|
|
|
|
2026-02-10 14:40:38 +01:00
|
|
|
@router.post("/hardware/quick", response_model=dict)
|
|
|
|
|
async def quick_create_hardware(data: dict):
|
|
|
|
|
"""Quick create hardware with minimal fields (name + AnyDesk info)."""
|
|
|
|
|
try:
|
|
|
|
|
name = (data.get("name") or "").strip()
|
|
|
|
|
customer_id = data.get("customer_id")
|
|
|
|
|
anydesk_id = (data.get("anydesk_id") or "").strip() or None
|
|
|
|
|
anydesk_link = (data.get("anydesk_link") or "").strip() or None
|
|
|
|
|
|
|
|
|
|
if not name:
|
|
|
|
|
raise HTTPException(status_code=400, detail="Name is required")
|
|
|
|
|
if not customer_id:
|
|
|
|
|
raise HTTPException(status_code=400, detail="Customer ID is required")
|
|
|
|
|
|
|
|
|
|
query = """
|
|
|
|
|
INSERT INTO hardware_assets (
|
|
|
|
|
asset_type, model, current_owner_type, current_owner_customer_id,
|
|
|
|
|
status, anydesk_id, anydesk_link, notes
|
|
|
|
|
)
|
|
|
|
|
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
|
|
|
|
|
RETURNING *
|
|
|
|
|
"""
|
|
|
|
|
params = (
|
|
|
|
|
"andet",
|
|
|
|
|
name,
|
|
|
|
|
"customer",
|
|
|
|
|
customer_id,
|
|
|
|
|
"active",
|
|
|
|
|
anydesk_id,
|
|
|
|
|
anydesk_link,
|
|
|
|
|
"Quick created from case/ticket flow",
|
|
|
|
|
)
|
|
|
|
|
result = execute_query(query, params)
|
|
|
|
|
if not result:
|
|
|
|
|
raise HTTPException(status_code=500, detail="Failed to create hardware")
|
|
|
|
|
|
|
|
|
|
return result[0]
|
|
|
|
|
except HTTPException:
|
|
|
|
|
raise
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.error(f"❌ Failed to quick-create hardware: {str(e)}")
|
|
|
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
|
|
|
|
|
2026-01-31 23:16:24 +01:00
|
|
|
@router.get("/hardware/{hardware_id}", response_model=dict)
|
|
|
|
|
async def get_hardware(hardware_id: int):
|
|
|
|
|
"""Get hardware details by ID."""
|
|
|
|
|
query = "SELECT * FROM hardware_assets WHERE id = %s AND deleted_at IS NULL"
|
|
|
|
|
result = execute_query(query, (hardware_id,))
|
|
|
|
|
if not result:
|
|
|
|
|
raise HTTPException(status_code=404, detail="Hardware not found")
|
|
|
|
|
|
|
|
|
|
logger.info(f"✅ Retrieved hardware: {hardware_id}")
|
|
|
|
|
return result[0]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.patch("/hardware/{hardware_id}", response_model=dict)
|
|
|
|
|
async def update_hardware(hardware_id: int, data: dict):
|
|
|
|
|
"""Update hardware asset."""
|
|
|
|
|
try:
|
|
|
|
|
# Build dynamic update query
|
|
|
|
|
update_fields = []
|
|
|
|
|
params = []
|
|
|
|
|
|
|
|
|
|
allowed_fields = [
|
|
|
|
|
"asset_type", "brand", "model", "serial_number", "customer_asset_id",
|
|
|
|
|
"internal_asset_id", "notes", "current_owner_type", "current_owner_customer_id",
|
|
|
|
|
"status", "status_reason", "warranty_until", "end_of_life",
|
2026-02-11 13:23:32 +01:00
|
|
|
"follow_up_date", "follow_up_owner_user_id", "anydesk_id", "anydesk_link",
|
|
|
|
|
"eset_uuid", "hardware_specs", "eset_group"
|
2026-01-31 23:16:24 +01:00
|
|
|
]
|
|
|
|
|
|
|
|
|
|
for field in allowed_fields:
|
|
|
|
|
if field in data:
|
|
|
|
|
update_fields.append(f"{field} = %s")
|
2026-02-11 13:23:32 +01:00
|
|
|
val = data[field]
|
|
|
|
|
if field == "hardware_specs" and val:
|
|
|
|
|
val = Json(val)
|
|
|
|
|
params.append(val)
|
2026-01-31 23:16:24 +01:00
|
|
|
|
|
|
|
|
if not update_fields:
|
|
|
|
|
raise HTTPException(status_code=400, detail="No valid fields to update")
|
|
|
|
|
|
|
|
|
|
update_fields.append("updated_at = NOW()")
|
|
|
|
|
params.append(hardware_id)
|
|
|
|
|
|
|
|
|
|
query = f"""
|
|
|
|
|
UPDATE hardware_assets
|
|
|
|
|
SET {', '.join(update_fields)}
|
|
|
|
|
WHERE id = %s AND deleted_at IS NULL
|
|
|
|
|
RETURNING *
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
result = execute_query(query, tuple(params))
|
|
|
|
|
if not result:
|
|
|
|
|
raise HTTPException(status_code=404, detail="Hardware not found")
|
|
|
|
|
|
|
|
|
|
logger.info(f"✅ Updated hardware: {hardware_id}")
|
|
|
|
|
return result[0]
|
|
|
|
|
except HTTPException:
|
|
|
|
|
raise
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.error(f"❌ Failed to update hardware {hardware_id}: {str(e)}")
|
|
|
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.delete("/hardware/{hardware_id}")
|
|
|
|
|
async def delete_hardware(hardware_id: int):
|
|
|
|
|
"""Soft-delete hardware asset."""
|
|
|
|
|
query = """
|
|
|
|
|
UPDATE hardware_assets
|
|
|
|
|
SET deleted_at = NOW()
|
|
|
|
|
WHERE id = %s AND deleted_at IS NULL
|
|
|
|
|
RETURNING id
|
|
|
|
|
"""
|
|
|
|
|
result = execute_query(query, (hardware_id,))
|
|
|
|
|
if not result:
|
|
|
|
|
raise HTTPException(status_code=404, detail="Hardware not found")
|
|
|
|
|
|
|
|
|
|
logger.info(f"✅ Deleted hardware: {hardware_id}")
|
|
|
|
|
return {"message": "Hardware deleted successfully"}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ============================================================================
|
|
|
|
|
# Ownership History Endpoints
|
|
|
|
|
# ============================================================================
|
|
|
|
|
|
|
|
|
|
@router.get("/hardware/{hardware_id}/ownership", response_model=List[dict])
|
|
|
|
|
async def get_ownership_history(hardware_id: int):
|
|
|
|
|
"""Get ownership history for hardware."""
|
|
|
|
|
query = """
|
|
|
|
|
SELECT * FROM hardware_ownership_history
|
|
|
|
|
WHERE hardware_id = %s AND deleted_at IS NULL
|
|
|
|
|
ORDER BY start_date DESC
|
|
|
|
|
"""
|
|
|
|
|
result = execute_query(query, (hardware_id,))
|
|
|
|
|
logger.info(f"✅ Retrieved ownership history for hardware: {hardware_id}")
|
|
|
|
|
return result or []
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/hardware/{hardware_id}/ownership", response_model=dict)
|
|
|
|
|
async def add_ownership_record(hardware_id: int, data: dict):
|
|
|
|
|
"""Add ownership record (auto-closes previous active ownership)."""
|
|
|
|
|
try:
|
|
|
|
|
# Close any active ownership records
|
|
|
|
|
close_query = """
|
|
|
|
|
UPDATE hardware_ownership_history
|
|
|
|
|
SET end_date = %s
|
|
|
|
|
WHERE hardware_id = %s AND end_date IS NULL AND deleted_at IS NULL
|
|
|
|
|
"""
|
|
|
|
|
execute_query(close_query, (date.today(), hardware_id))
|
|
|
|
|
|
|
|
|
|
# Create new ownership record
|
|
|
|
|
insert_query = """
|
|
|
|
|
INSERT INTO hardware_ownership_history (
|
|
|
|
|
hardware_id, owner_type, owner_customer_id, start_date, notes
|
|
|
|
|
)
|
|
|
|
|
VALUES (%s, %s, %s, %s, %s)
|
|
|
|
|
RETURNING *
|
|
|
|
|
"""
|
|
|
|
|
params = (
|
|
|
|
|
hardware_id,
|
|
|
|
|
data.get("owner_type"),
|
|
|
|
|
data.get("owner_customer_id"),
|
|
|
|
|
data.get("start_date", date.today()),
|
|
|
|
|
data.get("notes")
|
|
|
|
|
)
|
|
|
|
|
result = execute_query(insert_query, params)
|
|
|
|
|
|
|
|
|
|
# Update current owner in hardware_assets
|
|
|
|
|
update_query = """
|
|
|
|
|
UPDATE hardware_assets
|
|
|
|
|
SET current_owner_type = %s, current_owner_customer_id = %s, updated_at = NOW()
|
|
|
|
|
WHERE id = %s
|
|
|
|
|
"""
|
|
|
|
|
execute_query(update_query, (data.get("owner_type"), data.get("owner_customer_id"), hardware_id))
|
|
|
|
|
|
|
|
|
|
logger.info(f"✅ Added ownership record for hardware: {hardware_id}")
|
|
|
|
|
return result[0]
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.error(f"❌ Failed to add ownership record: {str(e)}")
|
|
|
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ============================================================================
|
|
|
|
|
# Location History Endpoints
|
|
|
|
|
# ============================================================================
|
|
|
|
|
|
|
|
|
|
@router.get("/hardware/{hardware_id}/locations", response_model=List[dict])
|
|
|
|
|
async def get_location_history(hardware_id: int):
|
|
|
|
|
"""Get location history for hardware."""
|
|
|
|
|
query = """
|
|
|
|
|
SELECT * FROM hardware_location_history
|
|
|
|
|
WHERE hardware_id = %s AND deleted_at IS NULL
|
|
|
|
|
ORDER BY start_date DESC
|
|
|
|
|
"""
|
|
|
|
|
result = execute_query(query, (hardware_id,))
|
|
|
|
|
logger.info(f"✅ Retrieved location history for hardware: {hardware_id}")
|
|
|
|
|
return result or []
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/hardware/{hardware_id}/locations", response_model=dict)
|
|
|
|
|
async def add_location_record(hardware_id: int, data: dict):
|
|
|
|
|
"""Add location record (auto-closes previous active location)."""
|
|
|
|
|
try:
|
|
|
|
|
# Close any active location records
|
|
|
|
|
close_query = """
|
|
|
|
|
UPDATE hardware_location_history
|
|
|
|
|
SET end_date = %s
|
|
|
|
|
WHERE hardware_id = %s AND end_date IS NULL AND deleted_at IS NULL
|
|
|
|
|
"""
|
|
|
|
|
execute_query(close_query, (date.today(), hardware_id))
|
|
|
|
|
|
|
|
|
|
# Create new location record
|
|
|
|
|
insert_query = """
|
|
|
|
|
INSERT INTO hardware_location_history (
|
|
|
|
|
hardware_id, location_id, location_name, start_date, notes
|
|
|
|
|
)
|
|
|
|
|
VALUES (%s, %s, %s, %s, %s)
|
|
|
|
|
RETURNING *
|
|
|
|
|
"""
|
|
|
|
|
params = (
|
|
|
|
|
hardware_id,
|
|
|
|
|
data.get("location_id"),
|
|
|
|
|
data.get("location_name"),
|
|
|
|
|
data.get("start_date", date.today()),
|
|
|
|
|
data.get("notes")
|
|
|
|
|
)
|
|
|
|
|
result = execute_query(insert_query, params)
|
|
|
|
|
|
|
|
|
|
# Update current location in hardware_assets
|
|
|
|
|
update_query = """
|
|
|
|
|
UPDATE hardware_assets
|
|
|
|
|
SET current_location_id = %s, updated_at = NOW()
|
|
|
|
|
WHERE id = %s
|
|
|
|
|
"""
|
|
|
|
|
execute_query(update_query, (data.get("location_id"), hardware_id))
|
|
|
|
|
|
|
|
|
|
logger.info(f"✅ Added location record for hardware: {hardware_id}")
|
|
|
|
|
return result[0]
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.error(f"❌ Failed to add location record: {str(e)}")
|
|
|
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ============================================================================
|
|
|
|
|
# Attachment Endpoints
|
|
|
|
|
# ============================================================================
|
|
|
|
|
|
|
|
|
|
@router.get("/hardware/{hardware_id}/attachments", response_model=List[dict])
|
|
|
|
|
async def get_attachments(hardware_id: int):
|
|
|
|
|
"""Get all attachments for hardware."""
|
|
|
|
|
query = """
|
|
|
|
|
SELECT * FROM hardware_attachments
|
|
|
|
|
WHERE hardware_id = %s AND deleted_at IS NULL
|
|
|
|
|
ORDER BY uploaded_at DESC
|
|
|
|
|
"""
|
|
|
|
|
result = execute_query(query, (hardware_id,))
|
|
|
|
|
logger.info(f"✅ Retrieved {len(result) if result else 0} attachments for hardware: {hardware_id}")
|
|
|
|
|
return result or []
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/hardware/{hardware_id}/attachments", response_model=dict)
|
|
|
|
|
async def upload_attachment(hardware_id: int, data: dict):
|
|
|
|
|
"""Upload attachment for hardware."""
|
|
|
|
|
try:
|
|
|
|
|
# Generate storage reference (in production, this would upload to cloud storage)
|
|
|
|
|
storage_ref = f"hardware/{hardware_id}/{uuid.uuid4()}_{data.get('file_name')}"
|
|
|
|
|
|
|
|
|
|
query = """
|
|
|
|
|
INSERT INTO hardware_attachments (
|
|
|
|
|
hardware_id, file_type, file_name, storage_ref,
|
|
|
|
|
file_size_bytes, mime_type, description, uploaded_by_user_id
|
|
|
|
|
)
|
|
|
|
|
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
|
|
|
|
|
RETURNING *
|
|
|
|
|
"""
|
|
|
|
|
params = (
|
|
|
|
|
hardware_id,
|
|
|
|
|
data.get("file_type", "other"),
|
|
|
|
|
data.get("file_name"),
|
|
|
|
|
storage_ref,
|
|
|
|
|
data.get("file_size_bytes"),
|
|
|
|
|
data.get("mime_type"),
|
|
|
|
|
data.get("description"),
|
|
|
|
|
data.get("uploaded_by_user_id")
|
|
|
|
|
)
|
|
|
|
|
result = execute_query(query, params)
|
|
|
|
|
|
|
|
|
|
logger.info(f"✅ Uploaded attachment for hardware: {hardware_id}")
|
|
|
|
|
return result[0]
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.error(f"❌ Failed to upload attachment: {str(e)}")
|
|
|
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.delete("/hardware/{hardware_id}/attachments/{attachment_id}")
|
|
|
|
|
async def delete_attachment(hardware_id: int, attachment_id: int):
|
|
|
|
|
"""Soft-delete attachment."""
|
|
|
|
|
query = """
|
|
|
|
|
UPDATE hardware_attachments
|
|
|
|
|
SET deleted_at = NOW()
|
|
|
|
|
WHERE id = %s AND hardware_id = %s AND deleted_at IS NULL
|
|
|
|
|
RETURNING id
|
|
|
|
|
"""
|
|
|
|
|
result = execute_query(query, (attachment_id, hardware_id))
|
|
|
|
|
if not result:
|
|
|
|
|
raise HTTPException(status_code=404, detail="Attachment not found")
|
|
|
|
|
|
|
|
|
|
logger.info(f"✅ Deleted attachment {attachment_id} for hardware: {hardware_id}")
|
|
|
|
|
return {"message": "Attachment deleted successfully"}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ============================================================================
|
|
|
|
|
# Case Relations Endpoints
|
|
|
|
|
# ============================================================================
|
|
|
|
|
|
|
|
|
|
@router.get("/hardware/{hardware_id}/cases", response_model=List[dict])
|
|
|
|
|
async def get_related_cases(hardware_id: int):
|
|
|
|
|
"""Get all cases related to this hardware."""
|
|
|
|
|
query = """
|
|
|
|
|
SELECT hcr.*, s.titel, s.status, s.customer_id
|
|
|
|
|
FROM hardware_case_relations hcr
|
|
|
|
|
LEFT JOIN sag_sager s ON hcr.case_id = s.id
|
|
|
|
|
WHERE hcr.hardware_id = %s AND hcr.deleted_at IS NULL AND s.deleted_at IS NULL
|
|
|
|
|
ORDER BY hcr.created_at DESC
|
|
|
|
|
"""
|
|
|
|
|
result = execute_query(query, (hardware_id,))
|
|
|
|
|
logger.info(f"✅ Retrieved {len(result) if result else 0} related cases for hardware: {hardware_id}")
|
|
|
|
|
return result or []
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/hardware/{hardware_id}/cases", response_model=dict)
|
|
|
|
|
async def link_case(hardware_id: int, data: dict):
|
|
|
|
|
"""Link hardware to a case."""
|
|
|
|
|
try:
|
|
|
|
|
query = """
|
|
|
|
|
INSERT INTO hardware_case_relations (
|
|
|
|
|
hardware_id, case_id, relation_type
|
|
|
|
|
)
|
|
|
|
|
VALUES (%s, %s, %s)
|
|
|
|
|
RETURNING *
|
|
|
|
|
"""
|
|
|
|
|
params = (
|
|
|
|
|
hardware_id,
|
|
|
|
|
data.get("case_id"),
|
|
|
|
|
data.get("relation_type", "related")
|
|
|
|
|
)
|
|
|
|
|
result = execute_query(query, params)
|
|
|
|
|
|
|
|
|
|
logger.info(f"✅ Linked hardware {hardware_id} to case {data.get('case_id')}")
|
|
|
|
|
return result[0]
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.error(f"❌ Failed to link case: {str(e)}")
|
|
|
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.delete("/hardware/{hardware_id}/cases/{case_id}")
|
|
|
|
|
async def unlink_case(hardware_id: int, case_id: int):
|
|
|
|
|
"""Unlink hardware from a case."""
|
|
|
|
|
query = """
|
|
|
|
|
UPDATE hardware_case_relations
|
|
|
|
|
SET deleted_at = NOW()
|
|
|
|
|
WHERE hardware_id = %s AND case_id = %s AND deleted_at IS NULL
|
|
|
|
|
RETURNING id
|
|
|
|
|
"""
|
|
|
|
|
result = execute_query(query, (hardware_id, case_id))
|
|
|
|
|
if not result:
|
|
|
|
|
raise HTTPException(status_code=404, detail="Case relation not found")
|
|
|
|
|
|
|
|
|
|
logger.info(f"✅ Unlinked hardware {hardware_id} from case {case_id}")
|
|
|
|
|
return {"message": "Case unlinked successfully"}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ============================================================================
|
|
|
|
|
# Tag Endpoints
|
|
|
|
|
# ============================================================================
|
|
|
|
|
|
|
|
|
|
@router.get("/hardware/{hardware_id}/tags", response_model=List[dict])
|
|
|
|
|
async def get_tags(hardware_id: int):
|
|
|
|
|
"""Get all tags for hardware."""
|
|
|
|
|
query = """
|
|
|
|
|
SELECT * FROM hardware_tags
|
|
|
|
|
WHERE hardware_id = %s AND deleted_at IS NULL
|
|
|
|
|
ORDER BY created_at DESC
|
|
|
|
|
"""
|
|
|
|
|
result = execute_query(query, (hardware_id,))
|
|
|
|
|
logger.info(f"✅ Retrieved {len(result) if result else 0} tags for hardware: {hardware_id}")
|
|
|
|
|
return result or []
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/hardware/{hardware_id}/tags", response_model=dict)
|
|
|
|
|
async def add_tag(hardware_id: int, data: dict):
|
|
|
|
|
"""Add tag to hardware."""
|
|
|
|
|
try:
|
|
|
|
|
query = """
|
|
|
|
|
INSERT INTO hardware_tags (
|
|
|
|
|
hardware_id, tag_name, tag_type
|
|
|
|
|
)
|
|
|
|
|
VALUES (%s, %s, %s)
|
|
|
|
|
RETURNING *
|
|
|
|
|
"""
|
|
|
|
|
params = (
|
|
|
|
|
hardware_id,
|
|
|
|
|
data.get("tag_name"),
|
|
|
|
|
data.get("tag_type", "manual")
|
|
|
|
|
)
|
|
|
|
|
result = execute_query(query, params)
|
|
|
|
|
|
|
|
|
|
logger.info(f"✅ Added tag '{data.get('tag_name')}' to hardware: {hardware_id}")
|
|
|
|
|
return result[0]
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.error(f"❌ Failed to add tag: {str(e)}")
|
|
|
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.delete("/hardware/{hardware_id}/tags/{tag_id}")
|
|
|
|
|
async def delete_tag(hardware_id: int, tag_id: int):
|
|
|
|
|
"""Delete tag from hardware."""
|
|
|
|
|
query = """
|
|
|
|
|
UPDATE hardware_tags
|
|
|
|
|
SET deleted_at = NOW()
|
|
|
|
|
WHERE id = %s AND hardware_id = %s AND deleted_at IS NULL
|
|
|
|
|
RETURNING id
|
|
|
|
|
"""
|
|
|
|
|
result = execute_query(query, (tag_id, hardware_id))
|
|
|
|
|
if not result:
|
|
|
|
|
raise HTTPException(status_code=404, detail="Tag not found")
|
|
|
|
|
|
|
|
|
|
logger.info(f"✅ Deleted tag {tag_id} from hardware: {hardware_id}")
|
|
|
|
|
return {"message": "Tag deleted successfully"}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ============================================================================
|
|
|
|
|
# Search Endpoint
|
|
|
|
|
# ============================================================================
|
|
|
|
|
|
|
|
|
|
@router.get("/search/hardware", response_model=List[dict])
|
|
|
|
|
async def search_hardware(q: str = Query(..., min_length=1)):
|
|
|
|
|
"""Search hardware by serial number, model, or brand."""
|
|
|
|
|
query = """
|
|
|
|
|
SELECT * FROM hardware_assets
|
|
|
|
|
WHERE deleted_at IS NULL
|
|
|
|
|
AND (
|
|
|
|
|
serial_number ILIKE %s
|
|
|
|
|
OR model ILIKE %s
|
|
|
|
|
OR brand ILIKE %s
|
|
|
|
|
OR customer_asset_id ILIKE %s
|
|
|
|
|
OR internal_asset_id ILIKE %s
|
|
|
|
|
)
|
|
|
|
|
ORDER BY created_at DESC
|
|
|
|
|
LIMIT 50
|
|
|
|
|
"""
|
|
|
|
|
search_param = f"%{q}%"
|
|
|
|
|
result = execute_query(query, (search_param, search_param, search_param, search_param, search_param))
|
|
|
|
|
|
|
|
|
|
logger.info(f"✅ Search for '{q}' returned {len(result) if result else 0} results")
|
|
|
|
|
return result or []
|
2026-02-11 13:23:32 +01:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/hardware/{hardware_id}/sync-eset", response_model=dict)
|
|
|
|
|
async def sync_eset_data(hardware_id: int, eset_uuid: Optional[str] = Query(None)):
|
|
|
|
|
"""Sync hardware data from ESET."""
|
|
|
|
|
# Get current hardware
|
|
|
|
|
check_query = "SELECT * FROM hardware_assets WHERE id = %s AND deleted_at IS NULL"
|
|
|
|
|
result = execute_query(check_query, (hardware_id,))
|
|
|
|
|
if not result:
|
|
|
|
|
raise HTTPException(status_code=404, detail="Hardware not found")
|
|
|
|
|
current = result[0]
|
|
|
|
|
|
|
|
|
|
# Determine UUID
|
|
|
|
|
uuid_to_use = eset_uuid or current.get("eset_uuid")
|
|
|
|
|
if not uuid_to_use:
|
|
|
|
|
raise HTTPException(status_code=400, detail="No ESET UUID provided or found on asset. Please provide 'eset_uuid' query parameter.")
|
|
|
|
|
|
|
|
|
|
# Fetch from ESET
|
|
|
|
|
details = await eset_service.get_device_details(uuid_to_use)
|
|
|
|
|
if not details:
|
|
|
|
|
raise HTTPException(status_code=404, detail="Device not found in ESET")
|
|
|
|
|
|
|
|
|
|
# Update hardware asset
|
|
|
|
|
update_data = {
|
|
|
|
|
"eset_uuid": uuid_to_use,
|
|
|
|
|
"hardware_specs": details
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
# We can perform the update directly here or call update_hardware if available
|
|
|
|
|
return await update_hardware(hardware_id, update_data)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/hardware/eset/test", response_model=dict)
|
|
|
|
|
async def test_eset_device(device_uuid: str = Query(..., min_length=1)):
|
|
|
|
|
"""Test ESET device lookup by UUID."""
|
|
|
|
|
details = await eset_service.get_device_details(device_uuid)
|
|
|
|
|
if not details:
|
|
|
|
|
raise HTTPException(status_code=404, detail="Device not found in ESET")
|
|
|
|
|
|
|
|
|
|
return details
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/hardware/eset/devices", response_model=dict)
|
2026-02-11 23:51:21 +01:00
|
|
|
async def list_eset_devices(
|
|
|
|
|
page_size: Optional[int] = Query(None, ge=1, le=1000),
|
|
|
|
|
page_token: Optional[str] = Query(None)
|
|
|
|
|
):
|
2026-02-11 13:23:32 +01:00
|
|
|
"""List devices directly from ESET Device Management."""
|
2026-02-11 23:51:21 +01:00
|
|
|
payload = await eset_service.list_devices(page_size=page_size, page_token=page_token)
|
2026-02-11 13:23:32 +01:00
|
|
|
if not payload:
|
|
|
|
|
raise HTTPException(status_code=404, detail="No devices returned from ESET")
|
|
|
|
|
return payload
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/hardware/eset/import", response_model=dict)
|
|
|
|
|
async def import_eset_device(data: dict):
|
|
|
|
|
"""Import ESET device into hardware assets and optionally link to contact."""
|
|
|
|
|
device_uuid = (data.get("device_uuid") or "").strip()
|
|
|
|
|
contact_id = data.get("contact_id")
|
|
|
|
|
|
|
|
|
|
if not device_uuid:
|
|
|
|
|
raise HTTPException(status_code=400, detail="device_uuid is required")
|
|
|
|
|
|
|
|
|
|
details = await eset_service.get_device_details(device_uuid)
|
|
|
|
|
if not details:
|
|
|
|
|
raise HTTPException(status_code=404, detail="Device not found in ESET")
|
|
|
|
|
|
|
|
|
|
serial = _eset_extract_first_str(details, ["serialNumber", "serial", "serial_number"])
|
|
|
|
|
model = _eset_extract_first_str(details, ["model", "deviceModel", "deviceName", "name"])
|
|
|
|
|
brand = _eset_extract_first_str(details, ["manufacturer", "brand", "vendor"])
|
|
|
|
|
group_path = _eset_extract_group_path(details)
|
|
|
|
|
group_name = _eset_extract_group_name(details)
|
|
|
|
|
company = _eset_extract_company(details)
|
|
|
|
|
|
|
|
|
|
if contact_id:
|
|
|
|
|
contact_check = execute_query("SELECT id FROM contacts WHERE id = %s", (contact_id,))
|
|
|
|
|
if not contact_check:
|
|
|
|
|
raise HTTPException(status_code=404, detail="Contact not found")
|
|
|
|
|
|
|
|
|
|
customer_id = _get_contact_customer(contact_id) if contact_id else None
|
|
|
|
|
if not customer_id:
|
|
|
|
|
customer_id = _match_customer_exact(group_name or company)
|
|
|
|
|
|
|
|
|
|
owner_type = "customer" if customer_id else "bmc"
|
|
|
|
|
|
|
|
|
|
conditions = ["eset_uuid = %s"]
|
|
|
|
|
params = [device_uuid]
|
|
|
|
|
if serial:
|
|
|
|
|
conditions.append("serial_number = %s")
|
|
|
|
|
params.append(serial)
|
|
|
|
|
|
|
|
|
|
lookup_query = f"SELECT * FROM hardware_assets WHERE deleted_at IS NULL AND ({' OR '.join(conditions)})"
|
|
|
|
|
existing = execute_query(lookup_query, tuple(params))
|
|
|
|
|
|
|
|
|
|
if existing:
|
|
|
|
|
hardware_id = existing[0]["id"]
|
|
|
|
|
update_fields = ["eset_uuid = %s", "hardware_specs = %s", "updated_at = NOW()"]
|
|
|
|
|
update_params = [device_uuid, Json(details)]
|
|
|
|
|
|
|
|
|
|
if group_path:
|
|
|
|
|
update_fields.append("eset_group = %s")
|
|
|
|
|
update_params.append(group_path)
|
|
|
|
|
if not existing[0].get("serial_number") and serial:
|
|
|
|
|
update_fields.append("serial_number = %s")
|
|
|
|
|
update_params.append(serial)
|
|
|
|
|
if not existing[0].get("model") and model:
|
|
|
|
|
update_fields.append("model = %s")
|
|
|
|
|
update_params.append(model)
|
|
|
|
|
if not existing[0].get("brand") and brand:
|
|
|
|
|
update_fields.append("brand = %s")
|
|
|
|
|
update_params.append(brand)
|
|
|
|
|
if customer_id:
|
|
|
|
|
update_fields.append("current_owner_type = %s")
|
|
|
|
|
update_params.append("customer")
|
|
|
|
|
update_fields.append("current_owner_customer_id = %s")
|
|
|
|
|
update_params.append(customer_id)
|
|
|
|
|
|
|
|
|
|
update_params.append(hardware_id)
|
|
|
|
|
update_query = f"""
|
|
|
|
|
UPDATE hardware_assets
|
|
|
|
|
SET {', '.join(update_fields)}
|
|
|
|
|
WHERE id = %s
|
|
|
|
|
RETURNING *
|
|
|
|
|
"""
|
|
|
|
|
hardware = execute_query(update_query, tuple(update_params))
|
|
|
|
|
hardware = hardware[0] if hardware else None
|
|
|
|
|
else:
|
|
|
|
|
insert_query = """
|
|
|
|
|
INSERT INTO hardware_assets (
|
|
|
|
|
asset_type, brand, model, serial_number,
|
|
|
|
|
current_owner_type, current_owner_customer_id,
|
|
|
|
|
notes, eset_uuid, hardware_specs, eset_group
|
|
|
|
|
)
|
|
|
|
|
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
|
|
|
|
RETURNING *
|
|
|
|
|
"""
|
|
|
|
|
insert_params = (
|
|
|
|
|
_eset_detect_asset_type(details),
|
|
|
|
|
brand,
|
|
|
|
|
model,
|
|
|
|
|
serial,
|
|
|
|
|
owner_type,
|
|
|
|
|
customer_id,
|
|
|
|
|
"Imported from ESET",
|
|
|
|
|
device_uuid,
|
|
|
|
|
Json(details),
|
|
|
|
|
group_path
|
|
|
|
|
)
|
|
|
|
|
hardware = execute_query(insert_query, insert_params)
|
|
|
|
|
hardware = hardware[0] if hardware else None
|
|
|
|
|
|
|
|
|
|
if hardware and contact_id:
|
|
|
|
|
_upsert_hardware_contact(hardware["id"], contact_id)
|
|
|
|
|
|
|
|
|
|
return hardware or {}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/hardware/eset/matches", response_model=List[dict])
|
|
|
|
|
async def list_eset_matches(limit: int = Query(500, ge=1, le=2000)):
|
|
|
|
|
"""List ESET-matched hardware with contact/customer info."""
|
|
|
|
|
query = """
|
|
|
|
|
SELECT
|
|
|
|
|
h.id,
|
|
|
|
|
h.asset_type,
|
|
|
|
|
h.brand,
|
|
|
|
|
h.model,
|
|
|
|
|
h.serial_number,
|
|
|
|
|
h.eset_uuid,
|
|
|
|
|
h.eset_group,
|
|
|
|
|
h.updated_at,
|
|
|
|
|
hc.contact_id,
|
|
|
|
|
c.first_name,
|
|
|
|
|
c.last_name,
|
|
|
|
|
c.user_company,
|
|
|
|
|
cc.customer_id,
|
|
|
|
|
cust.name AS customer_name
|
|
|
|
|
FROM hardware_assets h
|
|
|
|
|
LEFT JOIN hardware_contacts hc ON hc.hardware_id = h.id
|
|
|
|
|
LEFT JOIN contacts c ON c.id = hc.contact_id
|
|
|
|
|
LEFT JOIN contact_companies cc ON cc.contact_id = c.id
|
|
|
|
|
LEFT JOIN customers cust ON cust.id = cc.customer_id
|
|
|
|
|
WHERE h.deleted_at IS NULL
|
|
|
|
|
ORDER BY h.updated_at DESC NULLS LAST
|
|
|
|
|
LIMIT %s
|
|
|
|
|
"""
|
|
|
|
|
result = execute_query(query, (limit,))
|
|
|
|
|
return result or []
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/hardware/eset/incidents", response_model=List[dict])
|
|
|
|
|
async def list_eset_incidents(
|
|
|
|
|
severity: Optional[str] = Query("critical"),
|
|
|
|
|
limit: int = Query(200, ge=1, le=2000)
|
|
|
|
|
):
|
|
|
|
|
"""List cached ESET incidents by severity."""
|
|
|
|
|
severity_list = [s.strip().lower() for s in (severity or "").split(",") if s.strip()]
|
|
|
|
|
if not severity_list:
|
|
|
|
|
severity_list = ["critical"]
|
|
|
|
|
|
|
|
|
|
query = """
|
|
|
|
|
SELECT *
|
|
|
|
|
FROM eset_incidents
|
|
|
|
|
WHERE LOWER(COALESCE(severity, '')) = ANY(%s)
|
|
|
|
|
ORDER BY updated_at DESC NULLS LAST
|
|
|
|
|
LIMIT %s
|
|
|
|
|
"""
|
|
|
|
|
result = execute_query(query, (severity_list, limit))
|
|
|
|
|
return result or []
|
|
|
|
|
|