Skip to content

Commit 8aff6a2

Browse files
committed
Merge remote-tracking branch 'origin/main' into simplified-brain-regions
2 parents 3b7b5f9 + 77eb388 commit 8aff6a2

File tree

10 files changed

+133
-2697
lines changed

10 files changed

+133
-2697
lines changed

alembic/versions/20250509_121806_bcc13861444b_base_models.py

Lines changed: 0 additions & 2260 deletions
This file was deleted.

alembic/versions/20250509_121821_f81f202805ce_triggers.py

Lines changed: 0 additions & 419 deletions
This file was deleted.

app/db/model.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
AgentType,
3636
AgePeriod,
3737
AnnotationBodyType,
38+
AssetLabel,
3839
AssetStatus,
3940
ElectricalRecordingOrigin,
4041
ElectricalRecordingStimulusShape,
@@ -852,6 +853,7 @@ class Asset(Identifiable):
852853
size: Mapped[BIGINT]
853854
sha256_digest: Mapped[bytes | None] = mapped_column(LargeBinary(32))
854855
meta: Mapped[JSON_DICT] # not used yet. can be useful?
856+
label: Mapped[AssetLabel | None]
855857
entity_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("entity.id"), index=True)
856858

857859
# partial unique index

app/db/types.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,3 +176,18 @@ class StructuralDomain(StrEnum):
176176
apical_dendrite = auto()
177177
basal_dendrite = auto()
178178
axon = auto()
179+
180+
181+
class AssetLabel(StrEnum):
182+
neurolucida = auto()
183+
swc = auto()
184+
hdf5 = auto()
185+
186+
187+
ALLOWED_ASSET_LABELS_PER_ENTITY = {
188+
EntityType.reconstruction_morphology: {
189+
AssetLabel.neurolucida,
190+
AssetLabel.swc,
191+
AssetLabel.hdf5,
192+
}
193+
}

app/errors.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from typing import Any
99

1010
from psycopg2.errors import ForeignKeyViolation, InsufficientPrivilege, UniqueViolation
11+
from pydantic import ValidationError
1112
from sqlalchemy.exc import IntegrityError, NoResultFound, ProgrammingError
1213

1314
from app.utils.enum import UpperStrEnum
@@ -43,6 +44,7 @@ class ApiErrorCode(UpperStrEnum):
4344
ASSET_MISSING_PATH = auto()
4445
ASSET_INVALID_PATH = auto()
4546
ASSET_NOT_A_DIRECTORY = auto()
47+
ASSET_INVALID_SCHEMA = auto()
4648
ION_NAME_NOT_FOUND = auto()
4749

4850

@@ -138,3 +140,19 @@ def ensure_authorized_references(
138140
message=error_message, error_code=error_code, http_status_code=HTTPStatus.FORBIDDEN
139141
) from err
140142
raise
143+
144+
145+
@contextmanager
146+
def ensure_valid_schema(
147+
error_message: str, error_code: ApiErrorCode = ApiErrorCode.INVALID_REQUEST
148+
) -> Iterator[None]:
149+
"""Context manager that raises ApiError when a schema validation error is raised."""
150+
try:
151+
yield
152+
except ValidationError as err:
153+
raise ApiError(
154+
message=error_message,
155+
error_code=error_code,
156+
http_status_code=HTTPStatus.UNPROCESSABLE_ENTITY,
157+
details=[e["msg"] for e in err.errors()],
158+
) from err

app/repository/asset.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ def create_entity_asset(self, entity_id: uuid.UUID, asset: AssetCreate) -> Asset
6565
size=asset.size,
6666
sha256_digest=sha256_digest,
6767
meta=asset.meta,
68+
label=asset.label,
6869
)
6970
.returning(Asset)
7071
)

app/routers/asset.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
from starlette.responses import RedirectResponse
1111

1212
from app.config import settings
13-
from app.db.types import EntityType
13+
from app.db.types import AssetLabel, EntityType
1414
from app.dependencies.auth import UserContextDep, UserContextWithProjectIdDep
1515
from app.dependencies.db import RepoGroupDep
1616
from app.dependencies.s3 import S3ClientDep
@@ -92,6 +92,7 @@ def upload_entity_asset(
9292
entity_id: uuid.UUID,
9393
file: UploadFile,
9494
meta: Annotated[dict | None, Form()] = None,
95+
label: Annotated[AssetLabel | None, Form()] = None,
9596
) -> AssetRead:
9697
"""Upload an asset to be associated with the specified entity.
9798
@@ -123,6 +124,7 @@ def upload_entity_asset(
123124
size=file.size,
124125
sha256_digest=sha256_digest,
125126
meta=meta,
127+
label=label,
126128
)
127129
if not upload_to_s3(
128130
s3_client,

app/schemas/asset.py

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import uuid
22

3-
from pydantic import BaseModel, ConfigDict, field_validator
3+
from pydantic import BaseModel, ConfigDict, field_validator, model_validator
44

5-
from app.db.types import AssetStatus
5+
from app.db.types import ALLOWED_ASSET_LABELS_PER_ENTITY, AssetLabel, AssetStatus, EntityType
66

77

88
class AssetBase(BaseModel):
@@ -16,6 +16,7 @@ class AssetBase(BaseModel):
1616
size: int
1717
sha256_digest: str | None
1818
meta: dict
19+
label: AssetLabel | None = None
1920

2021
@field_validator("sha256_digest", mode="before")
2122
@classmethod
@@ -35,6 +36,29 @@ class AssetRead(AssetBase):
3536
class AssetCreate(AssetBase):
3637
"""Asset model for creation."""
3738

39+
entity_type: EntityType
40+
41+
@model_validator(mode="after")
42+
def ensure_entity_type_label_consistency(self):
43+
"""Asset label must be within the allowed labels for the entity_type."""
44+
if not self.label:
45+
return self
46+
47+
allowed_asset_labels = ALLOWED_ASSET_LABELS_PER_ENTITY.get(self.entity_type, None)
48+
49+
if allowed_asset_labels is None:
50+
msg = f"There are no allowed asset labels defined for '{self.entity_type}'"
51+
raise ValueError(msg)
52+
53+
if self.label not in allowed_asset_labels:
54+
msg = (
55+
f"Asset label '{self.label}' is not allowed for entity type '{self.entity_type}'. "
56+
f"Allowed asset labels: {sorted(label.value for label in allowed_asset_labels)}"
57+
)
58+
raise ValueError(msg)
59+
60+
return self
61+
3862

3963
class AssetsMixin(BaseModel):
4064
assets: list[AssetRead] | None

app/service/asset.py

Lines changed: 18 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import uuid
22

3-
from app.db.types import AssetStatus, EntityType
4-
from app.errors import ApiErrorCode, ensure_result, ensure_uniqueness
3+
from app.db.types import AssetLabel, AssetStatus, EntityType
4+
from app.errors import ApiErrorCode, ensure_result, ensure_uniqueness, ensure_valid_schema
55
from app.repository.group import RepositoryGroup
66
from app.schemas.asset import AssetCreate, AssetRead
77
from app.schemas.auth import UserContext, UserContextWithProjectId
@@ -59,6 +59,7 @@ def create_entity_asset(
5959
size: int,
6060
sha256_digest: str | None,
6161
meta: dict | None,
62+
label: AssetLabel | None,
6263
) -> AssetRead:
6364
"""Create an asset for an entity."""
6465
entity = entity_service.get_writable_entity(
@@ -75,15 +76,21 @@ def create_entity_asset(
7576
filename=filename,
7677
is_public=entity.authorized_public,
7778
)
78-
asset_create = AssetCreate(
79-
path=filename,
80-
full_path=full_path,
81-
is_directory=False,
82-
content_type=content_type,
83-
size=size,
84-
sha256_digest=sha256_digest,
85-
meta=meta or {},
86-
)
79+
80+
with ensure_valid_schema(
81+
"Asset schema is invalid", error_code=ApiErrorCode.ASSET_INVALID_SCHEMA
82+
):
83+
asset_create = AssetCreate(
84+
path=filename,
85+
full_path=full_path,
86+
is_directory=False,
87+
content_type=content_type,
88+
size=size,
89+
sha256_digest=sha256_digest,
90+
meta=meta or {},
91+
label=label,
92+
entity_type=entity_type,
93+
)
8794
with ensure_uniqueness(
8895
f"Asset with path {asset_create.path!r} already exists",
8996
error_code=ApiErrorCode.ASSET_DUPLICATED,

tests/routers/test_asset.py

Lines changed: 50 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
import pytest
55

66
from app.db.model import Asset, Entity
7-
from app.db.types import AssetStatus, EntityType
7+
from app.db.types import AssetLabel, AssetStatus, EntityType
88
from app.errors import ApiErrorCode
99
from app.routers.asset import EntityRoute
1010
from app.schemas.api import ErrorResponse
@@ -35,13 +35,18 @@ def _route(entity_type: EntityType) -> str:
3535
return f"/{_entity_type_to_route(entity_type)}"
3636

3737

38-
def _upload_entity_asset(client, entity_type: EntityType, entity_id: UUID):
38+
def _upload_entity_asset(
39+
client, entity_type: EntityType, entity_id: UUID, label: str | None = None
40+
):
3941
with FILE_EXAMPLE_PATH.open("rb") as f:
4042
files = {
4143
# (filename, file (or bytes), content_type, headers)
4244
"file": ("a/b/c.txt", f, "text/plain")
4345
}
44-
return client.post(f"{_route(entity_type)}/{entity_id}/assets", files=files)
46+
data = None
47+
if label:
48+
data = {"label": label}
49+
return client.post(f"{_route(entity_type)}/{entity_id}/assets", files=files, data=data)
4550

4651

4752
def _get_expected_full_path(entity, path):
@@ -95,7 +100,9 @@ def asset_directory(db, entity) -> AssetRead:
95100

96101

97102
def test_upload_entity_asset(client, entity):
98-
response = _upload_entity_asset(client, entity_type=entity.type, entity_id=entity.id)
103+
response = _upload_entity_asset(
104+
client, entity_type=entity.type, entity_id=entity.id, label="neurolucida"
105+
)
99106
assert response.status_code == 201, f"Failed to create asset: {response.text}"
100107
data = response.json()
101108

@@ -110,6 +117,7 @@ def test_upload_entity_asset(client, entity):
110117
"sha256_digest": FILE_EXAMPLE_DIGEST,
111118
"meta": {},
112119
"status": "created",
120+
"label": "neurolucida",
113121
}
114122

115123
# try to upload again the same file with the same path
@@ -133,6 +141,42 @@ def test_upload_entity_asset(client, entity):
133141
assert error.error_code == ApiErrorCode.ENTITY_NOT_FOUND
134142

135143

144+
def test_upload_entity_asset__label(monkeypatch, client, entity):
145+
response = _upload_entity_asset(
146+
client, entity_type=entity.type, entity_id=entity.id, label="foo"
147+
)
148+
assert response.status_code == 422, "Assel label was not rejected as not present in AssetLabel."
149+
150+
monkeypatch.setattr("app.schemas.asset.ALLOWED_ASSET_LABELS_PER_ENTITY", {})
151+
152+
response = _upload_entity_asset(
153+
client, entity_type=entity.type, entity_id=entity.id, label=AssetLabel.hdf5
154+
)
155+
assert response.status_code == 422
156+
assert response.json() == {
157+
"error_code": "ASSET_INVALID_SCHEMA",
158+
"message": "Asset schema is invalid",
159+
"details": [f"Value error, There are no allowed asset labels defined for '{entity.type}'"],
160+
}
161+
162+
required = {EntityType.reconstruction_morphology: {AssetLabel.swc}}
163+
164+
monkeypatch.setattr("app.schemas.asset.ALLOWED_ASSET_LABELS_PER_ENTITY", required)
165+
166+
response = _upload_entity_asset(
167+
client, entity_type=entity.type, entity_id=entity.id, label=AssetLabel.hdf5
168+
)
169+
assert response.status_code == 422
170+
assert response.json() == {
171+
"error_code": "ASSET_INVALID_SCHEMA",
172+
"message": "Asset schema is invalid",
173+
"details": [
174+
f"Value error, Asset label '{AssetLabel.hdf5}' is not allowed for "
175+
f"entity type '{entity.type}'. Allowed asset labels: ['{AssetLabel.swc}']"
176+
],
177+
}
178+
179+
136180
def test_get_entity_asset(client, entity, asset):
137181
response = client.get(f"{_route(entity.type)}/{entity.id}/assets/{asset.id}")
138182

@@ -149,6 +193,7 @@ def test_get_entity_asset(client, entity, asset):
149193
"sha256_digest": FILE_EXAMPLE_DIGEST,
150194
"meta": {},
151195
"status": "created",
196+
"label": None,
152197
}
153198

154199
# try to get an asset with non-existent entity id
@@ -181,6 +226,7 @@ def test_get_entity_assets(client, entity, asset):
181226
"sha256_digest": FILE_EXAMPLE_DIGEST,
182227
"meta": {},
183228
"status": "created",
229+
"label": None,
184230
}
185231
]
186232

0 commit comments

Comments
 (0)