From 64242a281118191022dfda79e7f724d7ea97031f Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Tue, 23 Jun 2026 10:24:22 -0500 Subject: [PATCH 1/2] feat(api/asset): Add new endpoint to update or delete asset thing association --- api/asset.py | 57 ++++++++++++++++++++++++++++++++++++++++++++++-- schemas/asset.py | 9 ++++++++ 2 files changed, 64 insertions(+), 2 deletions(-) diff --git a/api/asset.py b/api/asset.py index 32fa88580..0439b7b05 100644 --- a/api/asset.py +++ b/api/asset.py @@ -19,7 +19,7 @@ from fastapi import APIRouter, Depends, Form, UploadFile, File from fastapi_pagination.ext.sqlalchemy import paginate -from sqlalchemy import select +from sqlalchemy import delete, select from sqlalchemy.exc import ProgrammingError from starlette.concurrency import run_in_threadpool from starlette.status import ( @@ -38,7 +38,13 @@ ) from db import Thing from db.asset import Asset, AssetThingAssociation -from schemas.asset import AssetResponse, CreateAsset, UpdateAsset +from schemas.asset import ( + AssetAssociationResponse, + AssetAssociationUpdate, + AssetResponse, + CreateAsset, + UpdateAsset, +) from services.audit_helper import audit_add from services.crud_helper import model_patcher, model_deleter from services.edit_notification_helper import ( @@ -481,6 +487,53 @@ async def get_asset( # PATCH ====================================================================== +@router.patch("/{asset_id}/association") +async def update_asset_thing_association( + asset_id: int, + association_data: AssetAssociationUpdate, + session: session_dependency, + user: editor_dependency, +) -> AssetAssociationResponse: + """ + Move an asset to another Thing or remove its Thing association. + + Passing a `thing_id` replaces any existing Thing links for the asset with + that one Thing. Passing `thing_id: null` leaves the Asset record in place + and removes all Thing links. + """ + simple_get_by_id(session, Asset, asset_id) + + thing_id = association_data.thing_id + thing = None + if thing_id is not None: + thing = session.get(Thing, thing_id) + if thing is None: + raise PydanticStyleException( + status_code=HTTP_409_CONFLICT, + detail=[ + { + "loc": ["body", "thing_id"], + "msg": f"Thing with ID {thing_id} not found.", + "type": "value_error", + "input": {"thing_id": thing_id}, + } + ], + ) + + association_delete = delete(AssetThingAssociation).where( + AssetThingAssociation.asset_id == asset_id + ) + session.execute(association_delete) + + if thing is not None: + assoc = AssetThingAssociation(asset_id=asset_id, thing_id=thing.id) + audit_add(user, assoc) + session.add(assoc) + + session.commit() + return AssetAssociationResponse(asset_id=asset_id, thing_id=thing_id) + + @router.patch("/{asset_id}") async def update_asset( asset_id: int, diff --git a/schemas/asset.py b/schemas/asset.py index c62ab68a5..3fbca7eef 100644 --- a/schemas/asset.py +++ b/schemas/asset.py @@ -38,6 +38,15 @@ class AssetResponse(BaseResponseModel, BaseAsset): signed_url: str | None = None +class AssetAssociationUpdate(BaseModel): + thing_id: int | None = None + + +class AssetAssociationResponse(BaseModel): + asset_id: int + thing_id: int | None = None + + # -------- UPDATE ---------- class UpdateAsset(BaseUpdateModel): name: str | None = None From 4b3cc09b16f755586ec92f1d38c0bd09032ebb36 Mon Sep 17 00:00:00 2001 From: Tyler Adam Martinez Date: Tue, 23 Jun 2026 10:41:36 -0500 Subject: [PATCH 2/2] test(asset): add move association, remove association tests, & more --- tests/test_asset.py | 103 +++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 102 insertions(+), 1 deletion(-) diff --git a/tests/test_asset.py b/tests/test_asset.py index 533cd560b..158e53597 100644 --- a/tests/test_asset.py +++ b/tests/test_asset.py @@ -21,11 +21,13 @@ from unittest.mock import MagicMock, patch import pytest +from sqlalchemy import select from api.asset import get_storage_bucket from main import app from core.dependencies import viewer_function, admin_function, editor_function -from db import Asset +from db.engine import session_ctx +from db import Asset, AssetThingAssociation, Thing from schemas import DT_FMT from services import gcs_helper from tests import ( @@ -334,6 +336,105 @@ def test_get_asset_by_id_404_not_found(asset): # PATCH tests ================================================================ +def test_patch_asset_association_moves_asset_to_another_thing( + asset_with_associated_thing, water_well_thing +): + with session_ctx() as session: + other_thing = Thing( + name="Other Test Well", + thing_type="water well", + release_status="draft", + ) + session.add(other_thing) + session.commit() + session.refresh(other_thing) + other_thing_id = other_thing.id + + response = client.patch( + f"/asset/{asset_with_associated_thing.id}/association", + json={"thing_id": other_thing_id}, + ) + + assert response.status_code == 200 + data = response.json() + assert data == { + "asset_id": asset_with_associated_thing.id, + "thing_id": other_thing_id, + } + + with session_ctx() as session: + asset_association_matches = ( + AssetThingAssociation.asset_id == asset_with_associated_thing.id + ) + thing_id_query = select(AssetThingAssociation.thing_id).where( + asset_association_matches + ) + thing_ids = session.scalars(thing_id_query).all() + assert thing_ids == [other_thing_id] + assert water_well_thing.id not in thing_ids + + session.delete(session.get(Thing, other_thing_id)) + session.commit() + + +def test_patch_asset_association_disassociates_asset( + asset_with_associated_thing, +): + response = client.patch( + f"/asset/{asset_with_associated_thing.id}/association", + json={"thing_id": None}, + ) + + assert response.status_code == 200 + data = response.json() + assert data == { + "asset_id": asset_with_associated_thing.id, + "thing_id": None, + } + + with session_ctx() as session: + asset = session.get(Asset, asset_with_associated_thing.id) + asset_association_matches = ( + AssetThingAssociation.asset_id == asset_with_associated_thing.id + ) + association = session.scalars( + select(AssetThingAssociation).where(asset_association_matches) + ).one_or_none() + + assert asset is not None + assert association is None + + +def test_patch_asset_association_bad_thing_id(asset_with_associated_thing): + bad_thing_id = 99999 + + response = client.patch( + f"/asset/{asset_with_associated_thing.id}/association", + json={"thing_id": bad_thing_id}, + ) + + assert response.status_code == 409 + data = response.json() + assert data["detail"][0]["loc"] == ["body", "thing_id"] + expected_message = f"Thing with ID {bad_thing_id} not found." + assert data["detail"][0]["msg"] == expected_message + assert data["detail"][0]["type"] == "value_error" + assert data["detail"][0]["input"] == {"thing_id": bad_thing_id} + + +def test_patch_asset_association_bad_asset_id(): + bad_asset_id = 99999 + + response = client.patch( + f"/asset/{bad_asset_id}/association", + json={"thing_id": None}, + ) + + assert response.status_code == 404 + data = response.json() + assert data["detail"] == f"Asset with ID {bad_asset_id} not found." + + def test_patch_asset(asset): payload = { "name": "patched name",