Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: allow project and data connector slugs to be changed #554

Draft
wants to merge 8 commits into
base: main
Choose a base branch
from
Draft
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
61 changes: 57 additions & 4 deletions components/renku_data_services/data_connectors/db.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"""Adapters for data connectors database classes."""

from collections.abc import AsyncIterator, Callable
from typing import TypeVar
from typing import TypeVar, cast

from cryptography.hazmat.primitives.asymmetric import rsa
from sqlalchemy import Select, delete, func, select
Expand Down Expand Up @@ -94,6 +94,42 @@ async def get_data_connector_by_slug(
stmt = stmt.where(ns_schemas.EntitySlugORM.slug == slug.lower())
result = await session.scalars(stmt)
data_connector = result.one_or_none()

if data_connector is None:
old_data_connector_stmt_old_ns_current_slug = (
select(schemas.DataConnectorORM.id)
.where(ns_schemas.NamespaceOldORM.slug == namespace.lower())
.where(ns_schemas.NamespaceOldORM.latest_slug_id == ns_schemas.NamespaceORM.id)
.where(ns_schemas.EntitySlugORM.namespace_id == ns_schemas.NamespaceORM.id)
.where(schemas.DataConnectorORM.id == ns_schemas.EntitySlugORM.data_connector_id)
.where(schemas.DataConnectorORM.slug.has(ns_schemas.EntitySlugORM.slug == slug.lower()))
)
old_data_connector_stmt_current_ns_old_slug = (
select(schemas.DataConnectorORM.id)
.where(ns_schemas.NamespaceORM.slug == namespace.lower())
.where(ns_schemas.EntitySlugORM.namespace_id == ns_schemas.NamespaceORM.id)
.where(schemas.DataConnectorORM.id == ns_schemas.EntitySlugORM.data_connector_id)
.where(ns_schemas.EntitySlugOldORM.slug == slug.lower())
.where(ns_schemas.EntitySlugOldORM.latest_slug_id == ns_schemas.EntitySlugORM.id)
)
old_data_connector_stmt_old_ns_old_slug = (
select(schemas.DataConnectorORM.id)
.where(ns_schemas.NamespaceOldORM.slug == namespace.lower())
.where(ns_schemas.NamespaceOldORM.latest_slug_id == ns_schemas.NamespaceORM.id)
.where(ns_schemas.EntitySlugORM.namespace_id == ns_schemas.NamespaceORM.id)
.where(schemas.DataConnectorORM.id == ns_schemas.EntitySlugORM.data_connector_id)
.where(ns_schemas.EntitySlugOldORM.slug == slug.lower())
.where(ns_schemas.EntitySlugOldORM.latest_slug_id == ns_schemas.EntitySlugORM.id)
)
old_data_connector_stmt = old_data_connector_stmt_old_ns_current_slug.union(
old_data_connector_stmt_current_ns_old_slug, old_data_connector_stmt_old_ns_old_slug
)
result_old = await session.scalars(old_data_connector_stmt)
result_old_id = cast(ULID | None, result_old.first())
if result_old_id is not None:
stmt = select(schemas.DataConnectorORM).where(schemas.DataConnectorORM.id == result_old_id)
data_connector = (await session.scalars(stmt)).first()

if data_connector is None:
raise errors.MissingResourceError(message=not_found_msg)

Expand Down Expand Up @@ -211,17 +247,19 @@ async def update_data_connector(
if patch.namespace is not None and patch.namespace != old_data_connector.namespace.slug:
# NOTE: changing the namespace requires the user to be owner which means they should have DELETE permission # noqa E501
required_scope = Scope.DELETE
if patch.slug is not None and patch.slug != old_data_connector.slug:
# NOTE: changing the slug requires the user to be owner which means they should have DELETE permission
required_scope = Scope.DELETE
authorized = await self.authz.has_permission(
user, ResourceType.data_connector, data_connector_id, required_scope
)
if not authorized:
raise errors.MissingResourceError(message=not_found_msg)

current_etag = data_connector.dump().etag
current_etag = old_data_connector.etag
if current_etag != etag:
raise errors.ConflictError(message=f"Current ETag is {current_etag}, not {etag}.")

# TODO: handle slug update
if patch.name is not None:
data_connector.name = patch.name
if patch.visibility is not None:
Expand All @@ -231,7 +269,7 @@ async def update_data_connector(
else apispec.Visibility(patch.visibility.value)
)
data_connector.visibility = visibility_orm
if patch.namespace is not None:
if patch.namespace is not None and patch.namespace != old_data_connector.namespace.slug:
ns = await session.scalar(
select(ns_schemas.NamespaceORM).where(ns_schemas.NamespaceORM.slug == patch.namespace.lower())
)
Expand All @@ -248,6 +286,21 @@ async def update_data_connector(
message=f"The data connector cannot be moved because you do not have sufficient permissions with the namespace {patch.namespace}." # noqa: E501
)
data_connector.slug.namespace_id = ns.id
if patch.slug is not None and patch.slug != old_data_connector.slug:
namespace_id = data_connector.slug.namespace_id
existing_entity = await session.scalar(
select(ns_schemas.EntitySlugORM)
.where(ns_schemas.EntitySlugORM.slug == patch.slug)
.where(ns_schemas.EntitySlugORM.namespace_id == namespace_id)
)
if existing_entity is not None:
raise errors.ConflictError(
message=f"An entity with the slug '{data_connector.slug.namespace.slug}/{patch.slug}' already exists." # noqa E501
)
session.add(
ns_schemas.EntitySlugOldORM(slug=old_data_connector.slug, latest_slug_id=data_connector.slug.id)
)
data_connector.slug.slug = patch.slug
if patch.description is not None:
data_connector.description = patch.description if patch.description else None
if patch.keywords is not None:
Expand Down
3 changes: 1 addition & 2 deletions components/renku_data_services/namespace/orm.py
Original file line number Diff line number Diff line change
Expand Up @@ -276,7 +276,6 @@ class EntitySlugOldORM(BaseORM):
latest_slug_id: Mapped[int] = mapped_column(
ForeignKey(EntitySlugORM.id, ondelete="CASCADE"),
nullable=False,
init=False,
index=True,
)
latest_slug: Mapped[EntitySlugORM] = relationship(lazy="joined", repr=False, viewonly=True)
latest_slug: Mapped[EntitySlugORM] = relationship(lazy="joined", init=False, repr=False, viewonly=True)
2 changes: 2 additions & 0 deletions components/renku_data_services/project/api.spec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -487,6 +487,8 @@ components:
$ref: "#/components/schemas/ProjectName"
namespace:
$ref: "#/components/schemas/Slug"
slug:
$ref: "#/components/schemas/Slug"
repositories:
$ref: "#/components/schemas/RepositoriesList"
visibility:
Expand Down
10 changes: 9 additions & 1 deletion components/renku_data_services/project/apispec.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# generated by datamodel-codegen:
# filename: api.spec.yaml
# timestamp: 2024-11-25T14:22:47+00:00
# timestamp: 2024-11-29T14:11:33+00:00

from __future__ import annotations

Expand Down Expand Up @@ -345,6 +345,14 @@ class ProjectPatch(BaseAPISpec):
min_length=1,
pattern="^(?!.*\\.git$|.*\\.atom$|.*[\\-._][\\-._].*)[a-z0-9][a-z0-9\\-_.]*$",
)
slug: Optional[str] = Field(
None,
description="A command-line/url friendly name for a namespace",
example="a-slug-example",
max_length=99,
min_length=1,
pattern="^(?!.*\\.git$|.*\\.atom$|.*[\\-._][\\-._].*)[a-z0-9][a-z0-9\\-_.]*$",
)
repositories: Optional[List[str]] = Field(
None,
description="A list of repositories",
Expand Down
1 change: 1 addition & 0 deletions components/renku_data_services/project/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ def validate_project_patch(patch: apispec.ProjectPatch) -> models.ProjectPatch:
return models.ProjectPatch(
name=patch.name,
namespace=patch.namespace,
slug=patch.slug,
visibility=Visibility(patch.visibility.value) if patch.visibility is not None else None,
repositories=patch.repositories,
description=patch.description,
Expand Down
61 changes: 57 additions & 4 deletions components/renku_data_services/project/db.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
import functools
from collections.abc import AsyncGenerator, Awaitable, Callable
from datetime import UTC, datetime
from typing import Concatenate, ParamSpec, TypeVar
from typing import Concatenate, ParamSpec, TypeVar, cast

from sqlalchemy import Select, delete, func, select, update
from sqlalchemy.ext.asyncio import AsyncSession
Expand Down Expand Up @@ -147,8 +147,45 @@ async def get_project_by_namespace_slug(
stmt = stmt.where(schemas.ProjectORM.slug.has(ns_schemas.EntitySlugORM.slug == slug))
if with_documentation:
stmt = stmt.options(undefer(schemas.ProjectORM.documentation))
result = await session.execute(stmt)
project_orm = result.scalars().first()
result = await session.scalars(stmt)
project_orm = result.first()

if project_orm is None:
old_project_stmt_old_ns_current_slug = (
select(schemas.ProjectORM.id)
.where(ns_schemas.NamespaceOldORM.slug == namespace.lower())
.where(ns_schemas.NamespaceOldORM.latest_slug_id == ns_schemas.NamespaceORM.id)
.where(ns_schemas.EntitySlugORM.namespace_id == ns_schemas.NamespaceORM.id)
.where(schemas.ProjectORM.id == ns_schemas.EntitySlugORM.project_id)
.where(schemas.ProjectORM.slug.has(ns_schemas.EntitySlugORM.slug == slug))
)
old_project_stmt_current_ns_old_slug = (
select(schemas.ProjectORM.id)
.where(ns_schemas.NamespaceORM.slug == namespace.lower())
.where(ns_schemas.EntitySlugORM.namespace_id == ns_schemas.NamespaceORM.id)
.where(schemas.ProjectORM.id == ns_schemas.EntitySlugORM.project_id)
.where(ns_schemas.EntitySlugOldORM.slug == slug)
.where(ns_schemas.EntitySlugOldORM.latest_slug_id == ns_schemas.EntitySlugORM.id)
)
old_project_stmt_old_ns_old_slug = (
select(schemas.ProjectORM.id)
.where(ns_schemas.NamespaceOldORM.slug == namespace.lower())
.where(ns_schemas.NamespaceOldORM.latest_slug_id == ns_schemas.NamespaceORM.id)
.where(ns_schemas.EntitySlugORM.namespace_id == ns_schemas.NamespaceORM.id)
.where(schemas.ProjectORM.id == ns_schemas.EntitySlugORM.project_id)
.where(ns_schemas.EntitySlugOldORM.slug == slug)
.where(ns_schemas.EntitySlugOldORM.latest_slug_id == ns_schemas.EntitySlugORM.id)
)
old_project_stmt = old_project_stmt_old_ns_current_slug.union(
old_project_stmt_current_ns_old_slug, old_project_stmt_old_ns_old_slug
)
result_old = await session.scalars(old_project_stmt)
result_old_id = cast(ULID | None, result_old.first())
if result_old_id is not None:
stmt = select(schemas.ProjectORM).where(schemas.ProjectORM.id == result_old_id)
if with_documentation:
stmt = stmt.options(undefer(schemas.ProjectORM.documentation))
project_orm = (await session.scalars(stmt)).first()

not_found_msg = (
f"Project with identifier '{namespace}/{slug}' does not exist or you do not have access to it."
Expand Down Expand Up @@ -266,13 +303,16 @@ async def update_project(
if patch.namespace is not None and patch.namespace != old_project.namespace.slug:
# NOTE: changing the namespace requires the user to be owner which means they should have DELETE permission
required_scope = Scope.DELETE
if patch.slug is not None and patch.slug != old_project.slug:
# NOTE: changing the slug requires the user to be owner which means they should have DELETE permission
required_scope = Scope.DELETE
authorized = await self.authz.has_permission(user, ResourceType.project, project_id, required_scope)
if not authorized:
raise errors.MissingResourceError(
message=f"Project with id '{project_id}' does not exist or you do not have access to it."
)

current_etag = project.dump().etag
current_etag = old_project.etag
if etag is not None and current_etag != etag:
raise errors.ConflictError(message=f"Current ETag is {current_etag}, not {etag}.")

Expand All @@ -295,6 +335,19 @@ async def update_project(
message=f"The project cannot be moved because you do not have sufficient permissions with the namespace {patch.namespace}" # noqa: E501
)
project.slug.namespace_id = ns.id
if patch.slug is not None and patch.slug != old_project.slug:
namespace_id = project.slug.namespace_id
existing_entity = await session.scalar(
select(ns_schemas.EntitySlugORM)
.where(ns_schemas.EntitySlugORM.slug == patch.slug)
.where(ns_schemas.EntitySlugORM.namespace_id == namespace_id)
)
if existing_entity is not None:
raise errors.ConflictError(
message=f"An entity with the slug '{project.slug.namespace.slug}/{patch.slug}' already exists."
)
session.add(ns_schemas.EntitySlugOldORM(slug=old_project.slug, latest_slug_id=project.slug.id))
project.slug.slug = patch.slug
if patch.visibility is not None:
visibility_orm = (
project_apispec.Visibility(patch.visibility)
Expand Down
1 change: 1 addition & 0 deletions components/renku_data_services/project/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ class ProjectPatch:

name: str | None
namespace: str | None
slug: str | None
visibility: Visibility | None
repositories: list[Repository] | None
description: str | None
Expand Down
50 changes: 50 additions & 0 deletions test/bases/renku_data_services/data_api/test_data_connectors.py
Original file line number Diff line number Diff line change
Expand Up @@ -639,6 +639,56 @@ async def test_patch_data_connector_as_editor(
assert response.json.get("description") == "A new description"


@pytest.mark.asyncio
async def test_patch_data_connector_slug(
sanic_client: SanicASGITestClient,
create_data_connector,
user_headers,
) -> None:
await create_data_connector("Data connector 1")
await create_data_connector("Data connector 2")
data_connector = await create_data_connector("My data connector")
data_connector_id = data_connector["id"]
namespace = data_connector["namespace"]
old_slug = data_connector["slug"]
await create_data_connector("Data connector 3")

# Patch a data connector
headers = merge_headers(user_headers, {"If-Match": data_connector["etag"]})
new_slug = "some-updated-slug"
patch = {"slug": new_slug}
_, response = await sanic_client.patch(
f"/api/data/data_connectors/{data_connector_id}", headers=headers, json=patch
)

assert response.status_code == 200, response.text

# Check that the data connector's slug has been updated
_, response = await sanic_client.get(f"/api/data/data_connectors/{data_connector_id}", headers=user_headers)
assert response.status_code == 200, response.text
data_connector = response.json
assert data_connector["id"] == data_connector_id
assert data_connector["name"] == "My data connector"
assert data_connector["namespace"] == namespace
assert data_connector["slug"] == new_slug

# Check that we can get the data connector with the new slug
_, response = await sanic_client.get(
f"/api/data/namespaces/{namespace}/data_connectors/{new_slug}", headers=user_headers
)
assert response.status_code == 200, response.text
assert response.json is not None
assert response.json.get("id") == data_connector_id

# Check that we can get the data connector with the old slug
_, response = await sanic_client.get(
f"/api/data/namespaces/{namespace}/data_connectors/{old_slug}", headers=user_headers
)
assert response.status_code == 200, response.text
assert response.json is not None
assert response.json.get("id") == data_connector_id


@pytest.mark.asyncio
async def test_delete_data_connector(sanic_client: SanicASGITestClient, create_data_connector, user_headers) -> None:
await create_data_connector("Data connector 1")
Expand Down
Loading
Loading