Skip to content

Commit ee3f846

Browse files
authored
Enh/own assets (#511)
2 parents b4b5948 + 74c2c18 commit ee3f846

File tree

6 files changed

+195
-21
lines changed

6 files changed

+195
-21
lines changed

src/database/review.py

Lines changed: 2 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,10 @@
66
from sqlalchemy import Column, select
77
from sqlmodel import SQLModel, Field, Relationship, Session
88

9-
import routers
109
from database.model.field_length import NORMAL, LONG
1110
from database.model.concept.concept import AIoDConcept
1211
from database.model.helper_functions import non_abstract_subclasses
12+
from routers.helper_functions import get_all_asset_schemas
1313

1414
REQUIRED_NUMBER_OF_REVIEWS = 1
1515

@@ -121,25 +121,9 @@ class Config:
121121
# ResourceRead classes are only defined at runtime (generated dynamically).
122122
@staticmethod
123123
def schema_extra(schema: dict[str, Any], _: type["SubmissionView"]) -> None:
124-
available_schemas: list[AIoDConcept] = list(non_abstract_subclasses(AIoDConcept))
125-
classes_dict = {
126-
clz.__tablename__: clz for clz in available_schemas if clz.__tablename__
127-
}
128-
resrouters = {
129-
route.resource_name: route
130-
for route in routers.resource_routers.router_list # type: ignore
131-
}
132-
read_classes_dict = {
133-
name: resrouters[name].resource_class_read for name in classes_dict
134-
}
135-
136-
responses = [
137-
{"$ref": f"#/components/schemas/{clz.__name__}"}
138-
for clz in read_classes_dict.values()
139-
]
140124
schema["properties"]["asset"] = {
141125
"title": "Asset under review",
142126
"description": "The type of the object can be found in SubmissionView.asset_type.",
143127
"type": "object",
144-
"anyOf": responses,
128+
"anyOf": get_all_asset_schemas(),
145129
}

src/main.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
uploader_routers,
3232
search_routers,
3333
review_router,
34+
user_router,
3435
)
3536
from setup_logger import setup_logger
3637

@@ -101,7 +102,7 @@ def counts() -> dict:
101102
+ enum_routers.router_list
102103
+ search_routers.router_list
103104
+ uploader_routers.router_list
104-
+ [review_router]
105+
+ [review_router, user_router]
105106
):
106107
app.include_router(router.create(url_prefix))
107108

src/routers/helper_functions.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import routers
2+
from database.model.concept.concept import AIoDConcept
3+
from database.model.helper_functions import non_abstract_subclasses
4+
5+
6+
def get_all_asset_schemas():
7+
available_schemas: list[AIoDConcept] = list(non_abstract_subclasses(AIoDConcept))
8+
classes_dict = {clz.__tablename__: clz for clz in available_schemas if clz.__tablename__}
9+
resrouters = {
10+
route.resource_name: route
11+
for route in routers.resource_routers.router_list # type: ignore
12+
}
13+
read_classes_dict = {
14+
name: resrouters[name].resource_class_read
15+
for name in classes_dict
16+
if name not in ["testresource", "test_object"]
17+
}
18+
responses = [
19+
{"$ref": f"#/components/schemas/{clz.__name__}"} for clz in read_classes_dict.values()
20+
]
21+
return responses

src/routers/user_router.py

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
from http import HTTPStatus
2+
3+
from fastapi import APIRouter, Depends
4+
from sqlalchemy import select
5+
from sqlmodel import Session
6+
7+
from authentication import KeycloakUser, get_user_or_raise
8+
from database.authorization import Permission, PermissionType
9+
from database.session import get_session
10+
from database.model.concept.aiod_entry import AIoDEntryORM
11+
from database.model.concept.concept import AIoDConcept
12+
from database.model.helper_functions import non_abstract_subclasses
13+
from routers.helper_functions import get_all_asset_schemas
14+
15+
16+
def create(url_prefix: str) -> APIRouter:
17+
router = APIRouter()
18+
version = "v1"
19+
20+
router.get(
21+
f"{url_prefix}/user/resources/{version}",
22+
tags=["User"],
23+
description="Return all assets for which you have administrator rights",
24+
response_model=None, # Required! Otherwise FastAPI infers it from type annotation.
25+
responses={
26+
HTTPStatus.OK: {
27+
"content": {
28+
"application/json": {
29+
"schema": {
30+
"title": "List of assets owned by the user.",
31+
"type": "array",
32+
"items": {"anyOf": get_all_asset_schemas()},
33+
}
34+
}
35+
},
36+
}
37+
},
38+
)(get_resources_for_logged_in_user)
39+
return router
40+
41+
42+
def get_resources_for_logged_in_user(
43+
user: KeycloakUser = Depends(get_user_or_raise),
44+
session: Session = Depends(get_session),
45+
) -> list[AIoDConcept]:
46+
return _get_resources_for_user(user, session)
47+
48+
49+
def _get_resources_for_user(user: KeycloakUser, session: Session) -> list[AIoDConcept]:
50+
# "Ownership" is currently equivalent to having ADMIN permissions
51+
stmt = (
52+
select(AIoDEntryORM)
53+
.join(Permission.aiod_entry)
54+
.where(
55+
Permission.user_identifier == user._subject_identifier,
56+
Permission.type_ == PermissionType.ADMIN,
57+
)
58+
)
59+
entries = session.scalars(stmt).all()
60+
assets_to_fetch = [entry.identifier for entry in entries]
61+
# We have AIoD entries, but want their respective asset information (e.g. publication).
62+
# We lack the information about what the type of the asset is, so unfortunately we
63+
# have to check all tables:
64+
found_assets = []
65+
for asset_type in non_abstract_subclasses(AIoDConcept):
66+
query = select(asset_type).where(asset_type.aiod_entry_identifier.in_(assets_to_fetch))
67+
assets = session.scalars(query).all()
68+
found_assets.extend(assets)
69+
if len(found_assets) == len(assets_to_fetch):
70+
return found_assets # minor optimization since queries may be expensive
71+
72+
raise RuntimeError(
73+
f"Expected to find assets for identifiers {assets_to_fetch}, "
74+
f"but only found {len(found_assets)} in total: {found_assets}."
75+
)

src/tests/test_user_endpoints.py

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
from http import HTTPStatus
2+
from typing import Callable
3+
4+
from starlette.testclient import TestClient
5+
6+
from database.authorization import set_permission, PermissionType, register_user
7+
from database.model.knowledge_asset.publication import Publication
8+
from database.model.concept.aiod_entry import EntryStatus
9+
from database.session import DbSession
10+
from database.model.agent.organisation import Organisation
11+
from tests.testutils.users import register_asset, logged_in_user, ALICE, BOB
12+
from tests.testutils.default_instances import publication_factory, publication
13+
14+
15+
def test_my_resources_can_be_empty(client: TestClient) -> None:
16+
with logged_in_user(ALICE):
17+
response = client.get("/user/resources/v1", headers={"Authorization": "fake token"})
18+
assert response.status_code == HTTPStatus.OK
19+
assert response.json() == [], "A user with no resources should get an empty list"
20+
21+
22+
def test_my_resources_shows_draft_assets(client: TestClient, publication: Publication) -> None:
23+
register_asset(publication, owner=ALICE, status=EntryStatus.DRAFT)
24+
with logged_in_user(ALICE):
25+
response = client.get("/user/resources/v1", headers={"Authorization": "fake token"})
26+
assert response.status_code == HTTPStatus.OK
27+
assert len(response.json()) == 1, "Draft assets should be included in this view."
28+
29+
30+
def test_my_resources_shows_published_assets(client: TestClient, publication: Publication) -> None:
31+
register_asset(publication, owner=ALICE, status=EntryStatus.PUBLISHED)
32+
with logged_in_user(ALICE):
33+
response = client.get("/user/resources/v1", headers={"Authorization": "fake token"})
34+
assert response.status_code == HTTPStatus.OK
35+
assert len(response.json()) == 1, "Published assets should be included in this view."
36+
37+
38+
def test_my_resources_shows_mixed_assets(client: TestClient, publication: Publication, organisation: Organisation) -> None:
39+
register_asset(publication, owner=ALICE, status=EntryStatus.DRAFT)
40+
register_asset(organisation, owner=ALICE, status=EntryStatus.PUBLISHED)
41+
with logged_in_user(ALICE):
42+
response = client.get("/user/resources/v1", headers={"Authorization": "fake token"})
43+
assert response.status_code == HTTPStatus.OK
44+
45+
pub = next(asset for asset in response.json() if asset["aiod_entry_identifier"] == 1)
46+
org = next(asset for asset in response.json() if asset["aiod_entry_identifier"] == 2)
47+
dataset_property = "legal_name"
48+
publication_property = "isbn"
49+
50+
assert dataset_property in org and publication_property in pub, "Assets should report properties unique to their type"
51+
assert dataset_property not in pub and publication_property not in org, "Assets should not report properties they do not have"
52+
53+
def test_my_resources_shows_only_own_resources(client: TestClient, publication_factory: Callable[[], Publication]) -> None:
54+
register_asset(publication_factory(), owner=ALICE, status=EntryStatus.DRAFT)
55+
register_asset(publication_factory(), owner=ALICE, status=EntryStatus.PUBLISHED)
56+
register_asset(publication_factory(), owner=BOB, status=EntryStatus.PUBLISHED)
57+
58+
with logged_in_user(ALICE):
59+
response = client.get("/user/resources/v1", headers={"Authorization": "fake token"})
60+
assert len(response.json()) == 2
61+
62+
with logged_in_user(BOB):
63+
response = client.get("/user/resources/v1", headers={"Authorization": "fake token"})
64+
assert len(response.json()) == 1
65+
66+
67+
def test_my_resources_counts_only_if_admin(client: TestClient, publication_factory: Callable[[], Publication]) -> None:
68+
asset_one = publication_factory()
69+
identifier_one = register_asset(asset_one, owner=ALICE, status=EntryStatus.PUBLISHED)
70+
asset_two = publication_factory()
71+
identifier_two = register_asset(asset_two, owner=ALICE, status=EntryStatus.PUBLISHED)
72+
asset_three = publication_factory()
73+
identifier_three = register_asset(asset_three, owner=ALICE, status=EntryStatus.PUBLISHED)
74+
75+
with DbSession() as session:
76+
register_user(BOB, session)
77+
set_permission(BOB, session.get(Publication, identifier_one).aiod_entry, session, type_=PermissionType.READ)
78+
set_permission(BOB, session.get(Publication, identifier_two).aiod_entry, session, type_=PermissionType.WRITE)
79+
set_permission(BOB, session.get(Publication, identifier_three).aiod_entry, session, type_=PermissionType.ADMIN)
80+
session.commit()
81+
82+
with logged_in_user(BOB):
83+
response = client.get("/user/resources/v1", headers={"Authorization": "fake token"})
84+
assert len(response.json()) == 1, "Bob has ADMIN permission to one asset."
85+
assert response.json()[0]["identifier"] == identifier_three
86+
87+
88+
def test_my_resources_must_be_authorized(client: TestClient) -> None:
89+
response = client.get("/user/resources/v1")
90+
assert response.status_code == HTTPStatus.UNAUTHORIZED

src/tests/testutils/default_instances.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
import copy
88
import json
9+
import uuid
910
from functools import partial
1011
from typing import Callable
1112

@@ -75,12 +76,14 @@ def body_agent(body_resource: dict, load_body_agent: dict) -> dict:
7576
return copy.deepcopy(body)
7677

7778

78-
def make_publication(body_asset: dict) -> Publication:
79+
def make_publication(body_asset: dict, with_random_platform_identifier: bool = False) -> Publication:
7980
body = copy.deepcopy(body_asset)
8081
body["permanent_identifier"] = "http://dx.doi.org/10.1093/ajae/aaq063"
8182
body["isbn"] = "9783161484100"
8283
body["issn"] = "20493630"
8384
body["type"] = "journal"
85+
if with_random_platform_identifier:
86+
body["platform_resource_identifier"] = uuid.uuid4().hex
8487
return _create_class_with_body(Publication, body)
8588

8689

@@ -91,7 +94,7 @@ def publication(body_asset: dict) -> Publication:
9194

9295
@pytest.fixture
9396
def publication_factory(body_asset: dict) -> Callable[[], Publication]:
94-
return partial(make_publication, body_asset)
97+
return partial(make_publication, body_asset, with_random_platform_identifier=True)
9598

9699

97100
@pytest.fixture

0 commit comments

Comments
 (0)