Skip to content
Open
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
5 changes: 5 additions & 0 deletions HISTORY.rst
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,11 @@ History
* ``list_resources`` takes ``ordering`` and ``descending``, and each type's resources come back in that order.
Each listed ``Resource`` has a ``position``, its place in that order across every type.
Both require server version 3.26.21.
* Added ``update_project``, which takes a ``ProjectUpdateRequest`` to rename a project or change its description,
and returns the updated ``Project``.
Only admin users can update projects, and API tokens cannot.
* ``get_project`` sets ``Project.has_git_synced_resources``, which says whether the project holds a git-synced resource.
After a rename, a git-synced resource's next push writes a new copy under the new name, and the old copy stays.

1.6.0 (unreleased)
------------------
Expand Down
2 changes: 2 additions & 0 deletions datamasque/client/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@
ProjectDeleteConflictReason,
ProjectId,
ProjectIdentifier,
ProjectUpdateRequest,
)
from datamasque.client.models.resource import (
ConnectionResource,
Expand Down Expand Up @@ -299,6 +300,7 @@
"ProjectDeleteConflictReason",
"ProjectId",
"ProjectIdentifier",
"ProjectUpdateRequest",
"RGConfig",
"RGConfigId",
"RGConfigNotFoundError",
Expand Down
24 changes: 24 additions & 0 deletions datamasque/client/models/project.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,16 @@ class Project(BaseModel):
description: str = ""
created: datetime
updated: datetime
has_git_synced_resources: Optional[bool] = None
"""
Whether any library, discovery config library, ruleset or discovery config in the project
that is not deleted is synced with git.

A resource edited since its last push is not counted.
Only `get_project` sets it; other calls leave it `None`.
After a rename, a git-synced resource's next push writes a new copy under the new name and the old copy stays.
DataMasque does not move files in the repository.
"""


# A project, given as a `Project`, its `ProjectId`, or its `<id>-<slug>` form such as a listed resource's `project`.
Expand Down Expand Up @@ -80,6 +90,20 @@ class ProjectCreateRequest(BaseModel):
description: str = ""


class ProjectUpdateRequest(BaseModel):
"""
Request body for `PATCH /api/projects/{id}/`.

A field left as `None` is not sent, so the server keeps its current value.
A new `name` follows the rules of `ProjectCreateRequest`.
"""

model_config = ConfigDict(extra="forbid")

name: Optional[str] = None
description: Optional[str] = None


class ProjectDeleteConflictReason(str, Enum):
"""Why a project's resources cannot move to the destination project when the project is deleted."""

Expand Down
24 changes: 24 additions & 0 deletions datamasque/client/projects.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
ProjectCreateRequest,
ProjectDeleteConflict,
ProjectIdentifier,
ProjectUpdateRequest,
unwrap_project_id,
)

Expand Down Expand Up @@ -66,6 +67,29 @@ def create_project(self, request: ProjectCreateRequest) -> Project:
logger.info('Creation of project "%s" successful', project.name)
return project

def update_project(self, project: ProjectIdentifier, request: ProjectUpdateRequest) -> Project:
"""
Renames a project or changes its description, and returns the updated project.

Only admin users can, and API tokens cannot.
Renaming invalidates rulesets, discovery configs and generator configs that import a library of the project
by the old project name. Imports without a project name stay valid.
It also moves the project's seed and credential files, keeping connections working.
Seed and table references in rulesets that name the old project are not rewritten.

Raises:
DataMasqueApiError: the server rejects the name (see `ProjectCreateRequest`),
a file already exists where one of the project's files would move,
the project does not exist or is deleted, or any other non-2xx response.
"""

project_id = unwrap_project_id(project)
data = request.model_dump(mode="json", exclude_none=True)
response = self.make_request("PATCH", f"/api/projects/{project_id}/", data=data)
updated_project = Project.model_validate(response.json())
logger.info("Update of project %s successful", project_id)
return updated_project

def get_project_delete_conflicts(
self,
project: ProjectIdentifier,
Expand Down
106 changes: 105 additions & 1 deletion tests/test_projects.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"""Tests for project support in the DataMasque client."""

from datetime import datetime
from typing import Callable
from typing import Callable, Optional

import pytest
import requests_mock
Expand All @@ -17,6 +17,7 @@
ProjectDeleteConflictReason,
ProjectId,
ProjectIdentifier,
ProjectUpdateRequest,
)
from datamasque.client.exceptions import DataMasqueException
from datamasque.client.models.connection import ConnectionId, S3ConnectionConfig
Expand Down Expand Up @@ -49,6 +50,28 @@
"updated": "2026-10-01T10:00:00Z",
}

FILES_EXIST_MESSAGE = 'Cannot rename the project, because files already exist at "seeds/retail".'

RENAMED_PROJECT = {**BANKING_PROJECT, "reference": "7-retail", "name": "Retail"}

UPDATE_PROJECT_CASES = [
pytest.param(ProjectId(7), ProjectUpdateRequest(name="Retail"), {"name": "Retail"}, id="rename-by-id"),
pytest.param("7-banking", ProjectUpdateRequest(name="Retail"), {"name": "Retail"}, id="rename-by-reference"),
pytest.param(
Project.model_validate(BANKING_PROJECT),
ProjectUpdateRequest(description=""),
{"description": ""},
id="project-object-clears-description",
),
pytest.param(
ProjectId(7),
ProjectUpdateRequest(name="Banking", description="Customer banking data"),
{"name": "Banking", "description": "Customer banking data"},
id="name-and-description",
),
]


CONFLICTS = [
{
"reason": "name_in_use",
Expand Down Expand Up @@ -432,3 +455,84 @@ def test_unwrap_project_id_reads_the_id_from_each_form(project: ProjectIdentifie
def test_unwrap_project_id_rejects_a_string_that_does_not_start_with_an_id(project: str) -> None:
with pytest.raises(ValueError, match=f'"{project}" is not a project ID or reference'):
unwrap_project_id(project)


def test_update_request_rejects_unknown_fields() -> None:
with pytest.raises(ValidationError) as exc_info:
# mypy rejects the unknown field, which the test passes on purpose.
ProjectUpdateRequest(name="Banking", archived=True) # type: ignore[call-arg]

assert [(error["loc"], error["msg"]) for error in exc_info.value.errors()] == [
(("archived",), "Extra inputs are not permitted")
]


@pytest.mark.parametrize(("project", "request_body", "expected_body"), UPDATE_PROJECT_CASES)
def test_update_project_sends_only_the_fields_set(
client: DataMasqueClient,
project: ProjectIdentifier,
request_body: ProjectUpdateRequest,
expected_body: dict[str, object],
) -> None:
with requests_mock.Mocker() as m:
m.patch("http://test-server/api/projects/7/", json=BANKING_PROJECT)
client.update_project(project, request_body)

assert m.last_request.json() == expected_body


def test_update_project_returns_the_renamed_project(client: DataMasqueClient) -> None:
with requests_mock.Mocker() as m:
m.patch("http://test-server/api/projects/7/", json=RENAMED_PROJECT)
project = client.update_project(ProjectId(7), ProjectUpdateRequest(name="Retail"))

assert project.name == "Retail"
assert project.reference == "7-retail"


def test_update_project_rejected_name_raises(client: DataMasqueClient) -> None:
with requests_mock.Mocker() as m:
m.patch(
"http://test-server/api/projects/7/",
json={"name": [FILES_EXIST_MESSAGE]},
status_code=400,
)
with pytest.raises(DataMasqueApiError) as exc_info:
client.update_project(ProjectId(7), ProjectUpdateRequest(name="Retail"))

assert exc_info.value.response.status_code == 400
assert exc_info.value.response.json() == {"name": [FILES_EXIST_MESSAGE]}


@pytest.mark.parametrize(
("status_code", "detail"),
[
(403, "You do not have permission to perform this action."),
(404, "Not found."),
],
)
def test_update_project_refused_raises(client: DataMasqueClient, status_code: int, detail: str) -> None:
with requests_mock.Mocker() as m:
m.patch("http://test-server/api/projects/7/", json={"detail": detail}, status_code=status_code)
with pytest.raises(DataMasqueApiError) as exc_info:
client.update_project(ProjectId(7), ProjectUpdateRequest(name="Retail"))

assert exc_info.value.response.status_code == status_code


@pytest.mark.parametrize(
("body", "expected"),
[
pytest.param({**BANKING_PROJECT, "has_git_synced_resources": True}, True, id="synced"),
pytest.param({**BANKING_PROJECT, "has_git_synced_resources": False}, False, id="not-synced"),
pytest.param(BANKING_PROJECT, None, id="flag-absent"),
],
)
def test_get_project_reports_git_synced_resources(
client: DataMasqueClient, body: dict[str, object], expected: Optional[bool]
) -> None:
with requests_mock.Mocker() as m:
m.get("http://test-server/api/projects/7/", json=body)
project = client.get_project(ProjectId(7))

assert project.has_git_synced_resources is expected