diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml index b7d032f46..08dca9ca0 100644 --- a/.devcontainer/docker-compose.yml +++ b/.devcontainer/docker-compose.yml @@ -12,12 +12,12 @@ services: DB_USER: renku DB_NAME: renku DB_PASSWORD: renku - DB_HOST: 127.0.0.1 + DB_HOST: 127.0.0.1 CORS_ALLOW_ALL_ORIGINS: "true" ALEMBIC_CONFIG: /workspace/components/renku_data_services/migrations/alembic.ini - AUTHZ_DB_HOST: 127.0.0.1 - AUTHZ_DB_GRPC_PORT: "50051" - AUTHZ_DB_KEY: renku + AUTHZ_DB_HOST: 127.0.0.1 + AUTHZ_DB_GRPC_PORT: "50051" + AUTHZ_DB_KEY: renku AUTHZ_DB_NO_TLS_CONNECTION: "true" ZED_ENDPOINT: 127.0.0.1:50051 ZED_TOKEN: renku @@ -50,7 +50,7 @@ services: SWAGGER_JSON_URL: http://localhost:8000/api/data/spec.json PORT: "8080" network_mode: service:db - + authz: image: authzed/spicedb:latest-debug restart: unless-stopped diff --git a/Makefile b/Makefile index 2e8a34a54..5b588b2e5 100644 --- a/Makefile +++ b/Makefile @@ -26,8 +26,6 @@ components/renku_data_services/project/apispec.py: components/renku_data_service poetry run datamodel-codegen --input components/renku_data_services/project/api.spec.yaml --output components/renku_data_services/project/apispec.py --base-class renku_data_services.project.apispec_base.BaseAPISpec $(codegen_params) components/renku_data_services/session/apispec.py: components/renku_data_services/session/api.spec.yaml poetry run datamodel-codegen --input components/renku_data_services/session/api.spec.yaml --output components/renku_data_services/session/apispec.py --base-class renku_data_services.session.apispec_base.BaseAPISpec $(codegen_params) -components/renku_data_services/user_preferences/apispec.py: components/renku_data_services/user_preferences/api.spec.yaml - poetry run datamodel-codegen --input components/renku_data_services/user_preferences/api.spec.yaml --output components/renku_data_services/user_preferences/apispec.py --base-class renku_data_services.user_preferences.apispec_base.BaseAPISpec $(codegen_params) components/renku_data_services/namespace/apispec.py: components/renku_data_services/namespace/api.spec.yaml poetry run datamodel-codegen --input components/renku_data_services/namespace/api.spec.yaml --output components/renku_data_services/namespace/apispec.py --base-class renku_data_services.namespace.apispec_base.BaseAPISpec $(codegen_params) components/renku_data_services/secrets/apispec.py: components/renku_data_services/secrets/api.spec.yaml @@ -43,7 +41,7 @@ components/renku_data_services/platform/apispec.py: components/renku_data_servic ##@ Apispec -schemas: components/renku_data_services/crc/apispec.py components/renku_data_services/storage/apispec.py components/renku_data_services/users/apispec.py components/renku_data_services/project/apispec.py components/renku_data_services/user_preferences/apispec.py components/renku_data_services/namespace/apispec.py components/renku_data_services/secrets/apispec.py components/renku_data_services/connected_services/apispec.py components/renku_data_services/repositories/apispec.py components/renku_data_services/notebooks/apispec.py components/renku_data_services/platform/apispec.py ## Generate pydantic classes from apispec yaml files +schemas: components/renku_data_services/crc/apispec.py components/renku_data_services/storage/apispec.py components/renku_data_services/users/apispec.py components/renku_data_services/project/apispec.py components/renku_data_services/namespace/apispec.py components/renku_data_services/secrets/apispec.py components/renku_data_services/connected_services/apispec.py components/renku_data_services/repositories/apispec.py components/renku_data_services/notebooks/apispec.py components/renku_data_services/platform/apispec.py ## Generate pydantic classes from apispec yaml files @echo "generated classes based on ApiSpec" ##@ Avro schemas @@ -72,8 +70,6 @@ style_checks: ## Run linting and style checks @$(call test_apispec_up_to_date,"crc") @echo "checking storage apispec is up to date" @$(call test_apispec_up_to_date,"storage") - @echo "checking user preferences apispec is up to date" - @$(call test_apispec_up_to_date,"user_preferences") @echo "checking users apispec is up to date" @$(call test_apispec_up_to_date,"users") @echo "checking project apispec is up to date" diff --git a/bases/renku_data_services/data_api/app.py b/bases/renku_data_services/data_api/app.py index 2bda74481..b2af98684 100644 --- a/bases/renku_data_services/data_api/app.py +++ b/bases/renku_data_services/data_api/app.py @@ -21,8 +21,7 @@ from renku_data_services.repositories.blueprints import RepositoriesBP from renku_data_services.session.blueprints import EnvironmentsBP, SessionLaunchersBP from renku_data_services.storage.blueprints import StorageBP, StorageSchemaBP, StoragesV2BP -from renku_data_services.user_preferences.blueprints import UserPreferencesBP -from renku_data_services.users.blueprints import KCUsersBP, UserSecretsBP +from renku_data_services.users.blueprints import KCUsersBP, UserPreferencesBP, UserSecretsBP def register_all_handlers(app: Sanic, config: Config) -> Sanic: diff --git a/components/renku_data_services/app_config/config.py b/components/renku_data_services/app_config/config.py index 7fdfa5ec3..99f041f1e 100644 --- a/components/renku_data_services/app_config/config.py +++ b/components/renku_data_services/app_config/config.py @@ -28,7 +28,6 @@ import renku_data_services.platform import renku_data_services.repositories import renku_data_services.storage -import renku_data_services.user_preferences import renku_data_services.users from renku_data_services import errors from renku_data_services.authn.dummy import DummyAuthenticator, DummyUserStore @@ -59,8 +58,8 @@ from renku_data_services.secrets.db import UserSecretsRepo from renku_data_services.session.db import SessionRepository from renku_data_services.storage.db import StorageRepository, StorageV2Repository -from renku_data_services.user_preferences.config import UserPreferencesConfig -from renku_data_services.user_preferences.db import UserPreferencesRepository +from renku_data_services.users.config import UserPreferencesConfig +from renku_data_services.users.db import UserPreferencesRepository from renku_data_services.users.db import UserRepo as KcUserRepo from renku_data_services.users.dummy_kc_api import DummyKeycloakAPI from renku_data_services.users.kc_api import IKeycloakAPI, KeycloakAPI @@ -185,10 +184,6 @@ def __post_init__(self) -> None: with open(spec_file) as f: storage_spec = safe_load(f) - spec_file = Path(renku_data_services.user_preferences.__file__).resolve().parent / "api.spec.yaml" - with open(spec_file) as f: - user_preferences_spec = safe_load(f) - spec_file = Path(renku_data_services.users.__file__).resolve().parent / "api.spec.yaml" with open(spec_file) as f: users = safe_load(f) @@ -220,7 +215,6 @@ def __post_init__(self) -> None: self.spec = merge_api_specs( crc_spec, storage_spec, - user_preferences_spec, users, projects, groups, diff --git a/components/renku_data_services/migrations/env.py b/components/renku_data_services/migrations/env.py index 6f6518f4d..3f360a946 100644 --- a/components/renku_data_services/migrations/env.py +++ b/components/renku_data_services/migrations/env.py @@ -13,7 +13,6 @@ from renku_data_services.secrets.orm import BaseORM as secrets from renku_data_services.session.orm import BaseORM as sessions from renku_data_services.storage.orm import BaseORM as storage -from renku_data_services.user_preferences.orm import BaseORM as user_preferences from renku_data_services.users.orm import BaseORM as users # Interpret the config file for Python logging. @@ -31,7 +30,6 @@ secrets.metadata, sessions.metadata, storage.metadata, - user_preferences.metadata, users.metadata, ] diff --git a/components/renku_data_services/migrations/versions/17eea03f938e_move_user_preferences_to_user_schema.py b/components/renku_data_services/migrations/versions/17eea03f938e_move_user_preferences_to_user_schema.py new file mode 100644 index 000000000..7324f082e --- /dev/null +++ b/components/renku_data_services/migrations/versions/17eea03f938e_move_user_preferences_to_user_schema.py @@ -0,0 +1,85 @@ +"""move user preferences to user schema + +Revision ID: 17eea03f938e +Revises: dcc1c1ee662f +Create Date: 2024-07-26 14:37:29.556827 + +""" + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = "17eea03f938e" +down_revision = "dcc1c1ee662f" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "user_preferences", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("user_id", sa.String(), nullable=False), + sa.Column( + "pinned_projects", + sa.JSON().with_variant(postgresql.JSONB(astext_type=sa.Text()), "postgresql"), + nullable=False, + ), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("user_id"), + schema="users", + ) + # ### end Alembic commands ### + conn = op.get_bind() + inspector = sa.inspect(conn) + if "user_preferences" in inspector.get_schema_names() and "user_preferences" in inspector.get_table_names( + schema="user_preferences" + ): + # migrate old user preferences + statement = sa.sql.text( + """ + INSERT INTO users.user_preferences + SELECT * + FROM user_preferences.user_preferences + """ + ) + conn.execute(statement) + + op.drop_table("user_preferences", schema="user_preferences") + op.execute("DROP SCHEMA user_preferences") + + +def downgrade() -> None: + conn = op.get_bind() + inspector = sa.inspect(conn) + if "user_preferences" not in inspector.get_schema_names(): + op.execute("CREATE SCHEMA user_preferences") + if "user_preferences" not in inspector.get_table_names(schema="user_preferences"): + op.create_table( + "user_preferences", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("user_id", sa.String(), nullable=False), + sa.Column( + "pinned_projects", + sa.JSON().with_variant(postgresql.JSONB(astext_type=sa.Text()), "postgresql"), + nullable=False, + ), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("user_id"), + schema="user_preferences", + ) + statement = sa.sql.text( + """ + INSERT INTO user_preferences.user_preferences + SELECT * + FROM users.user_preferences + """ + ) + conn.execute(statement) + + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table("user_preferences", schema="users") + # ### end Alembic commands ### diff --git a/components/renku_data_services/migrations/versions/6eccd7d4e3ed_add_user_preferences.py b/components/renku_data_services/migrations/versions/6eccd7d4e3ed_add_user_preferences.py index 07cd3ccc3..40ff7a75a 100644 --- a/components/renku_data_services/migrations/versions/6eccd7d4e3ed_add_user_preferences.py +++ b/components/renku_data_services/migrations/versions/6eccd7d4e3ed_add_user_preferences.py @@ -8,9 +8,7 @@ from collections.abc import Sequence -import sqlalchemy as sa from alembic import op -from sqlalchemy.dialects import postgresql # revision identifiers, used by Alembic. revision = "6eccd7d4e3ed" @@ -20,21 +18,10 @@ def upgrade() -> None: - # ### commands auto generated by Alembic - please adjust! ### - op.create_table( - "user_preferences", - sa.Column("id", sa.Integer(), nullable=False), - sa.Column("user_id", sa.String(), nullable=False), - sa.Column( - "pinned_projects", - sa.JSON().with_variant(postgresql.JSONB(astext_type=sa.Text()), "postgresql"), - nullable=False, - ), - sa.PrimaryKeyConstraint("id"), - sa.UniqueConstraint("user_id"), - schema="user_preferences", - ) - # ### end Alembic commands ### + # this migration in the past created a user_preferences table in the user_preferences schema + # this table is now in the `users` schema, so we don't want to create it here (it's done in a new migration) + # If this migration already ran in the past, it doesn't matter + pass def downgrade() -> None: diff --git a/components/renku_data_services/user_preferences/__init__.py b/components/renku_data_services/user_preferences/__init__.py deleted file mode 100644 index 715ffb5f9..000000000 --- a/components/renku_data_services/user_preferences/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Blueprints for user preferences.""" diff --git a/components/renku_data_services/user_preferences/api.spec.yaml b/components/renku_data_services/user_preferences/api.spec.yaml deleted file mode 100644 index 88ee3ff2f..000000000 --- a/components/renku_data_services/user_preferences/api.spec.yaml +++ /dev/null @@ -1,153 +0,0 @@ ---- -openapi: 3.0.2 -info: - title: Renku Data Services API - description: | - This service is the main backend for Renku. It provides information about users, projects, - cloud storage, access to compute resources and many other things. - version: v1 -servers: - - url: /api/data - - url: /ui-server/api/data -paths: - "/user/preferences": - get: - summary: Get user preferences for the currently logged in user - responses: - "200": - description: The user preferences - content: - "application/json": - schema: - $ref: "#/components/schemas/UserPreferences" - "404": - description: The user has no preferences saved - content: - "application/json": - schema: - $ref: "#/components/schemas/ErrorResponse" - default: - $ref: "#/components/responses/Error" - tags: - - user_preferences - "/user/preferences/pinned_projects": - post: - summary: Add a pinned project - requestBody: - required: true - content: - "application/json": - schema: - $ref: "#/components/schemas/AddPinnedProject" - responses: - "200": - description: The updated user preferences - content: - "application/json": - schema: - $ref: "#/components/schemas/UserPreferences" - default: - $ref: "#/components/responses/Error" - tags: - - user_preferences - delete: - summary: Remove one or all pinned projects - parameters: - - in: query - description: query parameters - name: delete_pinned_params - style: form - explode: true - schema: - type: object - additionalProperties: false - properties: - project_slug: - type: string - default: "" - responses: - "200": - description: The updated user preferences - content: - "application/json": - schema: - $ref: "#/components/schemas/UserPreferences" - default: - $ref: "#/components/responses/Error" - tags: - - user_preferences - -components: - schemas: - UserPreferences: - type: object - description: The object containing user preferences - additionalProperties: false - properties: - user_id: - $ref: "#/components/schemas/UserId" - pinned_projects: - $ref: "#/components/schemas/PinnedProjects" - required: ["user_id", "pinned_projects"] - UserId: - type: string - description: Keycloak user ID - example: f74a228b-1790-4276-af5f-25c2424e9b0c - pattern: "^[A-Za-z0-9]{1}[A-Za-z0-9-]+$" - PinnedProjects: - type: object - description: The list of projects a user has pinned on their dashboard - properties: - project_slugs: - type: array - items: - $ref: "#/components/schemas/ProjectSlug" - ProjectSlug: - type: string - description: The slug used to identify a project - minLength: 3 - example: "user/my-project" - # limitations based on allowed characters in project slugs from Gitlab from here: - # https://docs.gitlab.com/ee/user/reserved_names.html - pattern: "[a-zA-Z0-9_.-/]" - AddPinnedProject: - type: object - additionalProperties: false - properties: - project_slug: - $ref: "#/components/schemas/ProjectSlug" - required: ["project_slug"] - ErrorResponse: - type: object - properties: - error: - type: object - properties: - code: - type: integer - minimum: 0 - exclusiveMinimum: true - example: 1404 - detail: - type: string - example: "A more detailed optional message showing what the problem was" - message: - type: string - example: "Something went wrong - please try again later" - required: ["code", "message"] - required: ["error"] - - responses: - Error: - description: The schema for all 4xx and 5xx responses - content: - "application/json": - schema: - $ref: "#/components/schemas/ErrorResponse" - securitySchemes: - oidc: - type: openIdConnect - openIdConnectUrl: /auth/realms/Renku/.well-known/openid-configuration -security: - - oidc: - - openid diff --git a/components/renku_data_services/user_preferences/apispec.py b/components/renku_data_services/user_preferences/apispec.py deleted file mode 100644 index c13291421..000000000 --- a/components/renku_data_services/user_preferences/apispec.py +++ /dev/null @@ -1,73 +0,0 @@ -# generated by datamodel-codegen: -# filename: api.spec.yaml -# timestamp: 2024-08-06T05:55:32+00:00 - -from __future__ import annotations - -from typing import List, Optional - -from pydantic import ConfigDict, Field, RootModel -from renku_data_services.user_preferences.apispec_base import BaseAPISpec - - -class ProjectSlug(RootModel[str]): - root: str = Field( - ..., - description="The slug used to identify a project", - example="user/my-project", - min_length=3, - pattern="[a-zA-Z0-9_.-/]", - ) - - -class AddPinnedProject(BaseAPISpec): - model_config = ConfigDict( - extra="forbid", - ) - project_slug: str = Field( - ..., - description="The slug used to identify a project", - example="user/my-project", - min_length=3, - pattern="[a-zA-Z0-9_.-/]", - ) - - -class Error(BaseAPISpec): - code: int = Field(..., example=1404, gt=0) - detail: Optional[str] = Field( - None, example="A more detailed optional message showing what the problem was" - ) - message: str = Field(..., example="Something went wrong - please try again later") - - -class ErrorResponse(BaseAPISpec): - error: Error - - -class DeletePinnedParams(BaseAPISpec): - model_config = ConfigDict( - extra="forbid", - ) - project_slug: str = "" - - -class UserPreferencesPinnedProjectsDeleteParametersQuery(BaseAPISpec): - delete_pinned_params: Optional[DeletePinnedParams] = None - - -class PinnedProjects(BaseAPISpec): - project_slugs: Optional[List[ProjectSlug]] = None - - -class UserPreferences(BaseAPISpec): - model_config = ConfigDict( - extra="forbid", - ) - user_id: str = Field( - ..., - description="Keycloak user ID", - example="f74a228b-1790-4276-af5f-25c2424e9b0c", - pattern="^[A-Za-z0-9]{1}[A-Za-z0-9-]+$", - ) - pinned_projects: PinnedProjects diff --git a/components/renku_data_services/user_preferences/apispec_base.py b/components/renku_data_services/user_preferences/apispec_base.py deleted file mode 100644 index ee344ebf7..000000000 --- a/components/renku_data_services/user_preferences/apispec_base.py +++ /dev/null @@ -1,12 +0,0 @@ -"""Base models for API specifications.""" - -from pydantic import BaseModel - - -class BaseAPISpec(BaseModel): - """Base API specification.""" - - class Config: - """Enables orm mode for pydantic.""" - - from_attributes = True diff --git a/components/renku_data_services/user_preferences/blueprints.py b/components/renku_data_services/user_preferences/blueprints.py deleted file mode 100644 index 9abb9f06b..000000000 --- a/components/renku_data_services/user_preferences/blueprints.py +++ /dev/null @@ -1,57 +0,0 @@ -"""User preferences app.""" - -from dataclasses import dataclass - -from sanic import Request, json -from sanic.response import JSONResponse -from sanic_ext import validate - -import renku_data_services.base_models as base_models -from renku_data_services.base_api.auth import authenticate -from renku_data_services.base_api.blueprint import BlueprintFactoryResponse, CustomBlueprint -from renku_data_services.base_api.misc import validate_query -from renku_data_services.user_preferences import apispec, models -from renku_data_services.user_preferences.db import UserPreferencesRepository - - -@dataclass(kw_only=True) -class UserPreferencesBP(CustomBlueprint): - """Handlers for manipulating user preferences.""" - - user_preferences_repo: UserPreferencesRepository - authenticator: base_models.Authenticator - - def get(self) -> BlueprintFactoryResponse: - """Get user preferences for the logged in user.""" - - @authenticate(self.authenticator) - async def _get(_: Request, user: base_models.APIUser) -> JSONResponse: - user_preferences: models.UserPreferences | None - user_preferences = await self.user_preferences_repo.get_user_preferences(user=user) - return json(apispec.UserPreferences.model_validate(user_preferences).model_dump()) - - return "/user/preferences", ["GET"], _get - - def post_pinned_projects(self) -> BlueprintFactoryResponse: - """Add a pinned project to user preferences for the logged in user.""" - - @authenticate(self.authenticator) - @validate(json=apispec.AddPinnedProject) - async def _post(_: Request, user: base_models.APIUser, body: apispec.AddPinnedProject) -> JSONResponse: - res = await self.user_preferences_repo.add_pinned_project(user=user, project_slug=body.project_slug) - return json(apispec.UserPreferences.model_validate(res).model_dump()) - - return "/user/preferences/pinned_projects", ["POST"], _post - - def delete_pinned_projects(self) -> BlueprintFactoryResponse: - """Remove a pinned project from user preferences for the logged in user.""" - - @authenticate(self.authenticator) - @validate_query(query=apispec.DeletePinnedParams) - async def _delete( - request: Request, user: base_models.APIUser, query: apispec.DeletePinnedParams - ) -> JSONResponse: - res = await self.user_preferences_repo.remove_pinned_project(user=user, project_slug=query.project_slug) - return json(apispec.UserPreferences.model_validate(res).model_dump()) - - return "/user/preferences/pinned_projects", ["DELETE"], _delete diff --git a/components/renku_data_services/user_preferences/db.py b/components/renku_data_services/user_preferences/db.py deleted file mode 100644 index 886fd3297..000000000 --- a/components/renku_data_services/user_preferences/db.py +++ /dev/null @@ -1,132 +0,0 @@ -"""Adapters for user preferences database classes.""" - -from collections.abc import Callable -from typing import cast - -from sqlalchemy import select -from sqlalchemy.ext.asyncio import AsyncSession - -import renku_data_services.base_models as base_models -from renku_data_services import errors -from renku_data_services.user_preferences import models -from renku_data_services.user_preferences import orm as schemas -from renku_data_services.user_preferences.config import UserPreferencesConfig - - -class _Base: - """Base class for repositories.""" - - def __init__(self, session_maker: Callable[..., AsyncSession]) -> None: - self.session_maker = session_maker - - -class UserPreferencesRepository(_Base): - """Repository for user preferences.""" - - def __init__( - self, user_preferences_config: UserPreferencesConfig, session_maker: Callable[..., AsyncSession] - ) -> None: - super().__init__(session_maker) - self.user_preferences_config = user_preferences_config - - async def get_user_preferences( - self, - user: base_models.APIUser, - ) -> models.UserPreferences: - """Get user preferences from the database.""" - async with self.session_maker() as session: - if not user.is_authenticated: - raise errors.UnauthorizedError(message="Anonymous users cannot have user preferences.") - - res = await session.scalars( - select(schemas.UserPreferencesORM).where(schemas.UserPreferencesORM.user_id == user.id) - ) - user_preferences = res.one_or_none() - - if user_preferences is None: - raise errors.MissingResourceError(message="Preferences not found for user.", quiet=True) - return user_preferences.dump() - - async def delete_user_preferences(self, user: base_models.APIUser) -> None: - """Delete user preferences from the database.""" - async with self.session_maker() as session, session.begin(): - if not user.is_authenticated: - return - - res = await session.scalars( - select(schemas.UserPreferencesORM).where(schemas.UserPreferencesORM.user_id == user.id) - ) - user_preferences = res.one_or_none() - - if user_preferences is None: - return - - await session.delete(user_preferences) - - async def add_pinned_project(self, user: base_models.APIUser, project_slug: str) -> models.UserPreferences: - """Adds a new pinned project to the user's preferences.""" - async with self.session_maker() as session, session.begin(): - if not user.is_authenticated: - raise errors.UnauthorizedError(message="Anonymous users cannot have user preferences.") - - res = await session.scalars( - select(schemas.UserPreferencesORM).where(schemas.UserPreferencesORM.user_id == user.id) - ) - user_preferences = res.one_or_none() - - if user_preferences is None: - new_preferences = models.UserPreferences( - user_id=cast(str, user.id), pinned_projects=models.PinnedProjects(project_slugs=[project_slug]) - ) - user_preferences = schemas.UserPreferencesORM.load(new_preferences) - session.add(user_preferences) - return user_preferences.dump() - - project_slugs: list[str] - project_slugs = user_preferences.pinned_projects.get("project_slugs", []) - - # Do nothing if the project is already listed - for slug in project_slugs: - if project_slug.lower() == slug.lower(): - return user_preferences.dump() - - # Check if we have reached the maximum number of pins - if ( - self.user_preferences_config.max_pinned_projects > 0 - and len(project_slugs) >= self.user_preferences_config.max_pinned_projects - ): - raise errors.ValidationError( - message="Maximum number of pinned projects already allocated" - + f" (limit: {self.user_preferences_config.max_pinned_projects}, current: {len(project_slugs)})" - ) - - new_project_slugs = list(project_slugs) + [project_slug] - pinned_projects = models.PinnedProjects(project_slugs=new_project_slugs).model_dump() - user_preferences.pinned_projects = pinned_projects - return user_preferences.dump() - - async def remove_pinned_project(self, user: base_models.APIUser, project_slug: str) -> models.UserPreferences: - """Removes on or all pinned projects from the user's preferences.""" - async with self.session_maker() as session, session.begin(): - if not user.is_authenticated: - raise errors.UnauthorizedError(message="Anonymous users cannot have user preferences.") - - res = await session.scalars( - select(schemas.UserPreferencesORM).where(schemas.UserPreferencesORM.user_id == user.id) - ) - user_preferences = res.one_or_none() - - if user_preferences is None: - raise errors.MissingResourceError(message="Preferences not found for user.", quiet=True) - - project_slugs: list[str] - project_slugs = user_preferences.pinned_projects.get("project_slugs", []) - - # Remove all projects if `project_slug` is None - new_project_slugs = ( - [slug for slug in project_slugs if project_slug.lower() != slug.lower()] if project_slug else [] - ) - - pinned_projects = models.PinnedProjects(project_slugs=new_project_slugs).model_dump() - user_preferences.pinned_projects = pinned_projects - return user_preferences.dump() diff --git a/components/renku_data_services/user_preferences/models.py b/components/renku_data_services/user_preferences/models.py deleted file mode 100644 index aa0f12b94..000000000 --- a/components/renku_data_services/user_preferences/models.py +++ /dev/null @@ -1,23 +0,0 @@ -"""Models for user preferences.""" - -from typing import Optional - -from pydantic import BaseModel, Field - - -class PinnedProjects(BaseModel): - """Pinned projects model.""" - - project_slugs: Optional[list[str]] = None - - @classmethod - def from_dict(cls, data: dict) -> "PinnedProjects": - """Create model from a dict object.""" - return cls(project_slugs=data.get("project_slugs")) - - -class UserPreferences(BaseModel): - """User preferences model.""" - - user_id: str = Field(min_length=3) - pinned_projects: PinnedProjects diff --git a/components/renku_data_services/user_preferences/orm.py b/components/renku_data_services/user_preferences/orm.py deleted file mode 100644 index d88354ef0..000000000 --- a/components/renku_data_services/user_preferences/orm.py +++ /dev/null @@ -1,48 +0,0 @@ -"""SQLAlchemy schemas for the user preferences database.""" - -from typing import Any - -from sqlalchemy import JSON, Integer, MetaData, String -from sqlalchemy.dialects.postgresql import JSONB -from sqlalchemy.orm import DeclarativeBase, Mapped, MappedAsDataclass, mapped_column - -from renku_data_services.user_preferences import models - -JSONVariant = JSON().with_variant(JSONB(), "postgresql") - -metadata_obj = MetaData(schema="user_preferences") # Has to match alembic ini section name - - -class BaseORM(MappedAsDataclass, DeclarativeBase): - """Base class for all ORM classes.""" - - metadata = metadata_obj - - -class UserPreferencesORM(BaseORM): - """Stored user preferences.""" - - __tablename__ = "user_preferences" - - id: Mapped[int] = mapped_column("id", Integer, primary_key=True, default=None, init=False) - """Id of this user preferences object.""" - - user_id: Mapped[str] = mapped_column("user_id", String(), unique=True) - """Id of the user.""" - - pinned_projects: Mapped[dict[str, Any]] = mapped_column("pinned_projects", JSONVariant) - """Pinned projects.""" - - @classmethod - def load(cls, user_preferences: models.UserPreferences) -> "UserPreferencesORM": - """Create UserPreferencesORM from the user preferences model.""" - return cls( - user_id=user_preferences.user_id, - pinned_projects=user_preferences.pinned_projects.model_dump(), - ) - - def dump(self) -> models.UserPreferences: - """Create a user preferences model from the ORM object.""" - return models.UserPreferences( - user_id=self.user_id, pinned_projects=models.PinnedProjects.from_dict(self.pinned_projects) - ) diff --git a/components/renku_data_services/users/api.spec.yaml b/components/renku_data_services/users/api.spec.yaml index 8bfba522e..5669520d5 100644 --- a/components/renku_data_services/users/api.spec.yaml +++ b/components/renku_data_services/users/api.spec.yaml @@ -232,6 +232,73 @@ paths: schema: $ref: "#/components/schemas/Version" + "/user/preferences": + get: + summary: Get user preferences for the currently logged in user + responses: + "200": + description: The user preferences + content: + "application/json": + schema: + $ref: "#/components/schemas/UserPreferences" + "404": + description: The user has no preferences saved + content: + "application/json": + schema: + $ref: "#/components/schemas/ErrorResponse" + default: + $ref: "#/components/responses/Error" + tags: + - user_preferences + "/user/preferences/pinned_projects": + post: + summary: Add a pinned project + requestBody: + required: true + content: + "application/json": + schema: + $ref: "#/components/schemas/AddPinnedProject" + responses: + "200": + description: The updated user preferences + content: + "application/json": + schema: + $ref: "#/components/schemas/UserPreferences" + default: + $ref: "#/components/responses/Error" + tags: + - user_preferences + delete: + summary: Remove one or all pinned projects + parameters: + - in: query + description: query parameters + name: delete_pinned_params + style: form + explode: true + schema: + type: object + additionalProperties: false + properties: + project_slug: + type: string + default: "" + responses: + "200": + description: The updated user preferences + content: + "application/json": + schema: + $ref: "#/components/schemas/UserPreferences" + default: + $ref: "#/components/responses/Error" + tags: + - user_preferences + components: schemas: UserWithId: @@ -380,6 +447,39 @@ components: enum: - general - storage + UserPreferences: + type: object + description: The object containing user preferences + additionalProperties: false + properties: + user_id: + $ref: "#/components/schemas/UserId" + pinned_projects: + $ref: "#/components/schemas/PinnedProjects" + required: ["user_id", "pinned_projects"] + PinnedProjects: + type: object + description: The list of projects a user has pinned on their dashboard + properties: + project_slugs: + type: array + items: + $ref: "#/components/schemas/ProjectSlug" + ProjectSlug: + type: string + description: The slug used to identify a project + minLength: 3 + example: "user/my-project" + # limitations based on allowed characters in project slugs from Gitlab from here: + # https://docs.gitlab.com/ee/user/reserved_names.html + pattern: "[a-zA-Z0-9_.-/]" + AddPinnedProject: + type: object + additionalProperties: false + properties: + project_slug: + $ref: "#/components/schemas/ProjectSlug" + required: ["project_slug"] ErrorResponse: type: object properties: diff --git a/components/renku_data_services/users/apispec.py b/components/renku_data_services/users/apispec.py index 8469e54e7..23c4adfb1 100644 --- a/components/renku_data_services/users/apispec.py +++ b/components/renku_data_services/users/apispec.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: api.spec.yaml -# timestamp: 2024-08-06T05:55:30+00:00 +# timestamp: 2024-08-06T06:08:47+00:00 from __future__ import annotations @@ -28,6 +28,29 @@ class SecretKind(Enum): storage = "storage" +class ProjectSlug(RootModel[str]): + root: str = Field( + ..., + description="The slug used to identify a project", + example="user/my-project", + min_length=3, + pattern="[a-zA-Z0-9_.-/]", + ) + + +class AddPinnedProject(BaseAPISpec): + model_config = ConfigDict( + extra="forbid", + ) + project_slug: str = Field( + ..., + description="The slug used to identify a project", + example="user/my-project", + min_length=3, + pattern="[a-zA-Z0-9_.-/]", + ) + + class Error(BaseAPISpec): code: int = Field(..., example=1404, gt=0) detail: Optional[str] = Field( @@ -66,6 +89,17 @@ class UserSecretsGetParametersQuery(BaseAPISpec): user_secrets_params: Optional[UserSecretsParams] = None +class DeletePinnedParams(BaseAPISpec): + model_config = ConfigDict( + extra="forbid", + ) + project_slug: str = "" + + +class UserPreferencesPinnedProjectsDeleteParametersQuery(BaseAPISpec): + delete_pinned_params: Optional[DeletePinnedParams] = None + + class UserWithId(BaseAPISpec): model_config = ConfigDict( extra="forbid", @@ -166,5 +200,22 @@ class SecretPatch(BaseAPISpec): ) +class PinnedProjects(BaseAPISpec): + project_slugs: Optional[List[ProjectSlug]] = None + + class SecretsList(RootModel[List[SecretWithId]]): root: List[SecretWithId] = Field(..., description="A list of secrets", min_length=0) + + +class UserPreferences(BaseAPISpec): + model_config = ConfigDict( + extra="forbid", + ) + user_id: str = Field( + ..., + description="Keycloak user ID", + example="f74a228b-1790-4276-af5f-25c2424e9b0c", + pattern="^[A-Za-z0-9]{1}[A-Za-z0-9-]+$", + ) + pinned_projects: PinnedProjects diff --git a/components/renku_data_services/users/blueprints.py b/components/renku_data_services/users/blueprints.py index 5513d559e..4540ce4d1 100644 --- a/components/renku_data_services/users/blueprints.py +++ b/components/renku_data_services/users/blueprints.py @@ -15,23 +15,11 @@ from renku_data_services.errors import errors from renku_data_services.secrets.db import UserSecretsRepo from renku_data_services.secrets.models import Secret, SecretKind -from renku_data_services.users import apispec -from renku_data_services.users.apispec_base import BaseAPISpec -from renku_data_services.users.db import UserRepo +from renku_data_services.users import apispec, models +from renku_data_services.users.db import UserPreferencesRepository, UserRepo from renku_data_services.utils.cryptography import encrypt_rsa, encrypt_string, generate_random_encryption_key -class GetSecretsParams(BaseAPISpec): - """The schema for the query parameters used when getting all secrets.""" - - class Config: - """Configuration.""" - - extra = "forbid" - - kind: apispec.SecretKind = apispec.SecretKind.general - - @dataclass(kw_only=True) class KCUsersBP(CustomBlueprint): """Handlers for creating and listing users.""" @@ -249,3 +237,48 @@ async def _delete(_: Request, user: base_models.APIUser, secret_id: str) -> HTTP return HTTPResponse(status=204) return "/user/secrets/", ["DELETE"], _delete + + +@dataclass(kw_only=True) +class UserPreferencesBP(CustomBlueprint): + """Handlers for manipulating user preferences.""" + + user_preferences_repo: UserPreferencesRepository + authenticator: base_models.Authenticator + + def get(self) -> BlueprintFactoryResponse: + """Get user preferences for the logged in user.""" + + @authenticate(self.authenticator) + async def _get(_: Request, user: base_models.APIUser) -> JSONResponse: + user_preferences: models.UserPreferences + user_preferences = await self.user_preferences_repo.get_user_preferences(requested_by=user) + return json(apispec.UserPreferences.model_validate(user_preferences).model_dump()) + + return "/user/preferences", ["GET"], _get + + def post_pinned_projects(self) -> BlueprintFactoryResponse: + """Add a pinned project to user preferences for the logged in user.""" + + @authenticate(self.authenticator) + @validate(json=apispec.AddPinnedProject) + async def _post(_: Request, user: base_models.APIUser, body: apispec.AddPinnedProject) -> JSONResponse: + res = await self.user_preferences_repo.add_pinned_project(requested_by=user, project_slug=body.project_slug) + return json(apispec.UserPreferences.model_validate(res).model_dump()) + + return "/user/preferences/pinned_projects", ["POST"], _post + + def delete_pinned_projects(self) -> BlueprintFactoryResponse: + """Remove a pinned project from user preferences for the logged in user.""" + + @authenticate(self.authenticator) + @validate_query(query=apispec.DeletePinnedParams) + async def _delete( + request: Request, user: base_models.APIUser, query: apispec.DeletePinnedParams + ) -> JSONResponse: + res = await self.user_preferences_repo.remove_pinned_project( + requested_by=user, project_slug=query.project_slug + ) + return json(apispec.UserPreferences.model_validate(res).model_dump()) + + return "/user/preferences/pinned_projects", ["DELETE"], _delete diff --git a/components/renku_data_services/user_preferences/config.py b/components/renku_data_services/users/config.py similarity index 100% rename from components/renku_data_services/user_preferences/config.py rename to components/renku_data_services/users/config.py diff --git a/components/renku_data_services/users/db.py b/components/renku_data_services/users/db.py index c3844678a..5817c9bb7 100644 --- a/components/renku_data_services/users/db.py +++ b/components/renku_data_services/users/db.py @@ -4,7 +4,7 @@ from collections.abc import Callable from dataclasses import asdict, dataclass, field from datetime import datetime, timedelta -from typing import Any +from typing import Any, cast from sanic.log import logger from sqlalchemy import delete, func, select @@ -20,15 +20,18 @@ from renku_data_services.message_queue.interface import IMessageQueue from renku_data_services.message_queue.redis_queue import dispatch_message from renku_data_services.namespace.db import GroupRepository +from renku_data_services.users.config import UserPreferencesConfig from renku_data_services.users.kc_api import IKeycloakAPI from renku_data_services.users.models import ( KeycloakAdminEvent, + PinnedProjects, UserInfo, UserInfoUpdate, + UserPreferences, UserWithNamespace, UserWithNamespaceUpdate, ) -from renku_data_services.users.orm import LastKeycloakEventTimestamp, UserORM +from renku_data_services.users.orm import LastKeycloakEventTimestamp, UserORM, UserPreferencesORM from renku_data_services.utils.core import with_db_transaction from renku_data_services.utils.cryptography import decrypt_string, encrypt_string @@ -320,3 +323,97 @@ async def events_sync(self, kc_api: IKeycloakAPI) -> None: logger.info( f"Updated the latest sync event timestamp in the database: {current_sync_latest_utc_timestamp}" ) + + +@dataclass +class UserPreferencesRepository: + """Repository for user preferences.""" + + session_maker: Callable[..., AsyncSession] + user_preferences_config: UserPreferencesConfig + + @only_authenticated + async def get_user_preferences( + self, + requested_by: APIUser, + ) -> UserPreferences: + """Get user preferences from the database.""" + async with self.session_maker() as session: + res = await session.scalars(select(UserPreferencesORM).where(UserPreferencesORM.user_id == requested_by.id)) + user_preferences = res.one_or_none() + + if user_preferences is None: + raise errors.MissingResourceError(message="Preferences not found for user.", quiet=True) + return user_preferences.dump() + + @only_authenticated + async def delete_user_preferences(self, requested_by: APIUser) -> None: + """Delete user preferences from the database.""" + async with self.session_maker() as session, session.begin(): + res = await session.scalars(select(UserPreferencesORM).where(UserPreferencesORM.user_id == requested_by.id)) + user_preferences = res.one_or_none() + + if user_preferences is None: + return + + await session.delete(user_preferences) + + @only_authenticated + async def add_pinned_project(self, requested_by: APIUser, project_slug: str) -> UserPreferences: + """Adds a new pinned project to the user's preferences.""" + async with self.session_maker() as session, session.begin(): + res = await session.scalars(select(UserPreferencesORM).where(UserPreferencesORM.user_id == requested_by.id)) + user_preferences = res.one_or_none() + + if user_preferences is None: + new_preferences = UserPreferences( + user_id=cast(str, requested_by.id), pinned_projects=PinnedProjects(project_slugs=[project_slug]) + ) + user_preferences = UserPreferencesORM.load(new_preferences) + session.add(user_preferences) + return user_preferences.dump() + + project_slugs: list[str] + project_slugs = user_preferences.pinned_projects.get("project_slugs", []) + + # Do nothing if the project is already listed + for slug in project_slugs: + if project_slug.lower() == slug.lower(): + return user_preferences.dump() + + # Check if we have reached the maximum number of pins + if ( + self.user_preferences_config.max_pinned_projects > 0 + and len(project_slugs) >= self.user_preferences_config.max_pinned_projects + ): + raise errors.ValidationError( + message="Maximum number of pinned projects already allocated" + + f" (limit: {self.user_preferences_config.max_pinned_projects}, current: {len(project_slugs)})" + ) + + new_project_slugs = list(project_slugs) + [project_slug] + pinned_projects = PinnedProjects(project_slugs=new_project_slugs).model_dump() + user_preferences.pinned_projects = pinned_projects + return user_preferences.dump() + + @only_authenticated + async def remove_pinned_project(self, requested_by: APIUser, project_slug: str) -> UserPreferences: + """Removes on or all pinned projects from the user's preferences.""" + async with self.session_maker() as session, session.begin(): + res = await session.scalars(select(UserPreferencesORM).where(UserPreferencesORM.user_id == requested_by.id)) + user_preferences = res.one_or_none() + + if user_preferences is None: + raise errors.MissingResourceError(message="Preferences not found for user.", quiet=True) + + project_slugs: list[str] + project_slugs = user_preferences.pinned_projects.get("project_slugs", []) + + # Remove all projects if `project_slug` is None + new_project_slugs = ( + [slug for slug in project_slugs if project_slug.lower() != slug.lower()] if project_slug else [] + ) + + pinned_projects = PinnedProjects(project_slugs=new_project_slugs).model_dump() + user_preferences.pinned_projects = pinned_projects + return user_preferences.dump() diff --git a/components/renku_data_services/users/models.py b/components/renku_data_services/users/models.py index d799420f6..7752bd481 100644 --- a/components/renku_data_services/users/models.py +++ b/components/renku_data_services/users/models.py @@ -8,6 +8,7 @@ from enum import Enum from typing import Any, NamedTuple +from pydantic import BaseModel, Field from sanic.log import logger from renku_data_services.namespace.models import Namespace @@ -264,3 +265,21 @@ class UserWithNamespaceUpdate(NamedTuple): old: UserWithNamespace | None new: UserWithNamespace + + +class PinnedProjects(BaseModel): + """Pinned projects model.""" + + project_slugs: list[str] | None = None + + @classmethod + def from_dict(cls, data: dict) -> "PinnedProjects": + """Create model from a dict object.""" + return cls(project_slugs=data.get("project_slugs")) + + +class UserPreferences(BaseModel): + """User preferences model.""" + + user_id: str = Field(min_length=3) + pinned_projects: PinnedProjects diff --git a/components/renku_data_services/users/orm.py b/components/renku_data_services/users/orm.py index ee1c0f905..73f00a4d5 100644 --- a/components/renku_data_services/users/orm.py +++ b/components/renku_data_services/users/orm.py @@ -1,18 +1,21 @@ """SQLAlchemy schemas for the CRC database.""" from datetime import datetime -from typing import TYPE_CHECKING, Optional +from typing import TYPE_CHECKING, Any, Optional -from sqlalchemy import DateTime, LargeBinary, MetaData, String +from sqlalchemy import JSON, DateTime, Integer, LargeBinary, MetaData, String +from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.orm import DeclarativeBase, Mapped, MappedAsDataclass, mapped_column, relationship from renku_data_services.base_models import Slug from renku_data_services.base_orm.registry import COMMON_ORM_REGISTRY -from renku_data_services.users.models import UserInfo +from renku_data_services.users.models import PinnedProjects, UserInfo, UserPreferences if TYPE_CHECKING: from renku_data_services.namespace.orm import NamespaceORM +JSONVariant = JSON().with_variant(JSONB(), "postgresql") + class BaseORM(MappedAsDataclass, DeclarativeBase): """Base class for all ORM classes.""" @@ -78,3 +81,30 @@ class LastKeycloakEventTimestamp(BaseORM): __tablename__ = "last_keycloak_event_timestamp" id: Mapped[int] = mapped_column(primary_key=True, init=False) timestamp_utc: Mapped[datetime] = mapped_column(DateTime(timezone=False), default_factory=datetime.utcnow) + + +class UserPreferencesORM(BaseORM): + """Stored user preferences.""" + + __tablename__ = "user_preferences" + + id: Mapped[int] = mapped_column("id", Integer, primary_key=True, default=None, init=False) + """Id of this user preferences object.""" + + user_id: Mapped[str] = mapped_column("user_id", String(), unique=True) + """Id of the user.""" + + pinned_projects: Mapped[dict[str, Any]] = mapped_column("pinned_projects", JSONVariant) + """Pinned projects.""" + + @classmethod + def load(cls, user_preferences: UserPreferences) -> "UserPreferencesORM": + """Create UserPreferencesORM from the user preferences model.""" + return cls( + user_id=user_preferences.user_id, + pinned_projects=user_preferences.pinned_projects.model_dump(), + ) + + def dump(self) -> UserPreferences: + """Create a user preferences model from the ORM object.""" + return UserPreferences(user_id=self.user_id, pinned_projects=PinnedProjects.from_dict(self.pinned_projects)) diff --git a/projects/background_jobs/pyproject.toml b/projects/background_jobs/pyproject.toml index ae42aa5b9..2d94fc56e 100644 --- a/projects/background_jobs/pyproject.toml +++ b/projects/background_jobs/pyproject.toml @@ -18,7 +18,6 @@ packages = [ { include = "renku_data_services/message_queue", from = "../../components" }, { include = "renku_data_services/db_config", from = "../../components" }, { include = "renku_data_services/k8s", from = "../../components" }, - { include = "renku_data_services/user_preferences", from = "../../components" }, { include = "renku_data_services/crc", from = "../../components" }, { include = "renku_data_services/project", from = "../../components" }, { include = "renku_data_services/authz", from = "../../components" }, diff --git a/projects/renku_data_service/pyproject.toml b/projects/renku_data_service/pyproject.toml index 2b4d66753..932f6edd1 100644 --- a/projects/renku_data_service/pyproject.toml +++ b/projects/renku_data_service/pyproject.toml @@ -27,7 +27,6 @@ packages = [ { include = "renku_data_services/secrets", from = "../../components" }, { include = "renku_data_services/session", from = "../../components" }, { include = "renku_data_services/storage", from = "../../components" }, - { include = "renku_data_services/user_preferences", from = "../../components" }, { include = "renku_data_services/users", from = "../../components" }, { include = "renku_data_services/utils", from = "../../components" }, # Note: poetry poly does not detect the migrations as dependencies, but they are. Don't remove these! diff --git a/projects/secrets_storage/pyproject.toml b/projects/secrets_storage/pyproject.toml index 0c0d7608a..d59fda44d 100644 --- a/projects/secrets_storage/pyproject.toml +++ b/projects/secrets_storage/pyproject.toml @@ -29,7 +29,6 @@ packages = [ { include = "renku_data_services/secrets", from = "../../components" }, { include = "renku_data_services/session", from = "../../components" }, { include = "renku_data_services/storage", from = "../../components" }, - { include = "renku_data_services/user_preferences", from = "../../components" }, { include = "renku_data_services/users", from = "../../components" }, { include = "renku_data_services/utils", from = "../../components" }, ] diff --git a/pyproject.toml b/pyproject.toml index 48838f16a..99b7c0912 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,7 +19,6 @@ packages = [ { include = "renku_data_services/base_models", from = "components" }, { include = "renku_data_services/base_api", from = "components" }, { include = "renku_data_services/storage", from = "components" }, - { include = "renku_data_services/user_preferences", from = "components" }, { include = "renku_data_services/utils", from = "components" }, { include = "renku_data_services/git", from = "components" }, { include = "renku_data_services/users", from = "components" }, @@ -195,7 +194,6 @@ module = [ "renku_data_services.repositories.apispec", "renku_data_services.secrets.apispec", "renku_data_services.session.apispec", - "renku_data_services.user_preferences.apispec", "renku_data_services.users.apispec", "renku_data_services.data_api.error_handler", "renku_data_services.namespace.apispec", diff --git a/test/components/renku_data_services/db/test_sqlalchemy_user_preferences_repo.py b/test/components/renku_data_services/db/test_sqlalchemy_user_preferences_repo.py index f40355a91..66585af48 100644 --- a/test/components/renku_data_services/db/test_sqlalchemy_user_preferences_repo.py +++ b/test/components/renku_data_services/db/test_sqlalchemy_user_preferences_repo.py @@ -23,7 +23,7 @@ async def test_user_preferences_insert_get( try: await create_user_preferences(project_slug=project_slug, repo=user_preferences_repo, user=loggedin_user) finally: - await user_preferences_repo.delete_user_preferences(user=loggedin_user) + await user_preferences_repo.delete_user_preferences(requested_by=loggedin_user) @given(project_slugs=project_slugs_strat) @@ -38,15 +38,15 @@ async def test_user_preferences_add_pinned_project( project_slugs = project_slugs[: app_config.user_preferences_config.max_pinned_projects] try: for project_slug in project_slugs: - await user_preferences_repo.add_pinned_project(user=loggedin_user, project_slug=project_slug) + await user_preferences_repo.add_pinned_project(requested_by=loggedin_user, project_slug=project_slug) - res = await user_preferences_repo.get_user_preferences(user=loggedin_user) + res = await user_preferences_repo.get_user_preferences(requested_by=loggedin_user) assert res.user_id == loggedin_user.id assert res.pinned_projects.project_slugs is not None assert len(res.pinned_projects.project_slugs) == len(project_slugs) assert sorted(res.pinned_projects.project_slugs) == sorted(project_slugs) finally: - await user_preferences_repo.delete_user_preferences(user=loggedin_user) + await user_preferences_repo.delete_user_preferences(requested_by=loggedin_user) @given(project_slugs=project_slugs_strat) @@ -61,16 +61,16 @@ async def test_user_preferences_add_pinned_project_existing( project_slugs = project_slugs[: app_config.user_preferences_config.max_pinned_projects] try: for project_slug in project_slugs: - await user_preferences_repo.add_pinned_project(user=loggedin_user, project_slug=project_slug) - user_preferences_before = await user_preferences_repo.get_user_preferences(user=loggedin_user) + await user_preferences_repo.add_pinned_project(requested_by=loggedin_user, project_slug=project_slug) + user_preferences_before = await user_preferences_repo.get_user_preferences(requested_by=loggedin_user) - await user_preferences_repo.add_pinned_project(user=loggedin_user, project_slug=project_slugs[0]) + await user_preferences_repo.add_pinned_project(requested_by=loggedin_user, project_slug=project_slugs[0]) - user_preferences_after = await user_preferences_repo.get_user_preferences(user=loggedin_user) + user_preferences_after = await user_preferences_repo.get_user_preferences(requested_by=loggedin_user) assert user_preferences_after.user_id == loggedin_user.id assert user_preferences_before.model_dump_json() == user_preferences_after.model_dump_json() finally: - await user_preferences_repo.delete_user_preferences(user=loggedin_user) + await user_preferences_repo.delete_user_preferences(requested_by=loggedin_user) @given(project_slugs=project_slugs_strat) @@ -85,17 +85,17 @@ async def test_user_preferences_delete_pinned_project( project_slugs_valid = project_slugs[: app_config.user_preferences_config.max_pinned_projects] try: for project_slug in project_slugs_valid: - await user_preferences_repo.add_pinned_project(user=loggedin_user, project_slug=project_slug) + await user_preferences_repo.add_pinned_project(requested_by=loggedin_user, project_slug=project_slug) for project_slug in project_slugs: - await user_preferences_repo.remove_pinned_project(user=loggedin_user, project_slug=project_slug) + await user_preferences_repo.remove_pinned_project(requested_by=loggedin_user, project_slug=project_slug) - res = await user_preferences_repo.get_user_preferences(user=loggedin_user) + res = await user_preferences_repo.get_user_preferences(requested_by=loggedin_user) assert res.user_id == loggedin_user.id assert res.pinned_projects.project_slugs is not None assert len(res.pinned_projects.project_slugs) == 0 finally: - await user_preferences_repo.delete_user_preferences(user=loggedin_user) + await user_preferences_repo.delete_user_preferences(requested_by=loggedin_user) @given(project_slugs=project_slugs_strat) @@ -111,18 +111,18 @@ async def test_user_preferences_add_pinned_project_respects_maximum( project_slugs_invalid = project_slugs[app_config.user_preferences_config.max_pinned_projects :] try: for project_slug in project_slugs_valid: - await user_preferences_repo.add_pinned_project(user=loggedin_user, project_slug=project_slug) + await user_preferences_repo.add_pinned_project(requested_by=loggedin_user, project_slug=project_slug) for project_slug in project_slugs_invalid: with pytest.raises( errors.ValidationError, match=r"^ValidationError: Maximum number of pinned projects already allocated" ): - await user_preferences_repo.add_pinned_project(user=loggedin_user, project_slug=project_slug) + await user_preferences_repo.add_pinned_project(requested_by=loggedin_user, project_slug=project_slug) - res = await user_preferences_repo.get_user_preferences(user=loggedin_user) + res = await user_preferences_repo.get_user_preferences(requested_by=loggedin_user) assert res.user_id == loggedin_user.id assert res.pinned_projects.project_slugs is not None assert len(res.pinned_projects.project_slugs) == len(project_slugs_valid) assert sorted(res.pinned_projects.project_slugs) == sorted(project_slugs_valid) finally: - await user_preferences_repo.delete_user_preferences(user=loggedin_user) + await user_preferences_repo.delete_user_preferences(requested_by=loggedin_user) diff --git a/test/utils.py b/test/utils.py index 4941b3da4..dd767adf7 100644 --- a/test/utils.py +++ b/test/utils.py @@ -6,8 +6,8 @@ from renku_data_services.crc.db import ResourcePoolRepository from renku_data_services.storage import models as storage_models from renku_data_services.storage.db import StorageRepository -from renku_data_services.user_preferences import models as user_preferences_models -from renku_data_services.user_preferences.db import UserPreferencesRepository +from renku_data_services.users import models as user_preferences_models +from renku_data_services.users.db import UserPreferencesRepository def remove_id_from_quota(quota: rp_models.Quota) -> rp_models.Quota: @@ -79,7 +79,7 @@ async def create_user_preferences( project_slug: str, repo: UserPreferencesRepository, user: base_models.APIUser ) -> user_preferences_models.UserPreferences: """Create user preferencers by adding a pinned project""" - user_preferences = await repo.add_pinned_project(user=user, project_slug=project_slug) + user_preferences = await repo.add_pinned_project(requested_by=user, project_slug=project_slug) assert user_preferences is not None assert user_preferences.user_id is not None assert user_preferences.pinned_projects is not None