diff --git a/GeoNetAgent/collector/app/api/v1/assets.py b/GeoNetAgent/collector/app/api/v1/assets.py index c105d96..d8f18d8 100644 --- a/GeoNetAgent/collector/app/api/v1/assets.py +++ b/GeoNetAgent/collector/app/api/v1/assets.py @@ -6,7 +6,7 @@ from sqlalchemy.orm import Session from app.core.auth import verify_api_bearer from app.db.session import get_db from app.schemas.agent_report import ErrorBody, ErrorResponse -from app.schemas.assets import AssetDetail, PaginatedAssets, PaginatedSnapshots, PaginatedSoftware, SnapshotSummary, StatsSummary +from app.schemas.assets import AssetDetail, AssetOwnerIn, AssetOwnerOut, AssetsByOwnerItem, PaginatedAssets, PaginatedSnapshots, PaginatedSoftware, SnapshotSummary, StatsSummary from app.services import asset_read_service router = APIRouter() @@ -51,6 +51,15 @@ def list_assets( ) +@router.get("/assets/by-owner/{ldap_username}", response_model=list[AssetsByOwnerItem]) +def get_assets_by_owner( + ldap_username: str, + db: Session = Depends(get_db), + _: str = Depends(verify_api_bearer), +) -> list[AssetsByOwnerItem]: + return asset_read_service.get_assets_by_ldap_username(db, ldap_username) + + @router.get("/assets/{asset_id}", response_model=AssetDetail) def get_asset( asset_id: uuid.UUID, @@ -109,3 +118,18 @@ def list_asset_software( if result is None: raise _not_found() return result + + +@router.put("/assets/{asset_id}/owner", response_model=AssetOwnerOut) +def set_asset_owner( + asset_id: uuid.UUID, + body: AssetOwnerIn, + db: Session = Depends(get_db), + _: str = Depends(verify_api_bearer), +) -> AssetOwnerOut: + result = asset_read_service.set_asset_owner(db, asset_id, body.ldap_username) + if result is None: + raise _not_found() + return result + + diff --git a/GeoNetAgent/collector/app/schemas/assets.py b/GeoNetAgent/collector/app/schemas/assets.py index 30843a9..da6ab48 100644 --- a/GeoNetAgent/collector/app/schemas/assets.py +++ b/GeoNetAgent/collector/app/schemas/assets.py @@ -7,6 +7,8 @@ from pydantic import BaseModel, Field class AssetListItem(BaseModel): id: str hostname: str + display_name: str | None = None + form_factor: str | None = None asset_type: str status: str manufacturer: str | None = None @@ -37,9 +39,76 @@ class DiskOut(BaseModel): model: str | None = None +class CpuOut(BaseModel): + model: str | None = None + manufacturer: str | None = None + cores: int | None = None + logical_processors: int | None = None + max_clock_mhz: int | None = None + current_clock_mhz: int | None = None + socket: str | None = None + socket_count: int | None = None + l2_cache_kb: int | None = None + l3_cache_kb: int | None = None + architecture: str | None = None + + class GpuOut(BaseModel): name: str dedicated_memory_mb: int | None = None + adapter_compatibility: str | None = None + video_processor: str | None = None + driver_version: str | None = None + driver_date: str | None = None + pnp_device_id: str | None = None + status: str | None = None + current_resolution: str | None = None + video_mode_description: str | None = None + + +class PhysicalDiskOut(BaseModel): + disk_number: int | None = None + device_id: str | None = None + model: str | None = None + friendly_name: str | None = None + media_type: str | None = None + operational_status: str | None = None + health_status: str | None = None + total_gb: float | None = None + partition_style: str | None = None + bus_type: str | None = None + serial_number: str | None = None + + +class MemoryModuleOut(BaseModel): + slot: str | None = None + bank_label: str | None = None + capacity_gb: float | None = None + speed_mhz: int | None = None + configured_speed_mhz: int | None = None + manufacturer: str | None = None + part_number: str | None = None + serial_number: str | None = None + form_factor: str | None = None + memory_type_label: str | None = None + rank: int | None = None + + +class MemorySummaryOut(BaseModel): + total_gb: float | None = None + module_count: int | None = None + slot_count: int | None = None + empty_slots: int | None = None + layout: str | None = None + configuration: str | None = None + upgradeable: bool | None = None + ddr_generation: str | None = None + + +class SystemSnapshotOut(BaseModel): + form_factor: str | None = None + chassis_label: str | None = None + pc_system_label: str | None = None class NetworkAdapterOut(BaseModel): @@ -76,6 +145,7 @@ class SnapshotSummary(BaseModel): os_name: str | None = None os_version: str | None = None cpu_model: str | None = None + cpu: CpuOut | None = None ram_gb: float | None = None gpu_model: str | None = None bios_version: str | None = None @@ -85,7 +155,11 @@ class SnapshotSummary(BaseModel): collected_at: datetime created_at: datetime gpus: list[GpuOut] = Field(default_factory=list) + memory_modules: list[MemoryModuleOut] = Field(default_factory=list) + memory_summary: MemorySummaryOut | None = None disks: list[DiskOut] = Field(default_factory=list) + physical_disks: list[PhysicalDiskOut] = Field(default_factory=list) + system: SystemSnapshotOut | None = None network: list[NetworkAdapterOut] = Field(default_factory=list) peripherals: list[PeripheralOut] = Field(default_factory=list) @@ -153,3 +227,27 @@ class StatsSummary(BaseModel): stale_assets: int total_snapshots: int total_software_rows: int + + +class AssetOwnerIn(BaseModel): + ldap_username: str = Field(..., min_length=1, max_length=100) + + +class AssetOwnerOut(BaseModel): + asset_id: str + ldap_username: str + assigned_at: datetime + assigned_by: str + + +class AssetsByOwnerItem(BaseModel): + id: str + hostname: str + display_name: str | None = None + asset_type: str + status: str + manufacturer: str | None = None + model: str | None = None + serial_number: str | None = None + last_seen_at: datetime | None = None + latest_snapshot_at: datetime | None = None diff --git a/GeoNetAgent/collector/app/services/asset_read_service.py b/GeoNetAgent/collector/app/services/asset_read_service.py index 4039c7a..56c62ce 100644 --- a/GeoNetAgent/collector/app/services/asset_read_service.py +++ b/GeoNetAgent/collector/app/services/asset_read_service.py @@ -5,21 +5,28 @@ from datetime import datetime, timedelta, timezone from sqlalchemy import Select, exists, func, or_, select from sqlalchemy.orm import Session -from app.db.models import Asset, AssetDisk, AssetNetworkAdapter, AssetSnapshot, SoftwareInstallation +from app.db.models import Asset, AssetDisk, AssetNetworkAdapter, AssetSnapshot, AssetUserAssignment, SoftwareInstallation, User from app.schemas.assets import ( AssetDetail, AssetListItem, + AssetOwnerOut, + AssetsByOwnerItem, + CpuOut, DiskOut, GpuOut, + MemoryModuleOut, + MemorySummaryOut, NetworkAdapterOut, PaginatedAssets, PaginatedSnapshots, PaginatedSoftware, PeripheralOut, + PhysicalDiskOut, SnapshotListItem, SnapshotSummary, SoftwareOut, StatsSummary, + SystemSnapshotOut, ) @@ -151,6 +158,9 @@ def list_assets( AssetListItem( id=str(asset.id), hostname=asset.hostname, + display_name=asset.display_name, + form_factor=_form_factor_from_payload(snap.payload if snap else None) + or (asset.metadata_ or {}).get("form_factor"), asset_type=asset.asset_type, status=asset.status, manufacturer=asset.manufacturer, @@ -240,6 +250,85 @@ def _peripherals_from_payload(payload: dict | None) -> list[PeripheralOut]: return items +def _form_factor_from_payload(payload: dict | None) -> str | None: + if not payload: + return None + system = payload.get("system") + if isinstance(system, dict): + value = system.get("form_factor") + if value: + return str(value) + return None + + +def _system_from_payload(payload: dict | None) -> SystemSnapshotOut | None: + if not payload: + return None + system = payload.get("system") + if not isinstance(system, dict): + return None + return SystemSnapshotOut( + form_factor=system.get("form_factor"), + chassis_label=system.get("chassis_label"), + pc_system_label=system.get("pc_system_label"), + ) + + +def _physical_disks_from_payload(payload: dict | None) -> list[PhysicalDiskOut]: + if not payload: + return [] + raw = payload.get("physical_disks") + if not isinstance(raw, list): + return [] + items: list[PhysicalDiskOut] = [] + for entry in raw: + if not isinstance(entry, dict): + continue + items.append( + PhysicalDiskOut( + disk_number=entry.get("disk_number"), + device_id=entry.get("device_id"), + model=entry.get("model"), + friendly_name=entry.get("friendly_name"), + media_type=entry.get("media_type"), + operational_status=entry.get("operational_status"), + health_status=entry.get("health_status"), + total_gb=entry.get("total_gb"), + partition_style=entry.get("partition_style"), + bus_type=entry.get("bus_type"), + serial_number=entry.get("serial_number"), + ) + ) + return items + + +def _cpu_from_payload(payload: dict | None) -> CpuOut | None: + if not payload: + return None + + raw = payload.get("cpu") + if isinstance(raw, dict): + return CpuOut( + model=raw.get("model"), + manufacturer=raw.get("manufacturer"), + cores=raw.get("cores"), + logical_processors=raw.get("logical_processors"), + max_clock_mhz=raw.get("max_clock_mhz"), + current_clock_mhz=raw.get("current_clock_mhz"), + socket=raw.get("socket"), + socket_count=raw.get("socket_count"), + l2_cache_kb=raw.get("l2_cache_kb"), + l3_cache_kb=raw.get("l3_cache_kb"), + architecture=raw.get("architecture"), + ) + + system = payload.get("system") + if isinstance(system, dict) and system.get("cpu_model"): + return CpuOut(model=str(system["cpu_model"])) + + return None + + def _gpus_from_payload(payload: dict | None) -> list[GpuOut]: if not payload: return [] @@ -263,21 +352,77 @@ def _gpus_from_payload(payload: dict | None) -> list[GpuOut]: GpuOut( name=str(name), dedicated_memory_mb=int(dedicated) if dedicated is not None else None, + adapter_compatibility=entry.get("adapter_compatibility"), + video_processor=entry.get("video_processor"), + driver_version=entry.get("driver_version"), + driver_date=str(entry.get("driver_date")) if entry.get("driver_date") else None, + pnp_device_id=entry.get("pnp_device_id"), + status=entry.get("status"), + current_resolution=entry.get("current_resolution"), + video_mode_description=entry.get("video_mode_description"), ) ) return items +def _memory_from_payload(payload: dict | None) -> tuple[list[MemoryModuleOut], MemorySummaryOut | None]: + if not payload: + return [], None + + modules: list[MemoryModuleOut] = [] + raw_modules = payload.get("memory_modules") + if isinstance(raw_modules, list): + for entry in raw_modules: + if not isinstance(entry, dict): + continue + modules.append( + MemoryModuleOut( + slot=entry.get("slot"), + bank_label=entry.get("bank_label"), + capacity_gb=entry.get("capacity_gb"), + speed_mhz=entry.get("speed_mhz"), + configured_speed_mhz=entry.get("configured_speed_mhz"), + manufacturer=entry.get("manufacturer"), + part_number=entry.get("part_number"), + serial_number=entry.get("serial_number"), + form_factor=entry.get("form_factor"), + memory_type_label=entry.get("memory_type_label"), + rank=entry.get("rank"), + ) + ) + + summary = None + raw_summary = payload.get("memory_summary") + if isinstance(raw_summary, dict): + summary = MemorySummaryOut( + total_gb=raw_summary.get("total_gb"), + module_count=raw_summary.get("module_count"), + slot_count=raw_summary.get("slot_count"), + empty_slots=raw_summary.get("empty_slots"), + layout=raw_summary.get("layout"), + configuration=raw_summary.get("configuration"), + upgradeable=raw_summary.get("upgradeable"), + ddr_generation=raw_summary.get("ddr_generation"), + ) + + return modules, summary + + def _snapshot_to_summary(db: Session, snapshot: AssetSnapshot) -> SnapshotSummary: disks, network = _load_snapshot_children(db, snapshot) peripherals = _peripherals_from_payload(snapshot.payload) gpus = _gpus_from_payload(snapshot.payload) + cpu = _cpu_from_payload(snapshot.payload) + memory_modules, memory_summary = _memory_from_payload(snapshot.payload) + physical_disks = _physical_disks_from_payload(snapshot.payload) + system = _system_from_payload(snapshot.payload) return SnapshotSummary( id=str(snapshot.id), agent_version=snapshot.agent_version, os_name=snapshot.os_name, os_version=snapshot.os_version, - cpu_model=snapshot.cpu_model, + cpu_model=snapshot.cpu_model or (cpu.model if cpu else None), + cpu=cpu, ram_gb=float(snapshot.ram_gb) if snapshot.ram_gb is not None else None, gpu_model=snapshot.gpu_model, bios_version=snapshot.bios_version, @@ -287,7 +432,11 @@ def _snapshot_to_summary(db: Session, snapshot: AssetSnapshot) -> SnapshotSummar collected_at=snapshot.collected_at, created_at=snapshot.created_at, gpus=gpus, + memory_modules=memory_modules, + memory_summary=memory_summary, disks=disks, + physical_disks=physical_disks, + system=system, network=network, peripherals=peripherals, ) @@ -417,3 +566,89 @@ def list_software( snapshot_id=str(resolved_snapshot_id) if resolved_snapshot_id else None, **meta, ) + + +def set_asset_owner(db: Session, asset_id: uuid.UUID, ldap_username: str) -> AssetOwnerOut | None: + asset = db.scalar(select(Asset).where(Asset.id == asset_id, Asset.deleted_at.is_(None))) + if not asset: + return None + + user = db.scalar(select(User).where(User.username == ldap_username)) + if not user: + user = User(username=ldap_username) + db.add(user) + db.flush() + + existing = db.scalar( + select(AssetUserAssignment).where( + AssetUserAssignment.asset_id == asset_id, + AssetUserAssignment.is_primary == True, # noqa: E712 + AssetUserAssignment.ended_at.is_(None), + ) + ) + if existing: + existing.ended_at = _utcnow() + db.flush() + + assignment = AssetUserAssignment( + asset_id=asset_id, + user_id=user.id, + assigned_by="manual", + is_primary=True, + ) + db.add(assignment) + db.commit() + db.refresh(assignment) + + return AssetOwnerOut( + asset_id=str(asset_id), + ldap_username=user.username, + assigned_at=assignment.assigned_at, + assigned_by=assignment.assigned_by, + ) + + +def get_assets_by_ldap_username(db: Session, ldap_username: str) -> list[AssetsByOwnerItem]: + user = db.scalar(select(User).where(User.username == ldap_username)) + if not user: + return [] + + rows = db.execute( + select(Asset, AssetUserAssignment) + .join(AssetUserAssignment, AssetUserAssignment.asset_id == Asset.id) + .where( + AssetUserAssignment.user_id == user.id, + AssetUserAssignment.is_primary == True, # noqa: E712 + AssetUserAssignment.ended_at.is_(None), + Asset.deleted_at.is_(None), + ) + .order_by(Asset.last_seen_at.desc().nullslast()) + ).all() + + latest = _latest_snapshot_subquery() + items: list[AssetsByOwnerItem] = [] + for asset, _ in rows: + snap = db.scalar( + select(AssetSnapshot) + .join( + latest, + (AssetSnapshot.asset_id == latest.c.asset_id) + & (AssetSnapshot.collected_at == latest.c.max_collected_at), + ) + .where(AssetSnapshot.asset_id == asset.id) + ) + items.append( + AssetsByOwnerItem( + id=str(asset.id), + hostname=asset.hostname, + display_name=asset.display_name, + asset_type=asset.asset_type, + status=asset.status, + manufacturer=asset.manufacturer, + model=asset.model, + serial_number=asset.serial_number, + last_seen_at=asset.last_seen_at, + latest_snapshot_at=snap.collected_at if snap else None, + ) + ) + return items