"""装备图鉴 — a collection-style view of every device the caller has ever held, current and returned alike, each with how long it served and a playful rarity tier earned by service time. The warm sibling of the footprint's device headline: where the footprint names just your first device and longest companion, the gallery lays out the whole collection like trading cards. Service time is computed from the real ownership trail — merging `asset_operations` AND `asset_history` (the direct-assign path writes only history, not an operation) — summing every span the device was assigned to you, so a device you held, returned, and were re-assigned counts both stints. Borrow events are excluded from span math (a loaned-out device was still *yours*; a device you borrowed from someone else was never *yours*) — the held-vs-borrowed nuance is the inventory's concern, the gallery is about ownership tenure. Read-only, self-scoped (no target param → no IDOR). By product decision the gallery, like the footprint, never names a device's previous owner. """ from __future__ import annotations import logging from datetime import UTC, datetime from typing import Any from uuid import UUID from pydantic import BaseModel from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.domains.asset.models import AssetHistory, AssetItem, AssetOperation from app.identity.person_models import IdentityPerson logger = logging.getLogger(__name__) # Ownership transitions — these MUST match what the asset service actually # writes. A device's holder is reconstructed by merging BOTH trails: the primary # "assign device to employee" path writes ONLY an AssetHistory row (no # operation), while peer-transfer writes both, and claim writes only an # operation — so neither table alone is complete. # # SET holder := the target person: _OP_SET = ("transfer", "claim") # AssetOperation.to_user_id _HIST_SET = ("assigned", "transferred") # AssetHistory.to_user # CLEAR holder := nobody (device left its holder): _OP_CLEAR = ("return", "disposal", "recycle_to_office", "release_from_office", "recover_from_leaver") _HIST_CLEAR = ("unassigned", "recycle_to_office", "release_from_office", "recovered_from_leaver", "retired", "deleted") # Deliberately NOT ownership transitions: borrow / borrow_return (a loan keeps # the original owner), mark_held_by_leaver / held_by_leaver (the leaver still # holds it). Feeding duplicate same-holder events (transfer appears in both # trails) is harmless — the state machine skips no-op transitions. _DAY = 86400.0 def _aware(dt: datetime | None) -> datetime | None: """MySQL DATETIME columns load back naive; treat naive values as UTC so span math never mixes offset-naive and offset-aware datetimes.""" if dt is None: return None return dt if dt.tzinfo is not None else dt.replace(tzinfo=UTC) class DeviceCard(BaseModel): id: str asset_tag: str | None asset_name: str | None category: str | None brand: str | None model: str | None service_days: int # total days the device was assigned to the caller first_received: str | None # ISO datetime of the first time it became theirs currently_held: bool rarity: str # 'rookie' | 'veteran' | 'elder' | 'legend' — earned by tenure class DeviceGalleryResponse(BaseModel): cards: list[DeviceCard] summary: dict[str, Any] generated_at: str def _rarity_for(service_days: int) -> str: if service_days >= 365 * 5: return "legend" if service_days >= 365 * 3: return "elder" if service_days >= 365: return "veteran" return "rookie" def _service_spans_for( events: list[tuple[datetime, UUID | None]], person_id: UUID, currently_held: bool, now: datetime, ) -> tuple[float, datetime | None]: """Sum the seconds the device was owned by ``person_id`` by replaying the merged ownership trail as a state machine. ``events`` is a time-ordered list of ``(timestamp, new_holder_or_None)`` transitions. Returns (total_seconds, first_received). Each maximal interval where the holder is ``person_id`` contributes its duration. If the person is the final holder and the live row confirms they still hold it, the open interval runs to ``now``.""" holder: UUID | None = None since: datetime | None = None total = 0.0 first_received: datetime | None = None def _close(end: datetime) -> None: nonlocal total, since if holder == person_id and since is not None: total += max(0.0, (end - since).total_seconds()) for ts, new_holder in events: if ts is None or new_holder == holder: continue _close(ts) holder = new_holder since = ts if holder == person_id and first_received is None: first_received = ts # Final open interval — only honoured if the live row still says it's theirs. if holder == person_id and since is not None: if currently_held: total += max(0.0, (now - since).total_seconds()) # else: the trail's last move left it with us but the live row disagrees # (e.g. a soft-deleted/disposed asset) — don't extend to now. return total, first_received async def build_my_device_gallery( db: AsyncSession, person: IdentityPerson, ) -> DeviceGalleryResponse: pid = person.id now = datetime.now(UTC) # Every asset the caller was ever GIVEN — ownership SET targets across BOTH # trails (operation transfer/claim + history assigned/transferred). The # assign-via-history path is essential: direct admin assignment writes no # operation row. received_asset_ids: set[UUID] = set((await db.execute( select(AssetOperation.asset_id).where( AssetOperation.to_user_id == pid, AssetOperation.status == "confirmed", AssetOperation.operation_type.in_(_OP_SET), ) )).scalars().all()) received_asset_ids |= set((await db.execute( select(AssetHistory.asset_id).where( AssetHistory.to_user == pid, AssetHistory.action.in_(_HIST_SET), ) )).scalars().all()) if not received_asset_ids: return DeviceGalleryResponse( cards=[], summary={"total_devices": 0, "current_count": 0, "total_service_days": 0}, generated_at=now.isoformat(), ) # Build the merged holder-transition event stream per asset from both trails. events_by_asset: dict[UUID, list[tuple[datetime, UUID | None]]] = {} op_rows = list((await db.execute( select(AssetOperation).where( AssetOperation.asset_id.in_(received_asset_ids), AssetOperation.status == "confirmed", AssetOperation.operation_type.in_(_OP_SET + _OP_CLEAR), ) )).scalars().all()) for op in op_rows: ts = _aware(op.created_at) if ts is None: continue holder = op.to_user_id if op.operation_type in _OP_SET else None events_by_asset.setdefault(op.asset_id, []).append((ts, holder)) hist_rows = list((await db.execute( select(AssetHistory).where( AssetHistory.asset_id.in_(received_asset_ids), AssetHistory.action.in_(_HIST_SET + _HIST_CLEAR), ) )).scalars().all()) for h in hist_rows: ts = _aware(h.created_at) if ts is None: continue holder = h.to_user if h.action in _HIST_SET else None events_by_asset.setdefault(h.asset_id, []).append((ts, holder)) for evs in events_by_asset.values(): evs.sort(key=lambda e: e[0]) # Batch-load the asset rows (some may be soft-deleted/disposed — still shown # as a memory; we just don't extend their span to now). asset_rows = list((await db.execute( select(AssetItem).where(AssetItem.id.in_(received_asset_ids)) )).scalars().all()) asset_by_id = {a.id: a for a in asset_rows} cards: list[DeviceCard] = [] total_service_days = 0 current_count = 0 for asset_id in received_asset_ids: a = asset_by_id.get(asset_id) if a is None: continue currently_held = a.assigned_to == pid and a.deleted_at is None seconds, first_received = _service_spans_for( events_by_asset.get(asset_id, []), pid, currently_held, now, ) service_days = int(seconds // _DAY) total_service_days += service_days if currently_held: current_count += 1 cards.append(DeviceCard( id=str(a.id), asset_tag=a.asset_tag, asset_name=a.asset_name, category=a.category, brand=a.brand, model=a.model, service_days=service_days, first_received=first_received.isoformat() if first_received else None, currently_held=currently_held, rarity=_rarity_for(service_days), )) # Current devices first, then by service time desc — the longest-serving # companions rise to the top of the collection. cards.sort(key=lambda c: (not c.currently_held, -c.service_days)) return DeviceGalleryResponse( cards=cards, summary={ "total_devices": len(cards), "current_count": current_count, "total_service_days": total_service_days, }, generated_at=now.isoformat(), ) __all__ = ["build_my_device_gallery", "DeviceGalleryResponse", "DeviceCard"]