Skip to content

Commit 740c8b9

Browse files
authored
Deleted assets are now no longer included in responses of other assets' relationships (#646)
* Do not serialize direct relationships to soft-deleted assets * Do not materialiaze soft deleted assets in server responses * Add a migration script
1 parent 13e5fc5 commit 740c8b9

File tree

15 files changed

+207
-51
lines changed

15 files changed

+207
-51
lines changed
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
"""cascade delete ai resource
2+
3+
Revision ID: 8f9ac801a283
4+
Revises: 95fa6a3c7eee
5+
Create Date: 2025-10-30 14:10:21.536071
6+
7+
"""
8+
9+
from typing import Sequence, Union
10+
11+
from alembic import op
12+
import sqlalchemy as sa
13+
14+
15+
# revision identifiers, used by Alembic.
16+
revision: str = "8f9ac801a283"
17+
down_revision: Union[str, None] = "95fa6a3c7eee"
18+
branch_labels: Union[str, Sequence[str], None] = None
19+
depends_on: Union[str, Sequence[str], None] = None
20+
21+
22+
def recreate_constraints(with_on_delete_cascade: bool):
23+
for table in ["part", "relevant"]:
24+
op.drop_constraint(
25+
f"ai_resource_{table}_link_ibfk_1", f"ai_resource_{table}_link", type_="foreignkey"
26+
)
27+
op.drop_constraint(
28+
f"ai_resource_{table}_link_ibfk_2", f"ai_resource_{table}_link", type_="foreignkey"
29+
)
30+
31+
op.create_foreign_key(
32+
f"ai_resource_{table}_link_ibfk_1",
33+
f"ai_resource_{table}_link",
34+
"ai_resource",
35+
["parent_identifier"],
36+
["identifier"],
37+
onupdate="CASCADE",
38+
ondelete="CASCADE" if with_on_delete_cascade else None,
39+
)
40+
local_col = "child_identifier" if table == "part" else "relevant_identifier"
41+
op.create_foreign_key(
42+
f"ai_resource_{table}_link_ibfk_2",
43+
f"ai_resource_{table}_link",
44+
"ai_resource",
45+
[local_col],
46+
["identifier"],
47+
onupdate="CASCADE",
48+
ondelete="CASCADE" if with_on_delete_cascade else None,
49+
)
50+
51+
52+
def upgrade():
53+
recreate_constraints(with_on_delete_cascade=True)
54+
55+
56+
def downgrade():
57+
recreate_constraints(with_on_delete_cascade=False)

alembic/alembic/versions/95fa6a3c7eee_extend_registration_link.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
"""extend registration link
22
33
Revision ID: 95fa6a3c7eee
4-
Revises: c7b4844dee44
4+
5+
Revises: 79b2dda7e3be
56
Create Date: 2025-10-30 08:30:38.564333
67
78
"""
@@ -14,7 +15,7 @@
1415

1516
# revision identifiers, used by Alembic.
1617
revision: str = "95fa6a3c7eee"
17-
down_revision: Union[str, None] = "c7b4844dee44"
18+
down_revision: Union[str, None] = "79b2dda7e3be"
1819
branch_labels: Union[str, Sequence[str], None] = None
1920
depends_on: Union[str, Sequence[str], None] = None
2021

src/database/model/ai_resource/resource_table.py

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,17 @@ class AIResourcePartLink(SQLModel, table=True): # type: ignore [call-arg]
1313
__tablename__ = "ai_resource_part_link"
1414
parent_identifier: str = Field(
1515
max_length=IDENTIFIER_LENGTH,
16-
sa_column_args=[ForeignKey("ai_resource.identifier", onupdate="CASCADE")],
16+
sa_column_args=[
17+
ForeignKey("ai_resource.identifier", onupdate="CASCADE", ondelete="CASCADE")
18+
],
1719
sa_column_kwargs=dict(nullable=True, index=True),
1820
primary_key=True,
1921
)
2022
child_identifier: str = Field(
2123
max_length=IDENTIFIER_LENGTH,
22-
sa_column_args=[ForeignKey("ai_resource.identifier", onupdate="CASCADE")],
24+
sa_column_args=[
25+
ForeignKey("ai_resource.identifier", onupdate="CASCADE", ondelete="CASCADE")
26+
],
2327
sa_column_kwargs=dict(nullable=True, index=True),
2428
primary_key=True,
2529
)
@@ -29,13 +33,17 @@ class AIResourceRelevantLink(SQLModel, table=True): # type: ignore [call-arg]
2933
__tablename__ = "ai_resource_relevant_link"
3034
parent_identifier: str = Field(
3135
max_length=IDENTIFIER_LENGTH,
32-
sa_column_args=[ForeignKey("ai_resource.identifier", onupdate="CASCADE")],
36+
sa_column_args=[
37+
ForeignKey("ai_resource.identifier", onupdate="CASCADE", ondelete="CASCADE")
38+
],
3339
sa_column_kwargs=dict(nullable=True, index=True),
3440
primary_key=True,
3541
)
3642
relevant_identifier: str = Field(
3743
max_length=IDENTIFIER_LENGTH,
38-
sa_column_args=[ForeignKey("ai_resource.identifier", onupdate="CASCADE")],
44+
sa_column_args=[
45+
ForeignKey("ai_resource.identifier", onupdate="CASCADE", ondelete="CASCADE")
46+
],
3947
sa_column_kwargs=dict(nullable=True, index=True),
4048
primary_key=True,
4149
)

src/database/model/helper_functions.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,16 @@
1+
from functools import cache
2+
from http import HTTPStatus
13
from typing import Type, TYPE_CHECKING
24

5+
from fastapi import HTTPException
36
from pydantic import create_model
47
from sqlalchemy import Column, Integer, ForeignKey, String
58
from sqlmodel import SQLModel, Field
69

710
from database.model.field_length import IDENTIFIER_LENGTH
811

912
if TYPE_CHECKING:
13+
from database.model.concept.concept import AIoDConcept
1014
from database.model.relationships import _ResourceRelationship
1115

1216

@@ -84,3 +88,27 @@ def non_abstract_subclasses(cls):
8488
yield grand_child
8589
if not has_grandchild:
8690
yield child
91+
92+
93+
@cache
94+
def get_asset_type_by_abbreviation() -> dict[str, type["AIoDConcept"]]:
95+
from database.model.concept.concept import AIoDConcept
96+
97+
return {
98+
cls.__abbreviation__: cls
99+
for cls in non_abstract_subclasses(AIoDConcept)
100+
if hasattr(cls, "__abbreviation__")
101+
}
102+
103+
104+
def get_asset_by_identifier(identifier, session):
105+
asset_type_map = get_asset_type_by_abbreviation()
106+
prefix = identifier.split("_")[0]
107+
model_class = asset_type_map.get(prefix)
108+
if not model_class:
109+
raise HTTPException(
110+
status_code=HTTPStatus.NOT_FOUND,
111+
detail=f"Unknown asset type with identifier '{identifier}'",
112+
)
113+
resource = session.get(model_class, identifier)
114+
return model_class, resource

src/database/model/relationships.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -194,7 +194,7 @@ def create_triggers(self, parent_class: Type[SQLModel], field_name: str):
194194
@dataclasses.dataclass
195195
class ManyToMany(_ResourceRelationshipList):
196196
"""
197-
Configuration for handling many-to-one relationships to another table.
197+
Configuration for handling many-to-many relationships to another table.
198198
199199
Args:
200200
on_delete_trigger_orphan_deletion(Callable): automatically delete orphans of the

src/database/model/serializers.py

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,11 @@
88
from starlette.status import HTTP_404_NOT_FOUND
99

1010
from authentication import KeycloakUser
11-
from database.model.helper_functions import get_relationships
11+
from database.model.helper_functions import get_relationships, get_asset_by_identifier
1212
from database.model.named_relation import NamedRelation, Taxonomy
13+
from database.model.ai_resource.resource_table import AIResourceORM
14+
from database.session import DbSession
15+
1316

1417
MODEL = TypeVar("MODEL", bound=SQLModel)
1518

@@ -99,7 +102,7 @@ def deserialize(
99102

100103
@staticmethod
101104
def deserialize_ids(clazz: type[SQLModel], session: Session, ids: list[int]):
102-
query = select(clazz).where(clazz.identifier.in_(ids)) # noqa
105+
query = select(clazz).where(clazz.identifier.in_(ids))
103106
existing = session.scalars(query).all()
104107
ids_not_found = set(ids) - {e.identifier for e in existing}
105108
if any(ids_not_found):
@@ -253,16 +256,30 @@ def create_getter_dict(attribute_serializers: Dict[str, Serializer]):
253256
object."""
254257
attribute_names = set(attribute_serializers.keys())
255258

259+
def is_soft_deleted(item) -> bool:
260+
if hasattr(item, "date_deleted") and item.date_deleted:
261+
return True
262+
if isinstance(item, AIResourceORM):
263+
with DbSession() as session:
264+
clazz_, resource = get_asset_by_identifier(item.identifier, session)
265+
return resource.date_deleted is not None
266+
267+
return False # Not sure what cases are not covered here.
268+
256269
class GetterDictSerializer(GetterDict):
257270
def get(self, key: Any, default: Any = None) -> Any:
258-
# if key == "has_part" and hasattr(self._obj, "ai_resource"):
259-
# return [p.identifier for p in self._obj.ai_resource.has_part]
260271
if key in attribute_names:
261272
serializer = attribute_serializers[key]
262273
attribute_value = serializer.value(model=self._obj, attribute_name=key)
263274
if attribute_value is not None:
264275
if isinstance(attribute_value, list):
265-
return [serializer.serialize(v) for v in attribute_value]
276+
return [
277+
serializer.serialize(v)
278+
for v in attribute_value
279+
if not is_soft_deleted(v)
280+
]
281+
if is_soft_deleted(attribute_value):
282+
return None
266283
return serializer.serialize(attribute_value)
267284
return super().get(key, default)
268285

src/database/review.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@
99
from database.model.field_length import NORMAL, LONG
1010
from database.model.concept.concept import AIoDConcept
1111
from database.model.helper_functions import non_abstract_subclasses
12-
from routers.helper_functions import get_all_asset_schemas, get_asset_type_by_abbreviation
12+
from routers.helper_functions import get_all_asset_schemas
13+
from database.model.helper_functions import get_asset_type_by_abbreviation
1314

1415
REQUIRED_NUMBER_OF_REVIEWS = 1
1516

src/routers/asset_router.py

Lines changed: 1 addition & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from authentication import KeycloakUser, get_user_or_none, get_user_or_raise, get_user_by_username
77
from database.authorization import user_can_administer, set_permission, Permission, register_user
88
from database.session import get_session
9-
from routers.helper_functions import get_asset_type_by_abbreviation
9+
from database.model.helper_functions import get_asset_by_identifier
1010
from routers.resource_routers import versioned_routers
1111
from database.model.concept.aiod_entry import EntryStatus
1212
from database.authorization import user_can_read, PermissionType
@@ -118,16 +118,4 @@ def asset(
118118
detail=f"No router found to deserialize asset of type '{model_class.__name__}'",
119119
)
120120

121-
def get_asset_by_identifier(identifier, session):
122-
asset_type_map = get_asset_type_by_abbreviation()
123-
prefix = identifier.split("_")[0]
124-
model_class = asset_type_map.get(prefix)
125-
if not model_class:
126-
raise HTTPException(
127-
status_code=HTTPStatus.NOT_FOUND,
128-
detail=f"Unknown asset type with identifier '{identifier}'",
129-
)
130-
resource = session.get(model_class, identifier)
131-
return model_class, resource
132-
133121
return router

src/routers/bookmark_router.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
from datetime import datetime
1414

1515
from dependencies.pagination import PaginationParams
16-
from routers.helper_functions import get_asset_type_by_abbreviation
16+
from database.model.helper_functions import get_asset_type_by_abbreviation
1717
from versioning import Version
1818

1919

src/routers/helper_functions.py

Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -29,15 +29,6 @@ def get_all_asset_schemas():
2929
]
3030

3131

32-
@cache
33-
def get_asset_type_by_abbreviation() -> dict[str, type[AIoDConcept]]:
34-
return {
35-
cls.__abbreviation__: cls
36-
for cls in non_abstract_subclasses(AIoDConcept)
37-
if hasattr(cls, "__abbreviation__")
38-
}
39-
40-
4132
@cache
4233
def get_router_by_type() -> dict[type[AIoDConcept], type["ResourceRouter"]]:
4334
from routers.resource_routers import versioned_routers # avoid cyclical import

0 commit comments

Comments
 (0)