Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
from datetime import datetime
from typing import Annotated, TypeAlias

from models_library.projects import ProjectID
from models_library.rpc_pagination import PageRpc
from pydantic import BaseModel, ConfigDict, Field


class ProjectRpcGet(BaseModel):
"""
Minimal information about a project that (for now) will fullfill
the needs of the api-server. Specifically, the fields needed in
project to call create_job_from_project
"""

uuid: Annotated[
ProjectID,
Field(description="project unique identifier"),
]
name: Annotated[
str,
Field(description="project display name"),
]
description: str

# timestamps
creation_date: datetime
last_change_date: datetime

model_config = ConfigDict(
extra="forbid",
populate_by_name=True,
)


PageRpcProjectRpcGet: TypeAlias = PageRpc[
# WARNING: keep this definition in models_library and not in the RPC interface
# otherwise the metaclass PageRpc[*] will create *different* classes in server/client side
# and will fail to serialize/deserialize these parameters when transmitted/received
ProjectRpcGet
]
Original file line number Diff line number Diff line change
@@ -1,9 +1,16 @@
import logging
from typing import cast

from models_library.api_schemas_webserver import WEBSERVER_RPC_NAMESPACE
from models_library.products import ProductName
from models_library.projects import ProjectID
from models_library.rabbitmq_basic_types import RPCMethodName
from models_library.rest_pagination import PageOffsetInt
from models_library.rpc.webserver.projects import PageRpcProjectRpcGet
from models_library.rpc_pagination import (
DEFAULT_NUMBER_OF_ITEMS_PER_PAGE,
PageLimitInt,
)
from models_library.users import UserID
from pydantic import TypeAdapter, validate_call
from servicelib.logging_utils import log_decorator
Expand Down Expand Up @@ -32,3 +39,29 @@ async def mark_project_as_job(
job_parent_resource_name=job_parent_resource_name,
)
assert result is None


@log_decorator(_logger, level=logging.DEBUG)
@validate_call(config={"arbitrary_types_allowed": True})
async def list_projects_marked_as_jobs(
rpc_client: RabbitMQRPCClient,
*,
product_name: ProductName,
user_id: UserID,
# pagination
offset: PageOffsetInt = 0,
limit: PageLimitInt = DEFAULT_NUMBER_OF_ITEMS_PER_PAGE,
# filters
job_parent_resource_name_filter: str | None = None,
) -> PageRpcProjectRpcGet:
result = await rpc_client.request(
WEBSERVER_RPC_NAMESPACE,
TypeAdapter(RPCMethodName).validate_python("list_projects_marked_as_jobs"),
product_name=product_name,
user_id=user_id,
offset=offset,
limit=limit,
job_parent_resource_name_filter=job_parent_resource_name_filter,
)
assert TypeAdapter(PageRpcProjectRpcGet).validate_python(result) # nosec
return cast(PageRpcProjectRpcGet, result)
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
from models_library.api_schemas_webserver import WEBSERVER_RPC_NAMESPACE
from models_library.products import ProductName
from models_library.projects import ProjectID
from models_library.rest_pagination import PageLimitInt, PageOffsetInt
from models_library.rpc.webserver.projects import PageRpcProjectRpcGet, ProjectRpcGet
from models_library.users import UserID
from pydantic import ValidationError, validate_call
from servicelib.rabbitmq import RPCRouter
Expand Down Expand Up @@ -50,6 +52,50 @@ async def mark_project_as_job(
raise ProjectNotFoundRpcError.from_domain_error(err) from err


@router.expose(reraise_if_error_type=(ValidationError,))
@validate_call(config={"arbitrary_types_allowed": True})
async def list_projects_marked_as_jobs(
app: web.Application,
*,
product_name: ProductName,
user_id: UserID,
# pagination
offset: PageOffsetInt,
limit: PageLimitInt,
# filters
job_parent_resource_name_filter: str | None,
) -> PageRpcProjectRpcGet:

total, projects = await _jobs_service.list_my_projects_marked_as_jobs(
app,
product_name=product_name,
user_id=user_id,
offset=offset,
limit=limit,
job_parent_resource_name_filter=job_parent_resource_name_filter,
)

job_projects = [
ProjectRpcGet(
uuid=project.uuid,
name=project.name,
description=project.description,
creation_date=project.creation_date,
last_change_date=project.last_change_date,
)
for project in projects
]

page: PageRpcProjectRpcGet = PageRpcProjectRpcGet.create(
job_projects,
total=total,
limit=limit,
offset=offset,
)

return page


async def register_rpc_routes_on_startup(app: web.Application):
rpc_server = get_rabbitmq_rpc_server(app)
await rpc_server.register_router(router, WEBSERVER_RPC_NAMESPACE, app)
Original file line number Diff line number Diff line change
@@ -1,16 +1,35 @@
import logging

import sqlalchemy as sa
from models_library.products import ProductName
from models_library.projects import ProjectID
from models_library.users import UserID
from pydantic import TypeAdapter
from simcore_postgres_database.models.groups import user_to_groups
from simcore_postgres_database.models.project_to_groups import project_to_groups
from simcore_postgres_database.models.projects import projects
from simcore_postgres_database.models.projects_to_jobs import projects_to_jobs
from simcore_postgres_database.utils_repos import transaction_context
from simcore_postgres_database.models.projects_to_products import projects_to_products
from simcore_postgres_database.utils_repos import (
get_columns_from_db_model,
pass_or_acquire_connection,
transaction_context,
)
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.ext.asyncio import AsyncConnection

from ..db.base_repository import BaseRepository
from .models import ProjectDBGet, ProjectJobDBGet

_logger = logging.getLogger(__name__)


_PROJECT_DB_COLS = get_columns_from_db_model(
projects,
ProjectDBGet,
)


class ProjectJobsRepository(BaseRepository):

async def set_project_as_job(
Expand All @@ -34,3 +53,90 @@ async def set_project_as_job(
)

await conn.execute(stmt)

async def list_projects_marked_as_jobs(
self,
connection: AsyncConnection | None = None,
*,
product_name: ProductName,
user_id: UserID,
offset: int = 0,
limit: int = 10,
job_parent_resource_name_filter: str | None = None,
) -> tuple[int, list[ProjectJobDBGet]]:
"""
Lists projects marked as jobs for a specific user and product
"""

# Step 1: Get group IDs associated with the user
user_groups_query = (
sa.select(user_to_groups.c.gid)
.where(user_to_groups.c.uid == user_id)
.subquery()
)

# Step 2: Create access_query to filter projects based on product_name and read access
access_query = (
sa.select(projects_to_jobs)
.select_from(
projects_to_jobs.join(
projects_to_products,
projects_to_jobs.c.project_uuid
== projects_to_products.c.project_uuid,
).join(
project_to_groups,
projects_to_jobs.c.project_uuid == project_to_groups.c.project_uuid,
)
)
.where(
projects_to_products.c.product_name == product_name,
project_to_groups.c.gid.in_(sa.select(user_groups_query.c.gid)),
project_to_groups.c.read.is_(True),
)
)

# Apply job_parent_resource_name_filter if provided
if job_parent_resource_name_filter:
access_query = access_query.where(
projects_to_jobs.c.job_parent_resource_name.like(
f"%{job_parent_resource_name_filter}%"
)
)

# Convert access_query to a subquery
base_query = access_query.subquery()

# Step 3: Query to get the total count
total_query = sa.select(sa.func.count()).select_from(base_query)

# Step 4: Query to get the paginated list with full selection
list_query = (
sa.select(
*_PROJECT_DB_COLS,
base_query.c.job_parent_resource_name,
)
.select_from(
base_query.join(
projects,
projects.c.uuid == base_query.c.project_uuid,
)
)
.order_by(
projects.c.creation_date.desc(), # latests first
projects.c.id.desc(),
)
.limit(limit)
.offset(offset)
)

# Step 5: Execute queries
async with pass_or_acquire_connection(self.engine, connection) as conn:
total_count = await conn.scalar(total_query)
assert isinstance(total_count, int) # nosec

result = await conn.execute(list_query)
projects_list = TypeAdapter(list[ProjectJobDBGet]).validate_python(
result.fetchall()
)

return total_count, projects_list
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from models_library.projects import ProjectID
from models_library.users import UserID
from pydantic import AfterValidator, validate_call
from simcore_service_webserver.projects.models import ProjectJobDBGet

from ._access_rights_service import check_user_project_permission
from ._jobs_repository import ProjectJobsRepository
Expand Down Expand Up @@ -45,3 +46,27 @@ async def set_project_as_job(
await repo.set_project_as_job(
project_uuid=project_uuid, job_parent_resource_name=job_parent_resource_name
)


@validate_call(config={"arbitrary_types_allowed": True})
async def list_my_projects_marked_as_jobs(
app: web.Application,
*,
product_name: ProductName,
user_id: UserID,
offset: int = 0,
limit: int = 10,
job_parent_resource_name_filter: str | None = None,
) -> tuple[int, list[ProjectJobDBGet]]:
"""
Lists paginated projects marked as jobs for the given user and product.
Optionally filters by job_parent_resource_name using SQL-like wildcard patterns.
"""
repo = ProjectJobsRepository.create_from_app(app)
return await repo.list_projects_marked_as_jobs(
user_id=user_id,
product_name=product_name,
offset=offset,
limit=limit,
job_parent_resource_name_filter=job_parent_resource_name_filter,
)
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,10 @@ class ProjectDBGet(BaseModel):
)


class ProjectJobDBGet(ProjectDBGet):
job_parent_resource_name: str


class ProjectWithTrashExtra(ProjectDBGet):
# This field is not part of the tables
trashed_by_primary_gid: GroupID | None = None
Expand Down
Loading
Loading