Skip to content

Commit

Permalink
feat: add a template flag to projects
Browse files Browse the repository at this point in the history
  • Loading branch information
m-alisafaee committed Nov 22, 2024
1 parent 0cf7166 commit f3d37af
Show file tree
Hide file tree
Showing 10 changed files with 231 additions and 18 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
"""add template flag to projects
Revision ID: 08ac2714e8e2
Revises: ea52d750e389
Create Date: 2024-11-19 13:17:22.222365
"""

import sqlalchemy as sa
from alembic import op

# revision identifiers, used by Alembic.
revision = "08ac2714e8e2"
down_revision = "ea52d750e389"
branch_labels = None
depends_on = None


def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.add_column("projects", sa.Column("template", sa.Boolean(), default=False, nullable=False), schema="projects")
# ### end Alembic commands ###


def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column("projects", "template", schema="projects")
# ### end Alembic commands ###
26 changes: 24 additions & 2 deletions components/renku_data_services/project/api.spec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,13 @@ paths:
required: true
schema:
$ref: "#/components/schemas/Ulid"
- in: query
description: When true, only return projects that user have write access to them
name: writable
required: false
schema:
type: boolean
default: false
responses:
"200":
description: The list of projects
Expand Down Expand Up @@ -418,6 +425,9 @@ components:
$ref: "#/components/schemas/ProjectDocumentation"
template_id:
$ref: "#/components/schemas/Ulid"
template:
$ref: "#/components/schemas/Template"
default: False
required:
- "id"
- "name"
Expand Down Expand Up @@ -466,8 +476,8 @@ components:
documentation:
$ref: "#/components/schemas/ProjectDocumentation"
required:
- "name"
- "namespace"
- name
- namespace
ProjectPatch:
type: object
description: Patch of a project
Expand All @@ -487,6 +497,15 @@ components:
$ref: "#/components/schemas/KeywordsList"
documentation:
$ref: "#/components/schemas/ProjectDocumentation"
template_id:
description: template_id is set when copying a project from a template project and it cannot be modified.
This field can be either null or an empty string; a null value won't change it while an empty
string value will delete it, meaning that the project is unlinked from its template
type: string
minLength: 0
maxLength: 0
template:
$ref: "#/components/schemas/Template"
Ulid:
description: ULID identifier
type: string
Expand Down Expand Up @@ -584,6 +603,9 @@ components:
enum:
- private
- public
Template:
description: Shows if a project is a template or not
type: boolean
ProjectMemberListPatchRequest:
description: List of members and their access level to the project
type: array
Expand Down
16 changes: 15 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-14T22:57:27+00:00
# timestamp: 2024-11-21T22:51:19+00:00

from __future__ import annotations

Expand Down Expand Up @@ -113,6 +113,10 @@ class NamespacesNamespaceProjectsSlugGetParametersQuery(BaseAPISpec):
)


class ProjectsProjectIdCopiesGetParametersQuery(BaseAPISpec):
writable: bool = False


class ProjectMemberPatchRequest(BaseAPISpec):
model_config = ConfigDict(
extra="forbid",
Expand Down Expand Up @@ -262,6 +266,7 @@ class Project(BaseAPISpec):
min_length=26,
pattern="^[0-7][0-9A-HJKMNP-TV-Z]{25}$",
)
template: bool = Field(False, description="Shows if a project is a template or not")


class ProjectPost(BaseAPISpec):
Expand Down Expand Up @@ -364,6 +369,15 @@ class ProjectPatch(BaseAPISpec):
max_length=5000,
min_length=0,
)
template_id: Optional[str] = Field(
None,
description="template_id is set when copying a project from a template project and it cannot be modified. This field can be either null or an empty string; a null value won't change it while an empty string value will delete it, meaning that the project is unlinked from its template",
max_length=0,
min_length=0,
)
template: Optional[bool] = Field(
None, description="Shows if a project is a template or not"
)


class ProjectMemberListPatchRequest(RootModel[List[ProjectMemberPatchRequest]]):
Expand Down
13 changes: 11 additions & 2 deletions components/renku_data_services/project/blueprints.py
Original file line number Diff line number Diff line change
Expand Up @@ -114,8 +114,16 @@ def get_all_copies(self) -> BlueprintFactoryResponse:

@authenticate(self.authenticator)
@only_authenticated
async def _get_all_copies(_: Request, user: base_models.APIUser, project_id: ULID) -> JSONResponse:
projects = await self.project_repo.get_all_copied_projects(user=user, project_id=project_id)
@validate(query=apispec.ProjectsProjectIdCopiesGetParametersQuery)
async def _get_all_copies(
_: Request,
user: base_models.APIUser,
project_id: ULID,
query: apispec.ProjectsProjectIdCopiesGetParametersQuery,
) -> JSONResponse:
projects = await self.project_repo.get_all_copied_projects(
user=user, project_id=project_id, only_writable=query.writable
)
projects_dump = [self._dump_project(p) for p in projects]
return validated_json(apispec.ProjectsList, projects_dump)

Expand Down Expand Up @@ -300,6 +308,7 @@ def _dump_project(project: project_models.Project, with_documentation: bool = Fa
etag=project.etag,
keywords=project.keywords or [],
template_id=project.template_id,
template=project.template,
)
if with_documentation:
result = dict(result, documentation=project.documentation)
Expand Down
2 changes: 2 additions & 0 deletions components/renku_data_services/project/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ def validate_project_patch(patch: apispec.ProjectPatch) -> models.ProjectPatch:
description=patch.description,
keywords=keywords,
documentation=patch.documentation,
template_id=patch.template_id,
template=patch.template,
)


Expand Down
12 changes: 10 additions & 2 deletions components/renku_data_services/project/db.py
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,9 @@ async def get_project(

return project_orm.dump(with_documentation=with_documentation)

async def get_all_copied_projects(self, user: base_models.APIUser, project_id: ULID) -> list[models.Project]:
async def get_all_copied_projects(
self, user: base_models.APIUser, project_id: ULID, only_writable: bool
) -> list[models.Project]:
"""Get all projects that are copied from the specified project."""
authorized = await self.authz.has_permission(user, ResourceType.project, project_id, Scope.READ)
if not authorized:
Expand All @@ -129,7 +131,8 @@ async def get_all_copied_projects(self, user: base_models.APIUser, project_id: U
project_orms = result.scalars().all()

# NOTE: Show only those projects that user has access to
project_ids = await self.authz.resources_with_permission(user, user.id, ResourceType.project, Scope.READ)
scope = Scope.WRITE if only_writable else Scope.READ
project_ids = await self.authz.resources_with_permission(user, user.id, ResourceType.project, scope=scope)
project_orms = [p for p in project_orms if p.id in project_ids]

return [p.dump() for p in project_orms]
Expand Down Expand Up @@ -312,6 +315,11 @@ async def update_project(
if patch.documentation is not None:
project.documentation = patch.documentation

if patch.template_id is not None:
project.template_id = None
if patch.template is not None:
project.template = patch.template

await session.flush()
await session.refresh(project)

Expand Down
3 changes: 3 additions & 0 deletions components/renku_data_services/project/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ class BaseProject:
keywords: Optional[list[str]] = None
documentation: Optional[str] = None
template_id: Optional[ULID] = None
template: bool = False

@property
def etag(self) -> str | None:
Expand Down Expand Up @@ -63,6 +64,8 @@ class ProjectPatch:
description: str | None
keywords: list[str] | None
documentation: str | None
template_id: str | None
template: bool | None


@dataclass
Expand Down
5 changes: 4 additions & 1 deletion components/renku_data_services/project/orm.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
from datetime import datetime
from typing import TYPE_CHECKING, Optional

from sqlalchemy import DateTime, Identity, Index, Integer, MetaData, String, func
from sqlalchemy import Boolean, DateTime, Identity, Index, Integer, MetaData, String, func
from sqlalchemy.dialects.postgresql import ARRAY
from sqlalchemy.orm import DeclarativeBase, Mapped, MappedAsDataclass, mapped_column, relationship
from sqlalchemy.schema import ForeignKey
Expand Down Expand Up @@ -57,6 +57,8 @@ class ProjectORM(BaseORM):
"updated_at", DateTime(timezone=True), default=None, server_default=func.now(), onupdate=func.now()
)
template_id: Mapped[Optional[ULID]] = mapped_column(ForeignKey("projects.id", ondelete="SET NULL"), default=None)
template: Mapped[bool] = mapped_column("template", Boolean(), default=False, nullable=False)
"""Indicates whether a project is a template project or not."""

def dump(self, with_documentation: bool = False) -> models.Project:
"""Create a project model from the ProjectORM."""
Expand All @@ -76,6 +78,7 @@ def dump(self, with_documentation: bool = False) -> models.Project:
keywords=self.keywords,
documentation=self.documentation if with_documentation else None,
template_id=self.template_id,
template=self.template,
)


Expand Down
18 changes: 15 additions & 3 deletions test/bases/renku_data_services/data_api/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -241,18 +241,30 @@ async def create_project_helper(
@pytest.fixture
def create_project_copy(sanic_client, user_headers, admin_headers, regular_user, admin_user):
async def create_project_copy_helper(
id: str, namespace: str, name: str, *, user: UserInfo | None = None, **payload
id: str,
namespace: str,
name: str,
*,
user: UserInfo | None = None,
members: list[dict[str, str]] = None,
**payload,
) -> dict[str, Any]:
headers = user_headers if user is None or user is regular_user else admin_headers
copy_payload = {"slug": Slug.from_name(name).value}
copy_payload.update(payload)
copy_payload.update({"namespace": namespace, "name": name})

_, response = await sanic_client.post(f"/api/data/projects/{id}/copies", headers=headers, json=copy_payload)

assert response.status_code == 201, response.text
project = response.json

return response.json
if members:
_, response = await sanic_client.patch(
f"/api/data/projects/{project['id']}/members", headers=headers, json=members
)
assert response.status_code == 200, response.text

return project

return create_project_copy_helper

Expand Down
Loading

0 comments on commit f3d37af

Please sign in to comment.