Skip to content

Commit 925423d

Browse files
authored
Feat/reviewqueue (#451)
2 parents f2c69ce + 2939424 commit 925423d

File tree

9 files changed

+274
-11
lines changed

9 files changed

+274
-11
lines changed

docs/developer/schema/index.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@ following figure:
1919

2020
![AIoD Metadata model](../../media/AIoD_Metadata_Model.drawio.png)
2121

22+
In addition to the implementation of the metadata schema, we maintain some metadata about that metadata.
23+
Specifically, a system which stores users, permissions, and reviews that relate to the metadata assets uploaded to AIoD.
24+
More on this can be found in the ["User Model"](../users.md) documentation.
2225

2326
## Reading the Conceptual Metadata Schema
2427
Tools and documentation on how to read the conceptual metadata model are currently being written.

docs/developer/users.md

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
# User Model
2+
3+
The user model in AIoD allows users to maintain and share ownership over assets,
4+
and to allow a review process for new assets.
5+
The components of this user model are defined in [src/database/authorization.py](https://github.com/aiondemand/AIOD-rest-api/blob/develop/src/database/authorization.py)
6+
and [src/database/review.py](https://github.com/aiondemand/AIOD-rest-api/blob/develop/src/database/review.py).
7+
8+
[//]: # (Add a diagram overview once the model is more final, e.g., groups are added)
9+
10+
## Users
11+
User credentials are kept in keycloak or by identity providers, in any case the user is uniquely identified
12+
by the unique "sub" key that keycloak provides in its token.
13+
In our user model we keep as little information about the user as we can.
14+
This avoids a scenario were data is kept twice and may desynchronize, and also respects the users' privacy.
15+
Currently, that is only the "sub", but in the future we may consider adding e.g., usernames.
16+
17+
## Permissions
18+
A user may have certain permissions for an asset, these are:
19+
20+
- read: a user may retrieve this metadata asset.
21+
- write: a user may modify this metadata asset.
22+
- admin: a user may delete this metadata asset, submit it for review, and grant other users permissions for the asset.
23+
24+
The only notion of "ownership" over a particular metadata asset is having administrator rights.
25+
When user A uploads an asset and assigns user B administrator rights, they have completely equal rights.
26+
There is no special privilege for user A, and this also means that e.g., user B may remove administrator rights from user A.
27+
28+
## Reviews
29+
An asset uploaded by a user is by default in `draft` state.
30+
The user may request the asset to be `published` by submitting it for review through the REST API.
31+
To do this, they submit the identifier of the asset to the `ASSET_TYPE/submit/v1/{identifier}` endpoint.
32+
When it is submitted for review, reviewers will be able to see the request and make a decision.
33+
As long as no decision has been made, the user may retract their submission from review using the `ASSET_TYPE/retract/v1/{identifier}`
34+
endpoint.
35+
Each asset may only have one concurrent review request, and the asset may not be modified while it is under review.
36+
37+
Reviewers can find pending submissions using the `submissions/v1` endpoint,
38+
and get information on a particular submission with the `submissions/v1/{identifier}` endpoint,
39+
where the identifier denotes the identifier of the submission (i.e., review request).
40+
Reviewers can submit a review using the `ASSET_TYPE/review/v1/{identifier}` endpoint.
41+
42+
If the submission is accepted, the asset will be published without further action from the original uploader.
43+
If the submission is rejected, the asset will move back to `draft` state and may be submitted again.

mkdocs.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ nav:
1717
- 'Developer Resources':
1818
- developer/index.md
1919
- 'Authentication': developer/authentication.md
20+
- 'User Model': developer/users.md
2021
- 'Metadata Schema':
2122
- developer/schema/index.md
2223
- 'Attributes': developer/schema/attributes.md

src/database/authorization.py

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -65,11 +65,14 @@ def register_user(kc_user: KeycloakUser, session: Session) -> User:
6565

6666

6767
def add_administrator(user: KeycloakUser, resource: AIoDConcept, session: Session):
68-
permission = Permission(
69-
type_=PermissionType.ADMIN,
70-
user_identifier=user._subject_identifier,
71-
aiod_entry=resource.aiod_entry,
72-
)
68+
query = select(Permission).where(User.subject_identifier == user._subject_identifier)
69+
permission = session.scalars(query).first()
70+
if permission is None:
71+
permission = Permission(
72+
user_identifier=user._subject_identifier,
73+
aiod_entry=resource.aiod_entry,
74+
)
75+
permission.type_ = PermissionType.ADMIN
7376
session.add(permission)
7477

7578

src/database/review.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ class Review(SQLModel, table=True): # type: ignore [call-arg]
3030

3131
reviewer_identifier: str = Field(
3232
foreign_key="user.subject_identifier",
33+
exclude=True,
3334
)
3435
submission_identifier: int = Field(
3536
foreign_key="submission.identifier",
@@ -56,7 +57,9 @@ class Submission(SQLModel, table=True): # type: ignore [call-arg]
5657
# We do not want the review to be deleted when the original requestee is deleted,
5758
# there could be e.g., shared ownership in which case the review data should be preserved.
5859
requestee_identifier: str | None = Field(
59-
foreign_key="user.subject_identifier", ondelete="SET NULL"
60+
foreign_key="user.subject_identifier",
61+
ondelete="SET NULL",
62+
exclude=True,
6063
)
6164

6265
# On the other hand, if the entry corresponding to the thing it reviews is removed,

src/main.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,14 @@
2424
from database.session import EngineSingleton, DbSession
2525
from database.setup import create_database, database_exists
2626
from error_handling import http_exception_handler
27-
from routers import resource_routers, parent_routers, enum_routers, uploader_routers
28-
from routers import search_routers
27+
from routers import (
28+
resource_routers,
29+
parent_routers,
30+
enum_routers,
31+
uploader_routers,
32+
search_routers,
33+
review_router,
34+
)
2935
from setup_logger import setup_logger
3036

3137

@@ -95,6 +101,7 @@ def counts() -> dict:
95101
+ enum_routers.router_list
96102
+ search_routers.router_list
97103
+ uploader_routers.router_list
104+
+ [review_router]
98105
):
99106
app.include_router(router.create(url_prefix))
100107

src/routers/review_router.py

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import enum
2+
from http import HTTPStatus
3+
from typing import Sequence, Literal
4+
5+
from fastapi import APIRouter, HTTPException
6+
from sqlmodel import select
7+
8+
from database.session import DbSession
9+
from database.review import Submission, Review
10+
11+
12+
def create(url_prefix: str) -> APIRouter:
13+
router = APIRouter()
14+
version = "v1"
15+
16+
router.get(
17+
f"{url_prefix}/submissions/{version}/",
18+
tags=["Reviewing"],
19+
description="List all assets submitted for review.",
20+
)(list_submissions)
21+
22+
router.get(
23+
f"{url_prefix}/submissions/{version}/{{identifier}}",
24+
tags=["Reviewing"],
25+
description="Retrieve a specific submission.",
26+
)(get_submission)
27+
28+
# Add MiddleWare which requires authentication as reviewer role
29+
return router
30+
31+
32+
class ListMode(enum.StrEnum):
33+
OLDEST = enum.auto()
34+
NEWEST = enum.auto()
35+
ALL = enum.auto()
36+
PENDING = enum.auto()
37+
COMPLETED = enum.auto()
38+
39+
40+
def _get_single_submission(
41+
*, which: Literal[ListMode.NEWEST, ListMode.OLDEST]
42+
) -> Submission | None:
43+
with DbSession() as session:
44+
has_review = select(1).where(Submission.identifier == Review.submission_identifier).exists()
45+
order = Submission.request_date
46+
if which == ListMode.NEWEST:
47+
order = Submission.request_date.desc() # type: ignore[attr-defined]
48+
query = select(Submission).order_by(order).where(~has_review)
49+
return session.scalars(query).first()
50+
51+
52+
def _get_submissions_by_state(
53+
*, which: Literal[ListMode.COMPLETED, ListMode.PENDING]
54+
) -> Sequence[Submission]:
55+
with DbSession() as session:
56+
has_review = select(1).where(Submission.identifier == Review.submission_identifier).exists()
57+
if which == ListMode.PENDING:
58+
submissions = select(Submission).where(~has_review)
59+
if which == ListMode.COMPLETED:
60+
submissions = select(Submission).where(has_review)
61+
return session.scalars(submissions).all()
62+
63+
64+
def list_submissions(mode: ListMode = ListMode.NEWEST) -> Sequence[Submission]:
65+
# mypy does not do type narrowing properly: https://github.com/python/mypy/issues/12535
66+
if mode in [ListMode.NEWEST, ListMode.OLDEST]:
67+
submission = _get_single_submission(which=mode) # type: ignore[arg-type]
68+
return [submission] if submission else []
69+
if mode in [ListMode.PENDING, ListMode.COMPLETED]:
70+
return _get_submissions_by_state(which=mode) # type: ignore[arg-type]
71+
raise ValueError(f"`mode` should be one of {ListMode!r} but is {mode!r}.")
72+
73+
74+
def get_submission(identifier: int) -> Submission:
75+
with DbSession() as session:
76+
query = select(Submission).where(Submission.identifier == identifier)
77+
submission = session.scalars(query).first()
78+
if submission:
79+
return submission
80+
raise HTTPException(
81+
status_code=HTTPStatus.NOT_FOUND,
82+
detail=f"No submission with identifier {identifier} found.",
83+
)

src/tests/authorization/test_authorization.py

Lines changed: 110 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
from database.model.concept.concept import AIoDConcept
1414
from database.review import Review, Decision, ReviewCreate, Submission
1515
from database.session import DbSession
16-
16+
from routers.review_router import ListMode
1717

1818
ALICE = KeycloakUser("Alice", {"edit_aiod_resources"}, "alice-sub")
1919
BOB = KeycloakUser("Bob", {"edit_aiod_resources"}, "bob-sub")
@@ -98,6 +98,12 @@ def test_user_can_submit_draft_for_review(client, publication):
9898
assert submission.status_code == HTTPStatus.OK, submission.json()
9999
assert "submission_identifier" in submission.json()
100100

101+
with logged_in_user(REVIEWER):
102+
queue = client.get("/submissions/v1", headers={"Authorization": "Fake token"})
103+
assert queue.status_code == HTTPStatus.OK, queue.json()
104+
assert len(queue.json()) == 1, "A successful request should result in a submission."
105+
assert "requestee_identifier" not in queue.json()
106+
101107

102108
def test_user_can_not_submit_other_for_review(client, publication):
103109
identifier = register_asset(publication, owner=ALICE, status=EntryStatus.DRAFT)
@@ -109,6 +115,102 @@ def test_user_can_not_submit_other_for_review(client, publication):
109115
)
110116
assert submission.status_code == HTTPStatus.FORBIDDEN, submission.json()
111117

118+
with logged_in_user(REVIEWER):
119+
queue = client.get("/submissions/v1", headers={"Authorization": "Fake token"})
120+
assert queue.status_code == HTTPStatus.OK, queue.json()
121+
assert len(queue.json()) == 0, "A rejected request should not result in a submission."
122+
123+
124+
def test_a_draft_is_not_pending_for_review(client, publication):
125+
register_asset(publication, owner=ALICE, status=EntryStatus.DRAFT)
126+
127+
with logged_in_user(REVIEWER):
128+
queue = client.get("/submissions/v1", headers={"Authorization": "Fake token"})
129+
assert queue.status_code == HTTPStatus.OK, queue.json()
130+
assert len(queue.json()) == 0, "An asset is only pending for review after submission."
131+
132+
133+
def test_a_submitted_asset_is_pending_for_review(client, publication):
134+
register_asset(publication, owner=ALICE, status=EntryStatus.SUBMITTED)
135+
136+
with logged_in_user(REVIEWER):
137+
queue = client.get("/submissions/v1", headers={"Authorization": "Fake token"})
138+
assert queue.status_code == HTTPStatus.OK, queue.json()
139+
assert len(queue.json()) == 1, "A submitted asset should be pending until a review is done."
140+
141+
142+
def test_get_submission_by_id(client, publication):
143+
register_asset(publication, owner=ALICE, status=EntryStatus.PUBLISHED)
144+
145+
with logged_in_user(REVIEWER):
146+
queue = client.get("/submissions/v1/1", headers={"Authorization": "Fake token"})
147+
assert queue.status_code == HTTPStatus.OK, queue.json()
148+
assert queue.json()["identifier"] == 1
149+
150+
151+
def test_unknown_submission_raises_404(client):
152+
with logged_in_user(REVIEWER):
153+
queue = client.get("/submissions/v1/1", headers={"Authorization": "Fake token"})
154+
assert queue.status_code == HTTPStatus.NOT_FOUND
155+
156+
157+
@pytest.mark.skip("Requiring reviewer role to be added through middleware")
158+
def test_getting_submission_requires_review_role(client):
159+
queue = client.get("/submissions/v1/1")
160+
assert queue.status_code == HTTPStatus.UNAUTHORIZED
161+
162+
with logged_in_user(ALICE):
163+
queue = client.get("/submissions/v1/1", headers={"Authorization": "Fake token"})
164+
assert queue.status_code == HTTPStatus.FORBIDDEN
165+
166+
167+
def test_an_published_asset_is_not_pending_for_review(client, publication):
168+
register_asset(publication, owner=ALICE, status=EntryStatus.PUBLISHED)
169+
170+
with logged_in_user(REVIEWER):
171+
queue = client.get(
172+
f"/submissions/v1?mode={ListMode.PENDING}", headers={"Authorization": "Fake token"}
173+
)
174+
assert queue.status_code == HTTPStatus.OK, queue.json()
175+
assert (
176+
len(queue.json()) == 0
177+
), "After publication, the submission is no longer pending a review."
178+
179+
queue = client.get(
180+
f"/submissions/v1?mode={ListMode.COMPLETED}", headers={"Authorization": "Fake token"}
181+
)
182+
assert queue.status_code == HTTPStatus.OK, queue.json()
183+
assert len(queue.json()) == 1, "After publication, the review request is completed."
184+
185+
186+
def test_retrieving_single_submission_works(client, publication_factory):
187+
publication = publication_factory()
188+
oldest = publication_factory()
189+
oldest.platform_resource_identifier = "OLDEST"
190+
newest = publication_factory()
191+
newest.platform_resource_identifier = "NEWEST"
192+
193+
register_asset(publication, owner=ALICE, status=EntryStatus.PUBLISHED)
194+
register_asset(oldest, owner=ALICE, status=EntryStatus.SUBMITTED)
195+
register_asset(newest, owner=ALICE, status=EntryStatus.SUBMITTED)
196+
197+
with logged_in_user(REVIEWER):
198+
queue = client.get(
199+
f"/submissions/v1?mode={ListMode.OLDEST}", headers={"Authorization": "Fake token"}
200+
)
201+
assert queue.status_code == HTTPStatus.OK, queue.json()
202+
assert (
203+
queue.json()[0]["aiod_entry_identifier"] == 2
204+
), "The oldest pending submission is the second one."
205+
206+
queue = client.get(
207+
f"/submissions/v1?mode={ListMode.NEWEST}", headers={"Authorization": "Fake token"}
208+
)
209+
assert queue.status_code == HTTPStatus.OK, queue.json()
210+
assert (
211+
queue.json()[0]["aiod_entry_identifier"] == 3
212+
), "The newest pending submission is the third one."
213+
112214

113215
def test_user_can_retract_assets(client, publication):
114216
identifier = register_asset(publication, owner=ALICE, status=EntryStatus.SUBMITTED)
@@ -119,6 +221,13 @@ def test_user_can_retract_assets(client, publication):
119221
assert "review_identifier" in response.json()
120222
assert Decision.RETRACTED == response.json()["decision"]
121223

224+
with logged_in_user(REVIEWER):
225+
queue = client.get(
226+
f"/submissions/v1?mode={ListMode.PENDING}", headers={"Authorization": "Fake token"}
227+
)
228+
assert queue.status_code == HTTPStatus.OK, queue.json()
229+
assert len(queue.json()) == 0, "A retracted request should not remain pending."
230+
122231

123232
def test_other_user_can_not_retract_assets(client, publication):
124233
identifier = register_asset(publication, owner=ALICE, status=EntryStatus.SUBMITTED)

src/tests/testutils/default_instances.py

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

77
import copy
88
import json
9+
from functools import partial
10+
from typing import Callable
911

1012
import pytest
1113
from sqlalchemy.engine import Engine
@@ -73,8 +75,7 @@ def body_agent(body_resource: dict, load_body_agent: dict) -> dict:
7375
return copy.deepcopy(body)
7476

7577

76-
@pytest.fixture
77-
def publication(body_asset: dict) -> Publication:
78+
def make_publication(body_asset: dict) -> Publication:
7879
body = copy.deepcopy(body_asset)
7980
body["permanent_identifier"] = "http://dx.doi.org/10.1093/ajae/aaq063"
8081
body["isbn"] = "9783161484100"
@@ -83,6 +84,16 @@ def publication(body_asset: dict) -> Publication:
8384
return _create_class_with_body(Publication, body)
8485

8586

87+
@pytest.fixture
88+
def publication(body_asset: dict) -> Publication:
89+
return make_publication(body_asset)
90+
91+
92+
@pytest.fixture
93+
def publication_factory(body_asset: dict) -> Callable[[], Publication]:
94+
return partial(make_publication, body_asset)
95+
96+
8697
@pytest.fixture
8798
def contact(body_concept, engine: Engine) -> Contact:
8899
body = copy.deepcopy(body_concept)

0 commit comments

Comments
 (0)