Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
53 commits
Select commit Hold shift + click to select a range
c65920e
refactor: update observation model
jacob-a-brown Aug 18, 2025
a29cf20
feat: udpate observation schemas
jacob-a-brown Aug 18, 2025
14a5351
refactor: simplify adding observations
jacob-a-brown Aug 18, 2025
eefc32d
fix: remove field_sample_id from observation
jacob-a-brown Aug 18, 2025
de3ad5c
refactor: make only the id the primary key
jacob-a-brown Aug 18, 2025
3560028
refactor: inherit from AutBaseMixin
jacob-a-brown Aug 18, 2025
544b910
feat: add temperature and Calcius to lexicon
jacob-a-brown Aug 18, 2025
9b697a4
refactor: update C to deg C for clarity
jacob-a-brown Aug 18, 2025
79ae468
refactor: update observation POST tests
jacob-a-brown Aug 18, 2025
153fc11
note: noting thoughts on query parameters
jacob-a-brown Aug 19, 2025
c86e2ba
fix: fix observation fixtures
jacob-a-brown Aug 19, 2025
d9f58bc
refactor: order observed properties by observation type
jacob-a-brown Aug 19, 2025
d0fe51e
feat: implement get groundwater level observations
jacob-a-brown Aug 19, 2025
bd450f4
refactor: observation responses should inherit from ORMBaseModel
jacob-a-brown Aug 19, 2025
c39d120
feat: implement GET water chemistry observations
jacob-a-brown Aug 19, 2025
3ded376
refactor: specify URL for 404 wrong observation class
jacob-a-brown Aug 19, 2025
dc4f5a1
feat: implement get observations for observation classes
jacob-a-brown Aug 19, 2025
4db3473
feat: implement general GET for /observation
jacob-a-brown Aug 19, 2025
75a54c4
feat: add auth to GET endpoints
jacob-a-brown Aug 19, 2025
edda50b
feat: implement PATCH for groundwater level observations
jacob-a-brown Aug 19, 2025
1ab5c49
feat: create observation helpers for sub tables in observation table
jacob-a-brown Aug 19, 2025
4944871
refactor: update error msg for wrong observation class ok id
jacob-a-brown Aug 19, 2025
c626ea3
refactor: update observation fixtures dt for testing order
jacob-a-brown Aug 19, 2025
202a63e
feat: implement PATCH for water chemistry observations
jacob-a-brown Aug 19, 2025
caf541a
feat: implement PATCH for geothermal observations
jacob-a-brown Aug 19, 2025
39be19f
feat: implement DELETE observation by ID
jacob-a-brown Aug 19, 2025
11e9205
Merge branch 'jab-api-coverage-contact' into jab-api-coverage-observa…
jacob-a-brown Aug 20, 2025
2ed59d1
Merge branch 'pre-production' into jab-api-coverage-observation
jacob-a-brown Aug 20, 2025
c4458c6
refactor: use simple_get_by id for /asset/{asset_id}
jacob-a-brown Aug 20, 2025
c8fdc3e
refactor: update database error handler
jacob-a-brown Aug 20, 2025
cd4fdbe
feat: implement POST endpoints for asset
jacob-a-brown Aug 20, 2025
511341c
feat: endpoint and tests for GET asset by ID
jacob-a-brown Aug 20, 2025
6c779d9
refactor: apply transformer to every item in list if asset is list of…
jacob-a-brown Aug 20, 2025
eac0c31
feat: type hint add_signed_url
jacob-a-brown Aug 20, 2025
e357b0e
refactor: simplify method to specify observation class
jacob-a-brown Aug 21, 2025
48684d4
Merge branch 'pre-production' into jab-api-coverage-observation
jacob-a-brown Aug 21, 2025
0df0815
Merge branch 'pre-production' into jab-api-coverage-observation
jacob-a-brown Aug 21, 2025
6d6d837
Merge branch 'jab-api-coverage-observation' into jab-api-coverage-asset
jacob-a-brown Aug 21, 2025
4f46bb7
refactor: use <observation class>:<observed_property> in observed pro…
jacob-a-brown Aug 21, 2025
7f7d473
Merge branch 'pre-production' into jab-api-coverage-asset
jacob-a-brown Aug 21, 2025
5c8c3b0
Merge branch 'jab-api-coverage-observation' into jab-api-coverage-asset
jacob-a-brown Aug 21, 2025
e63b04e
fix: add signed urls to all retrieved records
jacob-a-brown Aug 21, 2025
600d3bf
refactor: get observation class from path
jacob-a-brown Aug 21, 2025
658b8fa
Merge branch 'jab-api-coverage-observation' into jab-api-coverage-asset
jacob-a-brown Aug 21, 2025
e397d1b
feat: implement patch asset schema and test
jacob-a-brown Aug 22, 2025
52e4d06
feat: implemetn DELETE asset and remove from gcs
jacob-a-brown Aug 22, 2025
b619f1a
fix: add back thing_id fk error handler for post/patch contact
jacob-a-brown Aug 22, 2025
1a6bbe5
refactor: don't add signed url to asset without existing thing
jacob-a-brown Aug 22, 2025
a3ac5d0
refactor: only add signed url for assets with thing associations
jacob-a-brown Aug 23, 2025
71aba67
fix: delete fixtures
jacob-a-brown Aug 23, 2025
d48ee3e
refactor: address PR 97 feedback
jacob-a-brown Aug 25, 2025
90cee54
refactor: get_storage_bucket should not be dependency in GET /asset
jacob-a-brown Aug 25, 2025
611ca3d
feat: add developer's note about signed urls for assets
jacob-a-brown Aug 25, 2025
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
156 changes: 111 additions & 45 deletions api/asset.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,11 @@
# limitations under the License.
# ===============================================================================

from fastapi import APIRouter, Depends, UploadFile, File, HTTPException
from fastapi import APIRouter, Depends, UploadFile, File
from fastapi_pagination.ext.sqlalchemy import paginate
from sqlalchemy import select
from starlette.status import HTTP_201_CREATED
from sqlalchemy.exc import ProgrammingError
from starlette.status import HTTP_201_CREATED, HTTP_409_CONFLICT, HTTP_204_NO_CONTENT

from api.pagination import CustomPage
from core.dependencies import (
Expand All @@ -31,20 +32,51 @@
from db.asset import Asset, AssetThingAssociation
from schemas.asset import AssetResponse, CreateAsset, UpdateAsset
from services.audit_helper import audit_add
from services.crud_helper import model_patcher
from services.crud_helper import model_patcher, model_deleter
from services.query_helper import simple_get_by_id
from services.gcs_helper import (
get_storage_bucket,
gcs_upload,
gcs_remove,
check_asset_exists,
add_signed_url,
)
from services.exceptions_helper import PydanticStyleException

router = APIRouter(
prefix="/asset", tags=["asset"], dependencies=[Depends(viewer_function)]
)


# ======= Create =========
def database_error_handler(payload: CreateAsset, error: ProgrammingError) -> None:
"""
Handle errors raised by the database when adding or updating a asset.
"""

error_message = error.orig.args[0]["M"]

if (
error_message
== 'null value in column "thing_id" of relation "asset_thing_association" violates not-null constraint'
):
"""
Developer's notes

this error occurs because the thing_id is set by the Thing record that
is retrieved, so if there is no Thing with thing_id it tries to set
thing_id to None in the AssetThingAssociation table
"""
detail = {
"loc": ["body", "thing_id"],
"msg": f"Thing with ID {payload.thing_id} not found.",
"type": "value_error",
"input": {"thing_id": payload.thing_id},
}

raise PydanticStyleException(status_code=HTTP_409_CONFLICT, detail=[detail])


# POST =========================================================================
@router.post(
"/upload", status_code=HTTP_201_CREATED, dependencies=[Depends(admin_function)]
)
Expand All @@ -60,42 +92,63 @@ async def upload_asset(

@router.post("", status_code=HTTP_201_CREATED)
async def add_asset(
user: admin_dependency, session: session_dependency, asset_data: CreateAsset
user: admin_dependency,
session: session_dependency,
asset_data: CreateAsset,
bucket=Depends(get_storage_bucket),
) -> AssetResponse:

data = asset_data.model_dump()
thing_id = data.pop("thing_id", None)
storage_path = data["storage_path"]
try:
data = asset_data.model_dump()
thing_id = data.pop("thing_id", None)

# check to see if an asset entry already exists for
# this storage path and thing_id
existing_asset = check_asset_exists(session, storage_path, thing_id=thing_id)
if existing_asset:
# If an asset already exists, return it
return existing_asset
storage_path = data["storage_path"]

data["storage_service"] = "gcs"
asset = Asset(**data)
audit_add(user, asset)
# check to see if an asset entry already exists for
# this storage path and thing_id
existing_asset = check_asset_exists(session, storage_path, thing_id=thing_id)
if existing_asset:
# If an asset already exists, return it
return existing_asset

data["storage_service"] = "gcs"
asset = Asset(**data)
audit_add(user, asset)

if thing_id:
assoc = AssetThingAssociation()
audit_add(user, assoc)
thing = session.get(Thing, thing_id)
assoc.thing = thing
assoc.asset = asset
session.add(assoc)

session.add(asset)
session.commit()
session.refresh(asset)

return asset
except ProgrammingError as e:
database_error_handler(asset_data, e)

if thing_id:
assoc = AssetThingAssociation()
audit_add(user, assoc)
thing = session.get(Thing, thing_id)
assoc.thing = thing
assoc.asset = asset
session.add(assoc)

session.add(asset)
session.commit()
session.refresh(asset)
return asset

# GET ==========================================================================

"""
Developer's notes

Do not generate signed urls when listing ALL assets. There is a reason to
generate signed urls when listing assets for a given `thing_id` because this
is used by the front end to display a gallery of images all at once. This is
the only case in which signed urls should be generated for a list of assets. A
signed url is always generated when retrieving assets individually
"""


# ======= Read =========
@router.get("")
async def list_assets(
session: session_dependency, thing_id: int = None
session: session_dependency,
thing_id: int = None,
) -> CustomPage[AssetResponse]:
"""
List all assets or assets associated with a specific thing.
Expand All @@ -110,7 +163,6 @@ def transformer(records: list[Asset]):
if thing_id is not None:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Lets add a comment explaining this behavior e.g.

Do not generate signed urls when listing ALL assets. There is a reason to generate signed urls when listing assets for a given `thing_id` because this is used by the front end to display a gallery of images all at once. This is the only case in which signed urls should be generated for a list of assets. A signed url is always generated when retrieving assets individually

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This note was added at the beginning of the GET section of the file since it applies to both GET /asset and GET /asset/{asset_id}

bucket = get_storage_bucket()
records = [add_signed_url(ai, bucket) for ai in records]

return records

return paginate(query=sql, conn=session, transformer=transformer)
Expand All @@ -120,29 +172,18 @@ def transformer(records: list[Asset]):
async def get_asset(
asset_id: int,
session: session_dependency,
thing_id: int = None,
bucket=Depends(get_storage_bucket),
) -> AssetResponse:
"""
Retrieve an asset by its ID.
"""
sql = select(Asset)
if thing_id:
sql = sql.join(AssetThingAssociation).where(
AssetThingAssociation.thing_id == thing_id
)
else:
sql = sql.where(Asset.id == asset_id)

asset = session.scalars(sql).one_or_none()
if not asset:
raise HTTPException(status_code=404, detail="Asset not found")
asset = simple_get_by_id(session, Asset, asset_id)

add_signed_url(asset, bucket)
return asset


# ======= Update =========
# PATCH ========================================================================
@router.patch("/{asset_id}")
async def update_asset(
asset_id: int,
Expand All @@ -156,4 +197,29 @@ async def update_asset(
return model_patcher(session, Asset, asset_id, asset_data, user=user)


# DELETE =======================================================================


@router.delete("/{asset_id}", status_code=HTTP_204_NO_CONTENT)
async def delete_asset(
asset_id: int, session: session_dependency, user: admin_dependency
):
return model_deleter(session, Asset, asset_id)


@router.delete(
"/{asset_id}/remove",
status_code=HTTP_204_NO_CONTENT,
dependencies=[Depends(admin_function)],
)
async def remove_asset(
asset_id: int,
session: session_dependency,
user: admin_dependency,
bucket=Depends(get_storage_bucket),
):
asset = simple_get_by_id(session, Asset, asset_id)
gcs_remove(asset.uri, bucket)


# ============= EOF =============================================
14 changes: 1 addition & 13 deletions api/contact.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,6 @@
CreateAddress,
CreateEmail,
CreatePhone,
CreateThingAssociation,
PhoneResponse,
EmailResponse,
AddressResponse,
Expand All @@ -60,7 +59,7 @@


def database_error_handler(
payload: CreateThingAssociation, error: ProgrammingError
payload: CreateEmail | CreateContact | CreatePhone, error: ProgrammingError
) -> None:
"""
Handle errors raised by the database when adding or updating a sample.
Expand All @@ -78,17 +77,6 @@ def database_error_handler(
"type": "value_error",
"input": {"thing_id": payload.thing_id},
}

elif (
error_message
== 'insert or update on table "thing_contact_association" violates foreign key constraint "thing_contact_association_contact_id_fkey"'
):
detail = {
"loc": ["body", "contact_id"],
"msg": f"Contact with ID {payload.contact_id} not found.",
"type": "value_error",
"input": {"contact_id": payload.contact_id},
}
elif (
error_message
== 'insert or update on table "email" violates foreign key constraint "email_contact_id_fkey"'
Expand Down
Loading
Loading