Skip to content

Commit b4e67f5

Browse files
authored
Introduce one endpoint for all review requests: /submissions (#613)
* Move to a generic submission endpoint instead of per-assettype * Update the reviewing documentation * Partial progress to reviewing multiple assets * Allow bundling assets in one review request * Update review and retract to allow multiple assets * pre-commit fixes * Update tests to reflect new usage * Update documentation * Add tests for multi-owned assets * Added migration script
1 parent ad08968 commit b4e67f5

File tree

8 files changed

+405
-85
lines changed

8 files changed

+405
-85
lines changed
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
"""multi_asset_reviews
2+
3+
Revision ID: d02aac64a5c7
4+
Revises: 1fd9b6a162c4
5+
Create Date: 2025-09-16 12:27:10.238574
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 = "d02aac64a5c7"
17+
down_revision: Union[str, None] = "1fd9b6a162c4"
18+
branch_labels: Union[str, Sequence[str], None] = None
19+
depends_on: Union[str, Sequence[str], None] = None
20+
21+
22+
def upgrade() -> None:
23+
# We use a nuclear approach as there are no reviews in the database anyway:
24+
op.drop_table("review")
25+
op.drop_table("submission")
26+
27+
# We could leave it at the above an rely on SQLAlchemy to create the tables below.
28+
# However, this increases the complexity of the deployment so make it explicit:
29+
op.create_table(
30+
"submission",
31+
sa.Column("identifier", sa.Integer(), nullable=False, autoincrement=True),
32+
sa.Column("request_date", sa.DateTime(), nullable=False),
33+
sa.Column("comment", sa.String(256), nullable=False),
34+
sa.Column("requestee_identifier", sa.String(255), nullable=True),
35+
sa.PrimaryKeyConstraint("identifier"),
36+
sa.ForeignKeyConstraint(
37+
["requestee_identifier"],
38+
["user.subject_identifier"],
39+
name="submission_ibfk_1",
40+
ondelete="SET NULL",
41+
),
42+
)
43+
op.create_index("requestee_identifier", "submission", ["requestee_identifier"])
44+
45+
op.create_table(
46+
"review",
47+
sa.Column("comment", sa.String(1800), nullable=False),
48+
sa.Column(
49+
"decision",
50+
sa.Enum("ACCEPTED", "REJECTED", "RETRACTED", name="review_decision"),
51+
nullable=True,
52+
),
53+
sa.Column("identifier", sa.Integer(), nullable=False, autoincrement=True),
54+
sa.Column("decision_date", sa.DateTime(), nullable=False),
55+
sa.Column("reviewer_identifier", sa.String(255), nullable=False),
56+
sa.Column("submission_identifier", sa.Integer(), nullable=False),
57+
sa.PrimaryKeyConstraint("identifier"),
58+
sa.ForeignKeyConstraint(
59+
["reviewer_identifier"], ["user.subject_identifier"], name="review_ibfk_1"
60+
),
61+
sa.ForeignKeyConstraint(
62+
["submission_identifier"], ["submission.identifier"], name="review_ibfk_2"
63+
),
64+
)
65+
op.create_index("reviewer_identifier", "review", ["reviewer_identifier"])
66+
op.create_index("submission_identifier", "review", ["submission_identifier"])
67+
68+
op.create_table(
69+
"asset_review",
70+
sa.Column("asset_identifier", sa.String(255), nullable=False),
71+
sa.Column("aiod_entry_identifier", sa.Integer(), nullable=False),
72+
sa.Column("review_identifier", sa.Integer(), nullable=False),
73+
sa.PrimaryKeyConstraint("aiod_entry_identifier", "review_identifier"),
74+
sa.ForeignKeyConstraint(
75+
["aiod_entry_identifier"],
76+
["aiod_entry.identifier"],
77+
name="asset_review_ibfk_1",
78+
ondelete="CASCADE",
79+
),
80+
sa.ForeignKeyConstraint(
81+
["review_identifier"],
82+
["submission.identifier"],
83+
name="asset_review_ibfk_2",
84+
ondelete="CASCADE",
85+
),
86+
)
87+
op.create_index("review_identifier", "asset_review", ["review_identifier"])
88+
89+
90+
def downgrade() -> None:
91+
op.drop_table("review")
92+
op.drop_table("asset_review")
93+
op.drop_table("submission")
94+
95+
# just start the server and the tables will be created automatically.

docs/using/upload.md

Lines changed: 26 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -41,36 +41,37 @@ of the endpoints, including their exact addresses and schemas used.
4141
The sections below describe the process in more detail.
4242

4343
### Registering the Asset
44-
You upload your assets to `/ASSET/v1` using a `POST` request that contains
44+
You upload your assets to `/ASSET` using a `POST` request that contains
4545
in the JSON body all the required data for the asset type. A successful response
4646
should look something like this:
4747

4848
```json
4949
{
50-
"identifier": 3
50+
"identifier": "case_n8DhfFgMYv4beBnVurHa13ZS"
5151
}
5252
```
5353

5454
Take a note of the identifier assigned to the asset, as you will need it to request a review in the next step.
5555
The asset is now in draft mode, which means that other users cannot see it but you can, and you may still edit it.
56-
To edit the asset, use the `PUT` endpoints for `/ASSET/v1`.
56+
To edit the asset, use the `PUT` endpoints for `/ASSET`.
5757

5858
### Requesting a Review
5959
When you want to publish the asset, you can submit it for review. You
60-
can do this by making a `POST` request to the `/ASSET/submit/v1/IDENTIFIER` endpoint.
61-
Here you need to replace `IDENTIFIER` with the identifier of the asset you retrieved
62-
in the last step.
63-
You may include a comment to the reviewer of up to 256 characters in the body of the
60+
can do this by making a `POST` request to the `/submissions` endpoint.
61+
You are required to include the identifier in the request body,
62+
and may include a comment to the reviewer of up to 256 characters in the body of the
6463
`POST` request, this should generally not be necessary but may be useful to provide
65-
some clarification:
64+
some clarification. You may supply more than one asset identifier at once,
65+
these assets will then be accepted and rejected together.
6666

6767
```json
6868
{
69+
"asset_identifiers": ["case_n8DhfFgMYv4beBnVurHa13ZS"],
6970
"comment": "Clarification the reviewer should be aware of."
7071
}
7172
```
7273

73-
When you request a submission, you also get a response with the submission's identifier.
74+
When you request a submission, you get a response with the submission's identifier.
7475

7576
```json
7677
{
@@ -92,44 +93,43 @@ in their review.
9293

9394
You can check that status of your pending submission in two ways.
9495
First, you can request the status for that review specifically using the
95-
`/submissions/v1/IDENTIFIER` endpoint, where `IDENTIFIER` needs to replaced with the
96+
`/submissions/IDENTIFIER` endpoint, where `IDENTIFIER` needs to replaced with the
9697
identifier of the _submission_ (not the asset!).
9798
For example, if the submission identifier we received was '1', we can query
98-
`submissions/v1/1` and retrieve its status which would look something like:
99+
`submissions/1` and retrieve its status which would look something like:
99100
```json
100101
{
101102
"comment": "Clarification the reviewer should be aware of.",
102103
"identifier": 1,
103104
"request_date": "2025-03-20T09:09:54",
104105
"aiod_entry_identifier": 212,
105-
"asset_type": "case_study",
106106
"reviews": [],
107-
"asset": {
107+
"assets": [{
108108
...
109-
}
109+
}]
110110
}
111111
```
112112
No reviews have yet been performed on the submission, as indicated by the empty list
113113
of reviews (`"reviews": []`).
114114

115115
Alternatively, you can request an overview of all your submissions with a `GET` request
116-
to the `submissions/v1` endpoint, which will result in a response such as:
116+
to the `submissions` endpoint, which will result in a response such as:
117117
```json
118118
[
119119
{
120120
"comment": "Clarification the reviewer should be aware of.",
121121
"identifier": 1,
122122
"request_date": "2025-03-20T09:09:54",
123123
"aiod_entry_identifier": 212,
124-
"asset_type": "case_study"
124+
"asset_identifiers": ["case_n8DhfFgMYv4beBnVurHa13ZS"],
125125
}
126126
]
127127
```
128128
This endpoint fetches and returns minimal information of the submission, which makes it
129129
more lightweight for creating an overview. However, it does not return the status of the
130130
submissions directly. Instead, you may use query parameters to request only a subset of
131131
the submissions, such as those that still require a review (i.e., pending submissions)
132-
by querying `submissions/v1/?mode=pending`.
132+
by querying `submissions?mode=pending`.
133133
Please refer to the endpoint documentation for other options.
134134

135135
Users are only ever able to view information about their own submissions.
@@ -141,22 +141,21 @@ is likely revealing).
141141
#### Retracting a Submission
142142
Sometimes you may want to retract a submitted asset before a reviewer manages to review it.
143143
For example, to correct a mistake in the original submission. You can retract an asset from
144-
review at any time by doing a `POST` request to the `/submissions/retract/v1/SUBMISSION_IDENTIFIER`
144+
review at any time by doing a `POST` request to the `/submissions/retract/SUBMISSION_IDENTIFIER`
145145
endpoint.
146146

147147
A retracted submission is treated the same as a rejected submission. The asset is put back
148-
into draft status and will not be reviewed until the you submit a new review request.
148+
into draft status and will not be reviewed until you submit a new review request.
149149

150150
### A Rejected Submission
151-
You may find that your submission gets rejected. In that case, the `submissions/v1/IDENTIFIER`
151+
You may find that your submission gets rejected. In that case, the `submissions/IDENTIFIER`
152152
endpoint will provide you with the reviewer feedback, e.g.:
153153
```json
154154
{
155155
"comment": "Clarification the reviewer should be aware of.",
156156
"identifier": 1,
157157
"request_date": "2025-03-20T09:09:54",
158158
"aiod_entry_identifier": 212,
159-
"asset_type": "case_study",
160159
"reviews": [
161160
{
162161
"comment": "Several critical fields have incomplete information. Please improve the description, and add a house number to the address.",
@@ -166,11 +165,11 @@ endpoint will provide you with the reviewer feedback, e.g.:
166165
"submission_identifier": 1
167166
}
168167
],
169-
"asset": { ... }
168+
"assets": [{ ... }]
170169
}
171170
```
172171
You'll find reviewer comments under "reviews".
173-
You may subsequently edit your asset using `PUT` requests to `/ASSET/v1` to address
172+
You may subsequently edit your asset using `PUT` requests to `/ASSET` to address
174173
the reviewer feedback, and then request a new review following the regular submission
175174
process.
176175

@@ -182,17 +181,17 @@ The only restriction is that a reviewer cannot review their own submission.
182181

183182
For reviewers, the main endpoints of interest are:
184183

185-
* `/submissions/v1` to fetch identifiers of submissions which require a review.
184+
* `/submissions` to fetch identifiers of submissions which require a review.
186185
The `?mode=oldest` parameter can be used to fetch the submission which has been waiting for a review the longest.
187-
* `/submissions/v1/{identifier}` to get more detailed information on the submission,
186+
* `/submissions/{identifier}` to get more detailed information on the submission,
188187
in particular this will provide also the asset body to review.
189188
* `/reviews/v1` the endpoint through which reviews can be made using `POST` requests.
190189

191190
Given a submission identifier (obtained from either `/submissions` endpoint mentioned above),
192-
the review can post a review to `/reviews/v1`. This requires the user to make a decision
191+
the review can post a review to `/reviews`. This requires the user to make a decision
193192
to accept or reject the submission, and optionally leave a comment. When rejecting an
194193
asset, a comment is strongly encouraged, otherwise the user will not know how to improve
195-
their submission. An example body of the `POST` request to `/reviews/v1` could look like:
194+
their submission. An example body of the `POST` request to `/reviews` could look like:
196195
```json
197196
{
198197
"comment": "Several critical fields have incomplete information. Please improve the description, and add a house number to the address.",

src/database/review.py

Lines changed: 53 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,13 @@
33
from typing import Any
44

55
import sqlalchemy
6-
from sqlalchemy import Column, select
6+
from sqlalchemy import Column
77
from sqlmodel import SQLModel, Field, Relationship, Session
88

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
12+
from routers.helper_functions import get_all_asset_schemas, get_asset_type_by_abbreviation
1313

1414
REQUIRED_NUMBER_OF_REVIEWS = 1
1515

@@ -53,9 +53,25 @@ class Review(ReviewBase, table=True): # type: ignore [call-arg]
5353
submission: "Submission" = Relationship(back_populates="reviews")
5454

5555

56+
class SubmissionCreateV2(SQLModel):
57+
"""User provided information to submit a review request."""
58+
59+
comment: str = Field(
60+
description="Optional. Comment to the reviewer to motivate the submission or provide clarification.",
61+
max_length=NORMAL,
62+
default="",
63+
schema_extra={"example": "'IA' is not a typo, it's for L'intelligence artificielle."},
64+
)
65+
66+
5667
class SubmissionCreate(SQLModel):
5768
"""User provided information to submit a review request."""
5869

70+
asset_identifiers: list[str] = Field(
71+
description="List of identifiers of the assets to submit for review",
72+
min_length=1,
73+
schema_extra={"example": ["case_n8DhfFgMYv4beBnVurHa13ZS"]},
74+
)
5975
comment: str = Field(
6076
description="Optional. Comment to the reviewer to motivate the submission or provide clarification.",
6177
max_length=NORMAL,
@@ -64,17 +80,29 @@ class SubmissionCreate(SQLModel):
6480
)
6581

6682

67-
class SubmissionBase(SubmissionCreate):
83+
class SubmissionBase(SQLModel):
6884
"""A review request, requested by a user to publish an asset/change."""
6985

7086
identifier: int = Field(primary_key=True, default=None)
7187
request_date: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
88+
comment: str = Field(
89+
description="Optional. Comment to the reviewer to motivate the submission or provide clarification.",
90+
max_length=NORMAL,
91+
default="",
92+
schema_extra={"example": "'IA' is not a typo, it's for L'intelligence artificielle."},
93+
)
7294

95+
96+
class AssetReview(SQLModel, table=True): # type: ignore[call-arg]
97+
__tablename__ = "asset_review"
98+
asset_identifier: str = Field()
7399
# If the entry corresponding to the thing it reviews is removed,
74100
# then we also want to permanently remove the review data.
75-
aiod_entry_identifier: int = Field(foreign_key="aiod_entry.identifier", ondelete="CASCADE")
76-
asset_type: str = Field(
77-
description="The name of the table of the resource. E.g. 'dataset' or 'person'"
101+
aiod_entry_identifier: int = Field(
102+
primary_key=True, nullable=False, foreign_key="aiod_entry.identifier", ondelete="CASCADE"
103+
)
104+
review_identifier: int = Field(
105+
primary_key=True, nullable=False, foreign_key="submission.identifier", ondelete="CASCADE"
78106
)
79107

80108

@@ -88,20 +116,25 @@ class Submission(SubmissionBase, table=True): # type: ignore [call-arg]
88116
)
89117

90118
reviews: list[Review] = Relationship(back_populates="submission")
119+
_assets: list[AssetReview] = Relationship()
91120

92121
@property
93-
def asset(self) -> AIoDConcept:
122+
def assets(self) -> list[AIoDConcept]:
94123
# I could not find a way to just use a Relationship directly on account of it needing
95124
# to be defined on creation but the asset_type is only known at runtime.
96125
# We still mimic the behavior of a lazy-loaded relationship by fetching the session
97126
# related to the object, instead of instantiating a new session.
98127
session = Session.object_session(self)
99128
available_schemas: list[AIoDConcept] = list(non_abstract_subclasses(AIoDConcept))
100129
schema_by_name = {schema.__tablename__: schema for schema in available_schemas}
101-
schema = schema_by_name[self.asset_type]
102130

103-
query = select(schema).where(schema.aiod_entry_identifier == self.aiod_entry_identifier)
104-
return session.scalars(query).one()
131+
assets = []
132+
for identifier in [a.asset_identifier for a in self._assets]:
133+
prefix, _ = identifier.split("_")
134+
asset_type = get_asset_type_by_abbreviation()[prefix]
135+
schema = schema_by_name[asset_type.__tablename__]
136+
assets.append(session.get(schema, identifier))
137+
return assets
105138

106139
@property
107140
def is_pending(self):
@@ -114,16 +147,20 @@ class SubmissionView(SubmissionBase):
114147
# only return AIoDConcept fields to the user, instead of all supplied attributes.
115148
# E.g., a publication's issn is now returned but would not if it was AIoDConcept.
116149
# Instead we use the configuration below to set the schema annotations.
117-
asset: Any = Field()
150+
assets: Any = Field()
118151

119152
class Config:
120153
# This allows us to set the schema generation at runtime, which is necessary since the
121154
# ResourceRead classes are only defined at runtime (generated dynamically).
122155
@staticmethod
123156
def schema_extra(schema: dict[str, Any], _: type["SubmissionView"]) -> None:
124-
schema["properties"]["asset"] = {
125-
"title": "Asset under review",
126-
"description": "The type of the object can be found in SubmissionView.asset_type.",
127-
"type": "object",
128-
"anyOf": get_all_asset_schemas(),
157+
schema["properties"]["assets"] = {
158+
"type": "array",
159+
"title": "Assets under review",
160+
"items": {
161+
"title": "Asset",
162+
"description": "The type of the object can be derived from its identifier.",
163+
"type": "object",
164+
"anyOf": get_all_asset_schemas(),
165+
},
129166
}

0 commit comments

Comments
 (0)