Skip to content

Commit 0137533

Browse files
authored
✨ web-server RPC: exposes list_my_projects_marked_as_jobs (#7534)
1 parent b4ffe76 commit 0137533

File tree

8 files changed

+456
-1
lines changed

8 files changed

+456
-1
lines changed
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
from datetime import datetime
2+
from typing import Annotated, TypeAlias
3+
4+
from models_library.projects import ProjectID
5+
from models_library.rpc_pagination import PageRpc
6+
from pydantic import BaseModel, ConfigDict, Field
7+
8+
9+
class ProjectRpcGet(BaseModel):
10+
"""
11+
Minimal information about a project that (for now) will fullfill
12+
the needs of the api-server. Specifically, the fields needed in
13+
project to call create_job_from_project
14+
"""
15+
16+
uuid: Annotated[
17+
ProjectID,
18+
Field(description="project unique identifier"),
19+
]
20+
name: Annotated[
21+
str,
22+
Field(description="project display name"),
23+
]
24+
description: str
25+
26+
# timestamps
27+
creation_date: datetime
28+
last_change_date: datetime
29+
30+
model_config = ConfigDict(
31+
extra="forbid",
32+
populate_by_name=True,
33+
)
34+
35+
36+
PageRpcProjectRpcGet: TypeAlias = PageRpc[
37+
# WARNING: keep this definition in models_library and not in the RPC interface
38+
# otherwise the metaclass PageRpc[*] will create *different* classes in server/client side
39+
# and will fail to serialize/deserialize these parameters when transmitted/received
40+
ProjectRpcGet
41+
]

packages/service-library/src/servicelib/rabbitmq/rpc_interfaces/webserver/projects.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,16 @@
11
import logging
2+
from typing import cast
23

34
from models_library.api_schemas_webserver import WEBSERVER_RPC_NAMESPACE
45
from models_library.products import ProductName
56
from models_library.projects import ProjectID
67
from models_library.rabbitmq_basic_types import RPCMethodName
8+
from models_library.rest_pagination import PageOffsetInt
9+
from models_library.rpc.webserver.projects import PageRpcProjectRpcGet
10+
from models_library.rpc_pagination import (
11+
DEFAULT_NUMBER_OF_ITEMS_PER_PAGE,
12+
PageLimitInt,
13+
)
714
from models_library.users import UserID
815
from pydantic import TypeAdapter, validate_call
916
from servicelib.logging_utils import log_decorator
@@ -32,3 +39,29 @@ async def mark_project_as_job(
3239
job_parent_resource_name=job_parent_resource_name,
3340
)
3441
assert result is None
42+
43+
44+
@log_decorator(_logger, level=logging.DEBUG)
45+
@validate_call(config={"arbitrary_types_allowed": True})
46+
async def list_projects_marked_as_jobs(
47+
rpc_client: RabbitMQRPCClient,
48+
*,
49+
product_name: ProductName,
50+
user_id: UserID,
51+
# pagination
52+
offset: PageOffsetInt = 0,
53+
limit: PageLimitInt = DEFAULT_NUMBER_OF_ITEMS_PER_PAGE,
54+
# filters
55+
job_parent_resource_name_filter: str | None = None,
56+
) -> PageRpcProjectRpcGet:
57+
result = await rpc_client.request(
58+
WEBSERVER_RPC_NAMESPACE,
59+
TypeAdapter(RPCMethodName).validate_python("list_projects_marked_as_jobs"),
60+
product_name=product_name,
61+
user_id=user_id,
62+
offset=offset,
63+
limit=limit,
64+
job_parent_resource_name_filter=job_parent_resource_name_filter,
65+
)
66+
assert TypeAdapter(PageRpcProjectRpcGet).validate_python(result) # nosec
67+
return cast(PageRpcProjectRpcGet, result)

services/web/server/src/simcore_service_webserver/projects/_controller/projects_rpc.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
from models_library.api_schemas_webserver import WEBSERVER_RPC_NAMESPACE
33
from models_library.products import ProductName
44
from models_library.projects import ProjectID
5+
from models_library.rest_pagination import PageLimitInt, PageOffsetInt
6+
from models_library.rpc.webserver.projects import PageRpcProjectRpcGet, ProjectRpcGet
57
from models_library.users import UserID
68
from pydantic import ValidationError, validate_call
79
from servicelib.rabbitmq import RPCRouter
@@ -50,6 +52,50 @@ async def mark_project_as_job(
5052
raise ProjectNotFoundRpcError.from_domain_error(err) from err
5153

5254

55+
@router.expose(reraise_if_error_type=(ValidationError,))
56+
@validate_call(config={"arbitrary_types_allowed": True})
57+
async def list_projects_marked_as_jobs(
58+
app: web.Application,
59+
*,
60+
product_name: ProductName,
61+
user_id: UserID,
62+
# pagination
63+
offset: PageOffsetInt,
64+
limit: PageLimitInt,
65+
# filters
66+
job_parent_resource_name_filter: str | None,
67+
) -> PageRpcProjectRpcGet:
68+
69+
total, projects = await _jobs_service.list_my_projects_marked_as_jobs(
70+
app,
71+
product_name=product_name,
72+
user_id=user_id,
73+
offset=offset,
74+
limit=limit,
75+
job_parent_resource_name_filter=job_parent_resource_name_filter,
76+
)
77+
78+
job_projects = [
79+
ProjectRpcGet(
80+
uuid=project.uuid,
81+
name=project.name,
82+
description=project.description,
83+
creation_date=project.creation_date,
84+
last_change_date=project.last_change_date,
85+
)
86+
for project in projects
87+
]
88+
89+
page: PageRpcProjectRpcGet = PageRpcProjectRpcGet.create(
90+
job_projects,
91+
total=total,
92+
limit=limit,
93+
offset=offset,
94+
)
95+
96+
return page
97+
98+
5399
async def register_rpc_routes_on_startup(app: web.Application):
54100
rpc_server = get_rabbitmq_rpc_server(app)
55101
await rpc_server.register_router(router, WEBSERVER_RPC_NAMESPACE, app)

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

Lines changed: 107 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,35 @@
11
import logging
22

3+
import sqlalchemy as sa
4+
from models_library.products import ProductName
35
from models_library.projects import ProjectID
6+
from models_library.users import UserID
7+
from pydantic import TypeAdapter
8+
from simcore_postgres_database.models.groups import user_to_groups
9+
from simcore_postgres_database.models.project_to_groups import project_to_groups
10+
from simcore_postgres_database.models.projects import projects
411
from simcore_postgres_database.models.projects_to_jobs import projects_to_jobs
5-
from simcore_postgres_database.utils_repos import transaction_context
12+
from simcore_postgres_database.models.projects_to_products import projects_to_products
13+
from simcore_postgres_database.utils_repos import (
14+
get_columns_from_db_model,
15+
pass_or_acquire_connection,
16+
transaction_context,
17+
)
618
from sqlalchemy.dialects.postgresql import insert as pg_insert
719
from sqlalchemy.ext.asyncio import AsyncConnection
820

921
from ..db.base_repository import BaseRepository
22+
from .models import ProjectDBGet, ProjectJobDBGet
1023

1124
_logger = logging.getLogger(__name__)
1225

1326

27+
_PROJECT_DB_COLS = get_columns_from_db_model(
28+
projects,
29+
ProjectDBGet,
30+
)
31+
32+
1433
class ProjectJobsRepository(BaseRepository):
1534

1635
async def set_project_as_job(
@@ -34,3 +53,90 @@ async def set_project_as_job(
3453
)
3554

3655
await conn.execute(stmt)
56+
57+
async def list_projects_marked_as_jobs(
58+
self,
59+
connection: AsyncConnection | None = None,
60+
*,
61+
product_name: ProductName,
62+
user_id: UserID,
63+
offset: int = 0,
64+
limit: int = 10,
65+
job_parent_resource_name_filter: str | None = None,
66+
) -> tuple[int, list[ProjectJobDBGet]]:
67+
"""
68+
Lists projects marked as jobs for a specific user and product
69+
"""
70+
71+
# Step 1: Get group IDs associated with the user
72+
user_groups_query = (
73+
sa.select(user_to_groups.c.gid)
74+
.where(user_to_groups.c.uid == user_id)
75+
.subquery()
76+
)
77+
78+
# Step 2: Create access_query to filter projects based on product_name and read access
79+
access_query = (
80+
sa.select(projects_to_jobs)
81+
.select_from(
82+
projects_to_jobs.join(
83+
projects_to_products,
84+
projects_to_jobs.c.project_uuid
85+
== projects_to_products.c.project_uuid,
86+
).join(
87+
project_to_groups,
88+
projects_to_jobs.c.project_uuid == project_to_groups.c.project_uuid,
89+
)
90+
)
91+
.where(
92+
projects_to_products.c.product_name == product_name,
93+
project_to_groups.c.gid.in_(sa.select(user_groups_query.c.gid)),
94+
project_to_groups.c.read.is_(True),
95+
)
96+
)
97+
98+
# Apply job_parent_resource_name_filter if provided
99+
if job_parent_resource_name_filter:
100+
access_query = access_query.where(
101+
projects_to_jobs.c.job_parent_resource_name.like(
102+
f"%{job_parent_resource_name_filter}%"
103+
)
104+
)
105+
106+
# Convert access_query to a subquery
107+
base_query = access_query.subquery()
108+
109+
# Step 3: Query to get the total count
110+
total_query = sa.select(sa.func.count()).select_from(base_query)
111+
112+
# Step 4: Query to get the paginated list with full selection
113+
list_query = (
114+
sa.select(
115+
*_PROJECT_DB_COLS,
116+
base_query.c.job_parent_resource_name,
117+
)
118+
.select_from(
119+
base_query.join(
120+
projects,
121+
projects.c.uuid == base_query.c.project_uuid,
122+
)
123+
)
124+
.order_by(
125+
projects.c.creation_date.desc(), # latests first
126+
projects.c.id.desc(),
127+
)
128+
.limit(limit)
129+
.offset(offset)
130+
)
131+
132+
# Step 5: Execute queries
133+
async with pass_or_acquire_connection(self.engine, connection) as conn:
134+
total_count = await conn.scalar(total_query)
135+
assert isinstance(total_count, int) # nosec
136+
137+
result = await conn.execute(list_query)
138+
projects_list = TypeAdapter(list[ProjectJobDBGet]).validate_python(
139+
result.fetchall()
140+
)
141+
142+
return total_count, projects_list

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

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from models_library.projects import ProjectID
77
from models_library.users import UserID
88
from pydantic import AfterValidator, validate_call
9+
from simcore_service_webserver.projects.models import ProjectJobDBGet
910

1011
from ._access_rights_service import check_user_project_permission
1112
from ._jobs_repository import ProjectJobsRepository
@@ -45,3 +46,27 @@ async def set_project_as_job(
4546
await repo.set_project_as_job(
4647
project_uuid=project_uuid, job_parent_resource_name=job_parent_resource_name
4748
)
49+
50+
51+
@validate_call(config={"arbitrary_types_allowed": True})
52+
async def list_my_projects_marked_as_jobs(
53+
app: web.Application,
54+
*,
55+
product_name: ProductName,
56+
user_id: UserID,
57+
offset: int = 0,
58+
limit: int = 10,
59+
job_parent_resource_name_filter: str | None = None,
60+
) -> tuple[int, list[ProjectJobDBGet]]:
61+
"""
62+
Lists paginated projects marked as jobs for the given user and product.
63+
Optionally filters by job_parent_resource_name using SQL-like wildcard patterns.
64+
"""
65+
repo = ProjectJobsRepository.create_from_app(app)
66+
return await repo.list_projects_marked_as_jobs(
67+
user_id=user_id,
68+
product_name=product_name,
69+
offset=offset,
70+
limit=limit,
71+
job_parent_resource_name_filter=job_parent_resource_name_filter,
72+
)

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,10 @@ class ProjectDBGet(BaseModel):
7171
)
7272

7373

74+
class ProjectJobDBGet(ProjectDBGet):
75+
job_parent_resource_name: str
76+
77+
7478
class ProjectWithTrashExtra(ProjectDBGet):
7579
# This field is not part of the tables
7680
trashed_by_primary_gid: GroupID | None = None

0 commit comments

Comments
 (0)