bmc_hub/app/modules/hardware/backend/router.py
Christian 3d7fb1aa48 feat(migrations): add AnyDesk session management and customer wiki slug updates
- Created migration scripts for AnyDesk sessions and hardware assets.
- Implemented apply_migration_115.py to execute migration for AnyDesk sessions.
- Added set_customer_wiki_slugs.py script to update customer wiki slugs based on a predefined folder list.
- Developed run_migration.py to apply AnyDesk migration schema.
- Added tests for Service Contract Wizard to ensure functionality and dry-run mode.
2026-02-10 14:40:38 +01:00

589 lines
21 KiB
Python

import logging
from typing import List, Optional
from fastapi import APIRouter, HTTPException, Query, UploadFile, File
from app.core.database import execute_query
from datetime import datetime, date
import os
import uuid
logger = logging.getLogger(__name__)
router = APIRouter()
# ============================================================================
# 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 []
@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):
"""List hardware assets linked to a contact via company relations."""
query = """
SELECT h.*
FROM hardware_assets h
JOIN contact_companies cc ON cc.customer_id = h.current_owner_customer_id
WHERE cc.contact_id = %s AND h.deleted_at IS NULL
ORDER BY h.created_at DESC
"""
result = execute_query(query, (contact_id,))
return result or []
@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,
status, status_reason, warranty_until, end_of_life,
anydesk_id, anydesk_link
)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
RETURNING *
"""
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"),
data.get("anydesk_id"),
data.get("anydesk_link"),
)
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))
@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))
@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",
"follow_up_date", "follow_up_owner_user_id", "anydesk_id", "anydesk_link"
]
for field in allowed_fields:
if field in data:
update_fields.append(f"{field} = %s")
params.append(data[field])
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 []