Skip to content

Commit d38a6c3

Browse files
🎨 improve project full search (#6483)
1 parent 4d0fa91 commit d38a6c3

File tree

10 files changed

+306
-31
lines changed

10 files changed

+306
-31
lines changed

api/specs/web-server/_projects_crud.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -142,10 +142,17 @@ async def clone_project(
142142

143143
@router.get(
144144
"/projects:search",
145-
response_model=Page[ProjectListItem],
145+
response_model=Page[ProjectListFullSearchParams],
146146
)
147147
async def list_projects_full_search(
148148
_params: Annotated[ProjectListFullSearchParams, Depends()],
149+
order_by: Annotated[
150+
Json,
151+
Query(
152+
description="Order by field (type|uuid|name|description|prj_owner|creation_date|last_change_date) and direction (asc|desc). The default sorting order is ascending.",
153+
example='{"field": "last_change_date", "direction": "desc"}',
154+
),
155+
] = ('{"field": "last_change_date", "direction": "desc"}',),
149156
):
150157
...
151158

packages/service-library/src/servicelib/aiohttp/requests_validation.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,7 +166,10 @@ def parse_request_query_parameters_as(
166166
resource_name=request.rel_url.path,
167167
use_error_v1=use_enveloped_error_v1,
168168
):
169+
# NOTE: Currently, this does not take into consideration cases where there are multiple
170+
# query parameters with the same key. However, we are not using such cases anywhere at the moment.
169171
data = dict(request.query)
172+
170173
if hasattr(parameters_schema_cls, "parse_obj"):
171174
return parameters_schema_cls.parse_obj(data)
172175
model: ModelClass = parse_obj_as(parameters_schema_cls, data)

services/web/server/src/simcore_service_webserver/api/v0/openapi.yaml

Lines changed: 69 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3298,6 +3298,18 @@ paths:
32983298
summary: List Projects Full Search
32993299
operationId: list_projects_full_search
33003300
parameters:
3301+
- description: Order by field (type|uuid|name|description|prj_owner|creation_date|last_change_date)
3302+
and direction (asc|desc). The default sorting order is ascending.
3303+
required: false
3304+
schema:
3305+
title: Order By
3306+
description: Order by field (type|uuid|name|description|prj_owner|creation_date|last_change_date)
3307+
and direction (asc|desc). The default sorting order is ascending.
3308+
default:
3309+
- '{"field": "last_change_date", "direction": "desc"}'
3310+
example: '{"field": "last_change_date", "direction": "desc"}'
3311+
name: order_by
3312+
in: query
33013313
- required: false
33023314
schema:
33033315
title: Limit
@@ -3323,13 +3335,19 @@ paths:
33233335
type: string
33243336
name: text
33253337
in: query
3338+
- required: false
3339+
schema:
3340+
title: Tag Ids
3341+
type: string
3342+
name: tag_ids
3343+
in: query
33263344
responses:
33273345
'200':
33283346
description: Successful Response
33293347
content:
33303348
application/json:
33313349
schema:
3332-
$ref: '#/components/schemas/Page_ProjectListItem_'
3350+
$ref: '#/components/schemas/Page_ProjectListFullSearchParams_'
33333351
/v0/projects/{project_id}/inactivity:
33343352
get:
33353353
tags:
@@ -9555,6 +9573,25 @@ components:
95559573
$ref: '#/components/schemas/ProjectIterationResultItem'
95569574
additionalProperties: false
95579575
description: Paginated response model of ItemTs
9576+
Page_ProjectListFullSearchParams_:
9577+
title: Page[ProjectListFullSearchParams]
9578+
required:
9579+
- _meta
9580+
- _links
9581+
- data
9582+
type: object
9583+
properties:
9584+
_meta:
9585+
$ref: '#/components/schemas/PageMetaInfoLimitOffset'
9586+
_links:
9587+
$ref: '#/components/schemas/PageLinks'
9588+
data:
9589+
title: Data
9590+
type: array
9591+
items:
9592+
$ref: '#/components/schemas/ProjectListFullSearchParams'
9593+
additionalProperties: false
9594+
description: Paginated response model of ItemTs
95589595
Page_ProjectListItem_:
95599596
title: Page[ProjectListItem]
95609597
required:
@@ -10414,6 +10451,37 @@ components:
1041410451
format: uri
1041510452
results:
1041610453
$ref: '#/components/schemas/ExtractedResults'
10454+
ProjectListFullSearchParams:
10455+
title: ProjectListFullSearchParams
10456+
type: object
10457+
properties:
10458+
limit:
10459+
title: Limit
10460+
exclusiveMaximum: true
10461+
minimum: 1
10462+
type: integer
10463+
description: maximum number of items to return (pagination)
10464+
default: 20
10465+
maximum: 50
10466+
offset:
10467+
title: Offset
10468+
minimum: 0
10469+
type: integer
10470+
description: index to the first item to return (pagination)
10471+
default: 0
10472+
text:
10473+
title: Text
10474+
maxLength: 100
10475+
type: string
10476+
description: Multi column full text search, across all folders and workspaces
10477+
example: My Project
10478+
tag_ids:
10479+
title: Tag Ids
10480+
type: string
10481+
description: Search by tag ID (multiple tag IDs may be provided separated
10482+
by column)
10483+
example: 1,3
10484+
description: Use as pagination options in query parameters
1041710485
ProjectListItem:
1041810486
title: ProjectListItem
1041910487
required:

services/web/server/src/simcore_service_webserver/projects/_crud_api_read.py

Lines changed: 33 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -143,25 +143,54 @@ async def list_projects( # pylint: disable=too-many-arguments
143143

144144

145145
async def list_projects_full_search(
146-
app,
146+
request,
147147
*,
148148
user_id: UserID,
149149
product_name: str,
150150
offset: NonNegativeInt,
151151
limit: int,
152152
text: str | None,
153+
order_by: OrderBy,
154+
tag_ids_list: list[int],
153155
) -> tuple[list[ProjectDict], int]:
154-
db = ProjectDBAPI.get_from_app_context(app)
156+
db = ProjectDBAPI.get_from_app_context(request.app)
157+
158+
user_available_services: list[dict] = await get_services_for_user_in_product(
159+
request.app, user_id, product_name, only_key_versions=True
160+
)
155161

156-
total_number_projects, db_projects = await db.list_projects_full_search(
162+
(
163+
db_projects,
164+
db_project_types,
165+
total_number_projects,
166+
) = await db.list_projects_full_search(
157167
user_id=user_id,
158168
product_name=product_name,
169+
filter_by_services=user_available_services,
159170
text=text,
160171
offset=offset,
161172
limit=limit,
173+
order_by=order_by,
174+
tag_ids_list=tag_ids_list,
162175
)
163176

164-
return db_projects, total_number_projects
177+
projects: list[ProjectDict] = await logged_gather(
178+
*(
179+
_append_fields(
180+
request,
181+
user_id=user_id,
182+
project=prj,
183+
is_template=prj_type == ProjectTypeDB.TEMPLATE,
184+
workspace_access_rights=None,
185+
model_schema_cls=ProjectListItem,
186+
)
187+
for prj, prj_type in zip(db_projects, db_project_types)
188+
),
189+
reraise=True,
190+
max_concurrency=100,
191+
)
192+
193+
return projects, total_number_projects
165194

166195

167196
async def get_project(

services/web/server/src/simcore_service_webserver/projects/_crud_handlers.py

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@
5858
ProjectActiveParams,
5959
ProjectCreateHeaders,
6060
ProjectCreateParams,
61-
ProjectListFullSearchParams,
61+
ProjectListFullSearchWithJsonStrParams,
6262
ProjectListWithJsonStrParams,
6363
)
6464
from ._permalink_api import update_or_pop_permalink_in_project
@@ -69,6 +69,7 @@
6969
ProjectInvalidUsageError,
7070
ProjectNotFoundError,
7171
ProjectOwnerNotFoundInTheProjectAccessRightsError,
72+
WrongTagIdsInQueryError,
7273
)
7374
from .lock import get_project_locked_state
7475
from .models import ProjectDict
@@ -101,7 +102,10 @@ async def _wrapper(request: web.Request) -> web.StreamResponse:
101102
WorkspaceNotFoundError,
102103
) as exc:
103104
raise web.HTTPNotFound(reason=f"{exc}") from exc
104-
except ProjectOwnerNotFoundInTheProjectAccessRightsError as exc:
105+
except (
106+
ProjectOwnerNotFoundInTheProjectAccessRightsError,
107+
WrongTagIdsInQueryError,
108+
) as exc:
105109
raise web.HTTPBadRequest(reason=f"{exc}") from exc
106110
except (
107111
ProjectInvalidRightsError,
@@ -233,17 +237,22 @@ async def list_projects(request: web.Request):
233237
@_handle_projects_exceptions
234238
async def list_projects_full_search(request: web.Request):
235239
req_ctx = RequestContext.parse_obj(request)
236-
query_params: ProjectListFullSearchParams = parse_request_query_parameters_as(
237-
ProjectListFullSearchParams, request
240+
query_params: ProjectListFullSearchWithJsonStrParams = (
241+
parse_request_query_parameters_as(
242+
ProjectListFullSearchWithJsonStrParams, request
243+
)
238244
)
245+
tag_ids_list = query_params.tag_ids_list()
239246

240247
projects, total_number_of_projects = await _crud_api_read.list_projects_full_search(
241-
request.app,
248+
request,
242249
user_id=req_ctx.user_id,
243250
product_name=req_ctx.product_name,
244251
limit=query_params.limit,
245252
offset=query_params.offset,
246253
text=query_params.text,
254+
order_by=query_params.order_by,
255+
tag_ids_list=tag_ids_list,
247256
)
248257

249258
page = Page[ProjectDict].parse_obj(

services/web/server/src/simcore_service_webserver/projects/_crud_handlers_models.py

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,14 +17,23 @@
1717
null_or_none_str_to_none_validator,
1818
)
1919
from models_library.workspaces import WorkspaceID
20-
from pydantic import BaseModel, Extra, Field, Json, root_validator, validator
20+
from pydantic import (
21+
BaseModel,
22+
Extra,
23+
Field,
24+
Json,
25+
parse_obj_as,
26+
root_validator,
27+
validator,
28+
)
2129
from servicelib.common_headers import (
2230
UNDEFINED_DEFAULT_SIMCORE_USER_AGENT_VALUE,
2331
X_SIMCORE_PARENT_NODE_ID,
2432
X_SIMCORE_PARENT_PROJECT_UUID,
2533
X_SIMCORE_USER_AGENT,
2634
)
2735

36+
from .exceptions import WrongTagIdsInQueryError
2837
from .models import ProjectTypeAPI
2938

3039

@@ -123,7 +132,7 @@ def search_check_empty_string(cls, v):
123132
)(null_or_none_str_to_none_validator)
124133

125134

126-
class ProjectListWithJsonStrParams(ProjectListParams):
135+
class ProjectListWithOrderByParams(BaseModel):
127136
order_by: Json[OrderBy] = Field( # pylint: disable=unsubscriptable-object
128137
default=OrderBy(field=IDStr("last_change_date"), direction=OrderDirection.DESC),
129138
description="Order by field (type|uuid|name|description|prj_owner|creation_date|last_change_date) and direction (asc|desc). The default sorting order is ascending.",
@@ -151,6 +160,10 @@ class Config:
151160
extra = Extra.forbid
152161

153162

163+
class ProjectListWithJsonStrParams(ProjectListParams, ProjectListWithOrderByParams):
164+
...
165+
166+
154167
class ProjectActiveParams(BaseModel):
155168
client_session_id: str
156169

@@ -162,7 +175,30 @@ class ProjectListFullSearchParams(PageQueryParameters):
162175
max_length=100,
163176
example="My Project",
164177
)
178+
tag_ids: str | None = Field(
179+
default=None,
180+
description="Search by tag ID (multiple tag IDs may be provided separated by column)",
181+
example="1,3",
182+
)
165183

166184
_empty_is_none = validator("text", allow_reuse=True, pre=True)(
167185
empty_str_to_none_pre_validator
168186
)
187+
188+
189+
class ProjectListFullSearchWithJsonStrParams(
190+
ProjectListFullSearchParams, ProjectListWithOrderByParams
191+
):
192+
def tag_ids_list(self) -> list[int]:
193+
try:
194+
# Split the tag_ids by commas and map them to integers
195+
if self.tag_ids:
196+
tag_ids_list = list(map(int, self.tag_ids.split(",")))
197+
# Validate that the tag_ids_list is indeed a list of integers
198+
parse_obj_as(list[int], tag_ids_list)
199+
else:
200+
tag_ids_list = []
201+
except ValueError as exc:
202+
raise WrongTagIdsInQueryError from exc
203+
204+
return tag_ids_list

services/web/server/src/simcore_service_webserver/projects/_db_utils.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
from simcore_postgres_database.webserver_models import ProjectType, projects
2121
from sqlalchemy.dialects.postgresql import insert as pg_insert
2222
from sqlalchemy.sql import select
23-
from sqlalchemy.sql.selectable import Select
23+
from sqlalchemy.sql.selectable import CompoundSelect, Select
2424

2525
from ..db.models import GroupType, groups, projects_tags, user_to_groups, users
2626
from ..users.exceptions import UserNotFoundError
@@ -181,7 +181,7 @@ async def _execute_without_permission_check(
181181
conn: SAConnection,
182182
user_id: UserID,
183183
*,
184-
select_projects_query: Select,
184+
select_projects_query: Select | CompoundSelect,
185185
filter_by_services: list[dict] | None = None,
186186
) -> tuple[list[dict[str, Any]], list[ProjectType]]:
187187
api_projects: list[dict] = [] # API model-compatible projects

0 commit comments

Comments
 (0)