Skip to content

Commit

Permalink
✨ Adding ordering to list projects api (#5276)
Browse files Browse the repository at this point in the history
  • Loading branch information
matusdrobuliak66 authored Jan 29, 2024
1 parent f8064f8 commit 8b6562e
Show file tree
Hide file tree
Showing 7 changed files with 181 additions and 42 deletions.
20 changes: 14 additions & 6 deletions api/specs/web-server/_projects_crud.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@

from typing import Annotated

from fastapi import APIRouter, Depends, status
from fastapi import APIRouter, Depends, Query, status
from models_library.api_schemas_directorv2.dynamic_services import (
GetProjectInactivityResponse,
)
Expand All @@ -27,12 +27,11 @@
from models_library.generics import Envelope
from models_library.projects import ProjectID
from models_library.rest_pagination import Page
from pydantic import Json
from simcore_service_webserver._meta import API_VTAG
from simcore_service_webserver.projects._common_models import ProjectPathParams
from simcore_service_webserver.projects._crud_handlers import (
ProjectCreateParams,
ProjectListParams,
)
from simcore_service_webserver.projects._crud_handlers import ProjectCreateParams
from simcore_service_webserver.projects._crud_handlers_models import ProjectListParams

router = APIRouter(
prefix=f"/{API_VTAG}",
Expand All @@ -59,7 +58,16 @@ async def create_project(
"/projects",
response_model=Page[ProjectListItem],
)
async def list_projects(_params: Annotated[ProjectListParams, Depends()]):
async def list_projects(
_params: Annotated[ProjectListParams, Depends()],
order_by: Annotated[
Json,
Query(
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.",
example='{"field": "last_change_date", "direction": "desc"}',
),
] = '{"field": "last_change_date", "direction": "desc"}',
):
...


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2080,6 +2080,17 @@ paths:
summary: List Projects
operationId: list_projects
parameters:
- 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.
required: false
schema:
title: Order By
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.
default: '{"field": "last_change_date", "direction": "desc"}'
example: '{"field": "last_change_date", "direction": "desc"}'
name: order_by
in: query
- required: false
schema:
title: Limit
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,14 @@
Read operations are list, get
"""
from enum import Enum

from aiohttp import web
from models_library.api_schemas_webserver._base import OutputSchema
from models_library.api_schemas_webserver.projects import ProjectListItem
from models_library.projects import ProjectID
from models_library.rest_ordering import OrderBy
from models_library.users import UserID
from pydantic import BaseModel, Extra, Field, NonNegativeInt, PositiveInt
from pydantic import NonNegativeInt
from servicelib.utils import logged_gather
from simcore_postgres_database.webserver_models import ProjectType as ProjectTypeDB

Expand Down Expand Up @@ -54,6 +54,7 @@ async def list_projects(
offset: NonNegativeInt,
limit: int,
search: str | None,
order_by: OrderBy,
) -> tuple[list[ProjectDict], int]:
app = request.app
db = ProjectDBAPI.get_from_app_context(app)
Expand All @@ -71,6 +72,7 @@ async def list_projects(
limit=limit,
include_hidden=show_hidden,
search=search,
order_by=order_by,
)

projects: list[ProjectDict] = await logged_gather(
Expand Down Expand Up @@ -99,30 +101,3 @@ async def get_project(
project_type: ProjectTypeAPI,
):
raise NotImplementedError


class OrderDirection(str, Enum):
ASC = "asc"
DESC = "desc"


class ProjectListFilters(BaseModel):
"""inspired by Docker API https://docs.docker.com/engine/api/v1.43/#tag/Container/operation/ContainerList.
Encoded as JSON. Each available filter can have its own logic (should be well documented)
"""

tags: list[PositiveInt] = Field(default_factory=list)
classifiers: list[str] = Field(default_factory=list)

class Config:
extra = Extra.forbid


class ProjectOrderBy(BaseModel):
"""inspired by Google AIP https://google.aip.dev/132#ordering"""

field: str = Field(default=None)
direction: OrderDirection = Field(default=OrderDirection.DESC)

class Config:
extra = Extra.forbid
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,11 @@
from models_library.generics import Envelope
from models_library.projects import Project
from models_library.projects_state import ProjectLocked
from models_library.rest_ordering import OrderBy
from models_library.rest_pagination import Page
from models_library.rest_pagination_utils import paginate_data
from models_library.utils.fastapi_encoders import jsonable_encoder
from pydantic import parse_obj_as
from servicelib.aiohttp.long_running_tasks.server import start_long_running_task
from servicelib.aiohttp.requests_validation import (
parse_request_body_as,
Expand Down Expand Up @@ -48,7 +50,7 @@
from ._crud_handlers_models import (
ProjectActiveParams,
ProjectCreateParams,
ProjectListParams,
ProjectListWithJsonStrParams,
)
from ._permalink_api import update_or_pop_permalink_in_project
from .db import ProjectDBAPI
Expand Down Expand Up @@ -152,7 +154,9 @@ async def list_projects(request: web.Request):
"""
req_ctx = RequestContext.parse_obj(request)
query_params = parse_request_query_parameters_as(ProjectListParams, request)
query_params = parse_request_query_parameters_as(
ProjectListWithJsonStrParams, request
)

projects, total_number_of_projects = await _crud_api_read.list_projects(
request,
Expand All @@ -163,6 +167,7 @@ async def list_projects(request: web.Request):
limit=query_params.limit,
offset=query_params.offset,
search=query_params.search,
order_by=parse_obj_as(OrderBy, query_params.order_by),
)

page = Page[ProjectDict].parse_obj(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,9 @@
"""

from models_library.projects import ProjectID
from models_library.rest_ordering import OrderBy, OrderDirection
from models_library.rest_pagination import PageQueryParameters
from pydantic import BaseModel, Extra, Field, validator
from pydantic import BaseModel, Extra, Field, Json, validator

from .models import ProjectTypeAPI

Expand Down Expand Up @@ -52,6 +53,30 @@ def search_check_empty_string(cls, v):
return None
return v


class ProjectListWithJsonStrParams(ProjectListParams):
order_by: Json[OrderBy] = Field( # pylint: disable=unsubscriptable-object
default=OrderBy(field="last_change_date", direction=OrderDirection.DESC),
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.",
example='{"field": "prj_owner", "direction": "desc"}',
alias="order_by",
)

@validator("order_by", check_fields=False)
@classmethod
def validate_order_by_field(cls, v):
if v.field not in {
"type",
"uuid",
"name",
"description",
"prj_owner",
"creation_date",
"last_change_date",
}:
raise ValueError(f"We do not support ordering by provided field {v.field}")
return v

class Config:
extra = Extra.forbid

Expand Down
14 changes: 10 additions & 4 deletions services/web/server/src/simcore_service_webserver/projects/db.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
PricingPlanId,
PricingUnitId,
)
from models_library.rest_ordering import OrderBy, OrderDirection
from models_library.users import UserID
from models_library.utils.fastapi_encoders import jsonable_encoder
from models_library.wallets import WalletDB, WalletID
Expand All @@ -43,7 +44,7 @@
ProjectNodesRepo,
)
from simcore_postgres_database.webserver_models import ProjectType, projects, users
from sqlalchemy import desc, func, literal_column
from sqlalchemy import func, literal_column
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.sql import and_
from tenacity import TryAgain
Expand Down Expand Up @@ -308,7 +309,7 @@ async def upsert_project_linked_product(
.on_conflict_do_nothing()
)

async def list_projects(
async def list_projects( # pylint: disable=too-many-arguments
self,
user_id: PositiveInt,
*,
Expand All @@ -320,6 +321,9 @@ async def list_projects(
offset: int | None = 0,
limit: int | None = None,
search: str | None = None,
order_by: OrderBy = OrderBy(
field="last_change_date", direction=OrderDirection.DESC
),
) -> tuple[list[dict[str, Any]], list[ProjectType], int]:
async with self.engine.acquire() as conn:
user_groups: list[RowProxy] = await self._list_user_groups(conn, user_id)
Expand Down Expand Up @@ -365,8 +369,10 @@ async def list_projects(
| (users.c.name.ilike(f"%{search}%"))
)

# Default ordering
query = query.order_by(desc(projects.c.last_change_date), projects.c.id)
if order_by.direction == OrderDirection.ASC:
query = query.order_by(sa.asc(order_by.field))
else:
query = query.order_by(sa.desc(order_by.field))

total_number_of_projects = await conn.scalar(
query.with_only_columns(func.count()).order_by(None)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
# pylint: disable=unused-variable
# pylint: disable=too-many-statements

import json
import random
from collections import UserDict
from copy import deepcopy
Expand Down Expand Up @@ -251,3 +252,111 @@ async def test_list_projects_with_search_parameter(
assert data["_meta"]["limit"] == 1
assert data["_links"]["next"].endswith("/v0/projects?search=oda&offset=1&limit=1")
assert data["_links"]["last"].endswith("/v0/projects?search=oda&offset=1&limit=1")


_alphabetically_ordered_list = ["a", "b", "c", "d", "e"]


@pytest.mark.parametrize(*standard_user_role())
async def test_list_projects_with_order_by_parameter(
client: TestClient,
logged_user: UserDict,
expected: ExpectedResponse,
fake_project: ProjectDict,
tests_data_dir: Path,
osparc_product_name: str,
project_db_cleaner,
mock_catalog_api_get_services_for_user_in_product,
):
projects_info = [
_ProjectInfo(
uuid="aaa0eca3-d210-4db6-84f9-63670b07176b",
name="d",
description="c",
),
_ProjectInfo(
uuid="cccef868-fe1b-11ed-b038-cdb13a78a6f3",
name="b",
description="e",
),
_ProjectInfo(
uuid="eee66c12-fe1b-11ed-b038-cdb13a78a6f3",
name="a",
description="a",
),
_ProjectInfo(
uuid="ddd32426-fe1b-11ed-b038-cdb13a78a6f3",
name="c",
description="b",
),
_ProjectInfo(
uuid="bbb7aff6-fe1b-11ed-b038-cdb13a78a6f3",
name="e",
description="d",
),
]

user_projects = []
for project_ in projects_info:
project_data = deepcopy(fake_project)
project_data["name"] = project_.name
project_data["uuid"] = project_.uuid
project_data["description"] = project_.description

user_projects.append(
await _new_project(
client,
logged_user["id"],
osparc_product_name,
tests_data_dir,
project_data,
)
)

# Order by uuid ascending
base_url = client.app.router["list_projects"].url_for()
url = base_url.with_query(
order_by=json.dumps({"field": "uuid", "direction": "asc"})
)
assert (
f"{url}"
== f"/{api_version_prefix}/projects?order_by=%7B%22field%22:+%22uuid%22,+%22direction%22:+%22asc%22%7D"
)
resp = await client.get(url)
data = await resp.json()
assert resp.status == 200
assert [item["uuid"][0] for item in data["data"]] == _alphabetically_ordered_list

# Order by uuid descending
base_url = client.app.router["list_projects"].url_for()
url = base_url.with_query(
order_by=json.dumps({"field": "uuid", "direction": "desc"})
)
resp = await client.get(url)
data = await resp.json()
assert resp.status == 200
assert [item["uuid"][0] for item in data["data"]] == _alphabetically_ordered_list[
::-1
]

# Order by name ascending
base_url = client.app.router["list_projects"].url_for()
url = base_url.with_query(
order_by=json.dumps({"field": "name", "direction": "asc"})
)
resp = await client.get(url)
data = await resp.json()
assert resp.status == 200
assert [item["name"][0] for item in data["data"]] == _alphabetically_ordered_list

# Order by description ascending
base_url = client.app.router["list_projects"].url_for()
url = base_url.with_query(
order_by=json.dumps({"field": "description", "direction": "asc"})
)
resp = await client.get(url)
data = await resp.json()
assert resp.status == 200
assert [
item["description"][0] for item in data["data"]
] == _alphabetically_ordered_list

0 comments on commit 8b6562e

Please sign in to comment.