feat(assets): manual decommission — the state "Show decommissioned" filtered for
The "Show decommissioned" checkbox sent include_inactive=true and the backend
filter honoured it correctly, but no code path ever wrote status=DECOMMISSIONED:
the syncs only set INACTIVE/ACTIVE, the edit modal had no status field, and
Delete hard-removes the asset with its findings (CASCADE) instead of retiring
it. The filter had no state to filter for, so the checkbox looked dead.
The PUT endpoint did accept `status`, but wrote it with a bare setattr — the
transition reached the audit log as a generic ASSET_UPDATED instead of the
ASSET_DEACTIVATED / ASSET_REACTIVATED entry every sync-driven transition gets.
- Lifecycle Status select in the asset edit modal (edit only, editor+)
- PUT /assets/{id} routes status through apply_status() so the trail names the
transition, with force=True: the operator-final guard is there to stop a sync
from reviving a retired asset, not the operator who retired it
- tests/test_asset_decommission.py pins audit event, default filter and undo
No UI button for the lifecycle reconcile — it stays the nightly 04:15 job plus
POST /assets/reconcile-lifecycle (editor+). The tester text listing a
"Lifecycle abgleichen" button described a control that never existed.
This commit is contained in:
@@ -832,7 +832,15 @@ async def update_asset(
|
||||
|
||||
# Update nur gesetzte Felder
|
||||
import json as _json
|
||||
# Lifecycle transitions do not go through setattr: apply_status() writes the
|
||||
# dedicated ASSET_DEACTIVATED / ASSET_REACTIVATED audit entry, so a manual
|
||||
# decommission reads the same way in the trail as a sync-driven one.
|
||||
status_change: Optional[AssetStatus] = None
|
||||
for field, value in update_data.model_dump(exclude_unset=True).items():
|
||||
if field == "status":
|
||||
if value is not None:
|
||||
status_change = value if isinstance(value, AssetStatus) else AssetStatus(value)
|
||||
continue
|
||||
if field == "compliance_frameworks":
|
||||
# Stored as TEXT JSON list
|
||||
value = _json.dumps(value or []) if value is not None else None
|
||||
@@ -842,6 +850,16 @@ async def update_asset(
|
||||
raise HTTPException(400, "criticality must be one of: low, normal, high, critical")
|
||||
setattr(asset, field, value)
|
||||
|
||||
if status_change is not None:
|
||||
from app.services.asset_lifecycle import apply_status
|
||||
# force=True: the operator-final guard exists to stop a sync from
|
||||
# reviving a retired asset, not the operator who retired it.
|
||||
apply_status(
|
||||
db, asset, status_change,
|
||||
f"manual status change by {current_user.username}",
|
||||
force=True,
|
||||
)
|
||||
|
||||
db.commit()
|
||||
db.refresh(asset)
|
||||
|
||||
|
||||
@@ -164,7 +164,13 @@ def _liveness_stamp(asset: Asset) -> Optional[datetime]:
|
||||
return max(stamps) if stamps else None
|
||||
|
||||
|
||||
def apply_status(db: Session, asset: Asset, new: AssetStatus, reason: str) -> bool:
|
||||
def apply_status(
|
||||
db: Session,
|
||||
asset: Asset,
|
||||
new: AssetStatus,
|
||||
reason: str,
|
||||
force: bool = False,
|
||||
) -> bool:
|
||||
"""Set `asset.status` and audit-log the transition. No-op if unchanged.
|
||||
|
||||
Every sync that writes a status must go through here. Writing
|
||||
@@ -174,10 +180,12 @@ def apply_status(db: Session, asset: Asset, new: AssetStatus, reason: str) -> bo
|
||||
trail must not do (field report 2026-08-18).
|
||||
|
||||
DECOMMISSIONED is operator-final: a sync never revives it, mirroring both
|
||||
reconcile functions in this module.
|
||||
reconcile functions in this module. `force=True` lifts that guard for the
|
||||
one caller it was never meant to stop — the human who retired the asset
|
||||
and now wants it back (PUT /assets/{id}). Syncs must never pass it.
|
||||
"""
|
||||
old = asset.status.value if hasattr(asset.status, "value") else str(asset.status)
|
||||
if old == AssetStatus.DECOMMISSIONED.value:
|
||||
if old == AssetStatus.DECOMMISSIONED.value and not force:
|
||||
return False
|
||||
if old == new.value:
|
||||
return False
|
||||
|
||||
Reference in New Issue
Block a user