From 8056245a50e1f3373d5b5e9c21b940f47e4e7b3a Mon Sep 17 00:00:00 2001 From: Flora Thiebaut Date: Mon, 6 Jan 2025 14:27:36 +0000 Subject: [PATCH] fix: update project ETag when the namespace slug is changed --- .../data_connectors/models.py | 2 +- .../renku_data_services/platform/models.py | 2 +- components/renku_data_services/project/db.py | 4 ++++ .../renku_data_services/project/models.py | 19 +++++++-------- components/renku_data_services/utils/etag.py | 23 ++++++++++++++++--- 5 files changed, 36 insertions(+), 14 deletions(-) diff --git a/components/renku_data_services/data_connectors/models.py b/components/renku_data_services/data_connectors/models.py index 498e664b4..dc95d92f9 100644 --- a/components/renku_data_services/data_connectors/models.py +++ b/components/renku_data_services/data_connectors/models.py @@ -51,7 +51,7 @@ class DataConnector(BaseDataConnector): @property def etag(self) -> str: """Entity tag value for this data connector object.""" - return compute_etag_from_timestamp(self.updated_at, include_quotes=True) + return compute_etag_from_timestamp(self.updated_at) @dataclass(frozen=True, eq=True, kw_only=True) diff --git a/components/renku_data_services/platform/models.py b/components/renku_data_services/platform/models.py index 04a31800b..59a933460 100644 --- a/components/renku_data_services/platform/models.py +++ b/components/renku_data_services/platform/models.py @@ -25,7 +25,7 @@ class PlatformConfig: @property def etag(self) -> str: """Entity tag value for this project object.""" - return compute_etag_from_timestamp(self.updated_at, include_quotes=True) + return compute_etag_from_timestamp(self.updated_at) @dataclass(frozen=True, eq=True, kw_only=True) diff --git a/components/renku_data_services/project/db.py b/components/renku_data_services/project/db.py index 5ea6529a1..b856a3e91 100644 --- a/components/renku_data_services/project/db.py +++ b/components/renku_data_services/project/db.py @@ -345,6 +345,8 @@ 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 + # Trigger update for ``updated_at`` column + await session.execute(update(schemas.ProjectORM).where(schemas.ProjectORM.id == project_id).values()) if patch.slug is not None and patch.slug != old_project.slug: namespace_id = project.slug.namespace_id existing_entity = await session.scalar( @@ -358,6 +360,8 @@ async def update_project( ) session.add(ns_schemas.EntitySlugOldORM(slug=old_project.slug, latest_slug_id=project.slug.id)) project.slug.slug = patch.slug + # Trigger update for ``updated_at`` column + await session.execute(update(schemas.ProjectORM).where(schemas.ProjectORM.id == project_id).values()) if patch.visibility is not None: visibility_orm = ( project_apispec.Visibility(patch.visibility) diff --git a/components/renku_data_services/project/models.py b/components/renku_data_services/project/models.py index 73e0f9366..3b671293c 100644 --- a/components/renku_data_services/project/models.py +++ b/components/renku_data_services/project/models.py @@ -10,7 +10,7 @@ from renku_data_services.authz.models import Visibility from renku_data_services.base_models import ResetType from renku_data_services.namespace.models import Namespace -from renku_data_services.utils.etag import compute_etag_from_timestamp +from renku_data_services.utils.etag import compute_etag_from_fields, compute_etag_from_timestamp Repository = str @@ -33,13 +33,6 @@ class BaseProject: is_template: bool = False secrets_mount_directory: PurePosixPath | None = None - @property - def etag(self) -> str | None: - """Entity tag value for this project object.""" - if self.updated_at is None: - return None - return compute_etag_from_timestamp(self.updated_at) - @dataclass(frozen=True, eq=True, kw_only=True) class Project(BaseProject): @@ -49,6 +42,14 @@ class Project(BaseProject): namespace: Namespace secrets_mount_directory: PurePosixPath + @property + def etag(self) -> str | None: + """Entity tag value for this project object.""" + if self.updated_at is None: + return None + # NOTE: `slug` is the only field from `self.namespace` which is serialized in API responses. + return compute_etag_from_fields(self.updated_at, self.namespace.slug) + @dataclass(frozen=True, eq=True, kw_only=True) class UnsavedProject(BaseProject): @@ -120,7 +121,7 @@ class SessionSecretSlot(UnsavedSessionSecretSlot): @property def etag(self) -> str: """Entity tag value for this session secret slot object.""" - return compute_etag_from_timestamp(self.updated_at, include_quotes=True) + return compute_etag_from_timestamp(self.updated_at) @dataclass(frozen=True, eq=True, kw_only=True) diff --git a/components/renku_data_services/utils/etag.py b/components/renku_data_services/utils/etag.py index 11521cae6..f61b22810 100644 --- a/components/renku_data_services/utils/etag.py +++ b/components/renku_data_services/utils/etag.py @@ -2,11 +2,28 @@ from datetime import datetime from hashlib import md5 +from typing import Any -def compute_etag_from_timestamp(updated_at: datetime, include_quotes: bool = False) -> str: +def compute_etag_from_timestamp(updated_at: datetime) -> str: """Computes an entity tag value by hashing the updated_at value.""" etag = md5(updated_at.isoformat().encode(), usedforsecurity=False).hexdigest().upper() - if not include_quotes: - return etag return f'"{etag}"' + + +def compute_etag_from_fields(updated_at: datetime, *args: Any) -> str: + """Computes an entity tag value by hashing the field values. + + By convention, the first field should be `updated_at`. + """ + values: list[Any] = [updated_at] + values.extend(arg for arg in args) + to_hash = "-".join(_get_hashable_string(value) for value in values) + etag = md5(to_hash.encode(), usedforsecurity=False).hexdigest().upper() + return f'"{etag}"' + + +def _get_hashable_string(value: Any) -> str: + if isinstance(value, datetime): + return value.isoformat() + return f"{value}"