Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 55 additions & 2 deletions api/asset.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -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 (
Expand Down Expand Up @@ -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,
Expand Down
9 changes: 9 additions & 0 deletions schemas/asset.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
103 changes: 102 additions & 1 deletion tests/test_asset.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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",
Expand Down
Loading