diff --git a/components/renku_data_services/migrations/versions/32fab117ebec_add_project_migrations.py b/components/renku_data_services/migrations/versions/6e4dfb1ca90f_add_project_migrations.py similarity index 85% rename from components/renku_data_services/migrations/versions/32fab117ebec_add_project_migrations.py rename to components/renku_data_services/migrations/versions/6e4dfb1ca90f_add_project_migrations.py index e6a9d82db..fc26b7e09 100644 --- a/components/renku_data_services/migrations/versions/32fab117ebec_add_project_migrations.py +++ b/components/renku_data_services/migrations/versions/6e4dfb1ca90f_add_project_migrations.py @@ -1,8 +1,8 @@ """add project migrations -Revision ID: 32fab117ebec +Revision ID: 6e4dfb1ca90f Revises: d71f0f795d30 -Create Date: 2025-02-06 15:20:45.667192 +Create Date: 2025-02-11 10:05:25.003625 """ @@ -10,7 +10,7 @@ from alembic import op # revision identifiers, used by Alembic. -revision = "32fab117ebec" +revision = "6e4dfb1ca90f" down_revision = "d71f0f795d30" branch_labels = None depends_on = None @@ -25,7 +25,8 @@ def upgrade() -> None: sa.Column("project_id", sa.String(length=26), nullable=False), sa.ForeignKeyConstraint(["project_id"], ["projects.projects.id"], ondelete="CASCADE"), sa.PrimaryKeyConstraint("id"), - sa.UniqueConstraint("project_id", "project_v1_id", name="uq_project_migrations"), + sa.UniqueConstraint("project_v1_id"), + sa.UniqueConstraint("project_v1_id", name="uq_project_v1_id"), schema="projects", ) op.create_index( diff --git a/components/renku_data_services/project/db.py b/components/renku_data_services/project/db.py index cf65f1994..a8d4f409a 100644 --- a/components/renku_data_services/project/db.py +++ b/components/renku_data_services/project/db.py @@ -917,30 +917,30 @@ async def migrate_v1_project( """Migrate a v1 project by creating a new project and tracking the migration.""" if not session: raise errors.ProgrammingError(message="A database session is required") - created_project = await self.project_repo.insert_project(user, project) - if not created_project: - raise errors.ValidationError( - message=f"Failed to create a project for migration from v1 (project_v1_id={project_v1_id})." - ) result = await session.scalars( - select(schemas.ProjectMigrationsORM) - .where(schemas.ProjectMigrationsORM.project_id == created_project.id) - .where(schemas.ProjectMigrationsORM.project_v1_id == project_v1_id) + select(schemas.ProjectMigrationsORM).where(schemas.ProjectMigrationsORM.project_v1_id == project_v1_id) ) project_migration = result.one_or_none() if project_migration is not None: + raise errors.ValidationError(message=f"Project V1 with id '{project_v1_id}' already exists.") + created_project = await self.project_repo.insert_project(user, project) + if not created_project: raise errors.ValidationError( - message=( - f"Project with project_v1_id '{project_v1_id}' and " - f"Project id '{created_project.id}' already exists." - ) + message=f"Failed to create a project for migration from v1 (project_v1_id={project_v1_id})." ) - migration_entry = models.UnsavedProjectMigration(project=created_project, project_v1_id=project_v1_id) - session.add(migration_entry) + if not created_project.id: + raise errors.ValidationError(message="Failed to create project with a valid ID.") + + migration_orm = schemas.ProjectMigrationsORM(project_id=created_project.id, project_v1_id=project_v1_id) + + if migration_orm.project_id is None: + raise errors.ValidationError(message="Project ID cannot be None for the migration entry.") + + session.add(migration_orm) await session.flush() - await session.refresh(migration_entry) + await session.refresh(migration_orm) return created_project diff --git a/components/renku_data_services/project/models.py b/components/renku_data_services/project/models.py index 8fb461256..4b24f7990 100644 --- a/components/renku_data_services/project/models.py +++ b/components/renku_data_services/project/models.py @@ -161,7 +161,7 @@ class SessionSecretPatchSecretValue: class UnsavedProjectMigration: """Model representing a migration from an old project version that has not been persisted.""" - project: Project + project_id: ULID project_v1_id: int @@ -170,11 +170,11 @@ class ProjectMigration: """Model representing a migration from an old project version.""" id: ULID - project: Project + project_id: ULID project_v1_id: int migrated_at: datetime = field(default_factory=lambda: datetime.now(UTC).replace(microsecond=0)) @property def etag(self) -> str: """Entity tag value for this project migration object.""" - return compute_etag_from_fields(self.migrated_at, self.project.id) + return compute_etag_from_fields(self.migrated_at, self.project_v1_id) diff --git a/components/renku_data_services/project/orm.py b/components/renku_data_services/project/orm.py index 46c53aff8..6840cd367 100644 --- a/components/renku_data_services/project/orm.py +++ b/components/renku_data_services/project/orm.py @@ -199,17 +199,15 @@ class ProjectMigrationsORM(BaseORM): """Tracks project migrations from an old project (project_v1_id) to a new project (project_id).""" __tablename__ = "project_migrations" - __table_args__ = (UniqueConstraint("project_id", "project_v1_id", name="uq_project_migrations"),) + __table_args__ = (UniqueConstraint("project_v1_id", name="uq_project_v1_id"),) id: Mapped[ULID] = mapped_column("id", ULIDType, primary_key=True, default_factory=lambda: str(ULID()), init=False) - project_v1_id: Mapped[int] = mapped_column("project_v1_id", Integer, nullable=False) - """The old project being migrated.""" + project_v1_id: Mapped[int] = mapped_column("project_v1_id", Integer, nullable=False, unique=True) + """The old project being migrated. Must be unique.""" - project_id: Mapped[Optional[ULID]] = mapped_column( - ForeignKey("projects.id", ondelete="CASCADE"), nullable=False, index=True - ) - """The new project that replaces the old one.""" + project_id: Mapped[ULID] = mapped_column(ForeignKey("projects.id", ondelete="CASCADE"), nullable=False, index=True) + """The new project of the migration of the v1.""" project: Mapped[Optional[ProjectORM]] = relationship("ProjectORM", foreign_keys=[project_id], default=None) """Relationship to the new project.""" diff --git a/test/bases/renku_data_services/data_api/test_projects.py b/test/bases/renku_data_services/data_api/test_projects.py index e800c6089..0c61b0aed 100644 --- a/test/bases/renku_data_services/data_api/test_projects.py +++ b/test/bases/renku_data_services/data_api/test_projects.py @@ -1560,3 +1560,58 @@ async def test_get_project_after_group_moved( assert response.json is not None assert response.json.get("id") == project_id assert response.json.get("documentation") == "Hello, World!" + + +@pytest.mark.asyncio +async def test_migrate_v1_project(sanic_client, app_config, user_headers, regular_user, create_project) -> None: + v1_id = 1122 + v1_project = { + "name": "New Migrated Project", + "slug": "new-project-slug", + "namespace": regular_user.namespace.slug, + "description": "Old project for migration", + "repositories": ["http://old-repository.com"], + "visibility": "private", + "keywords": ["old", "project"], + } + + await app_config.event_repo.delete_all_events() + + _, response = await sanic_client.post( + f"/api/data/renku_v1_projects/{v1_id}/migrations", headers=user_headers, json=v1_project + ) + + assert response.status_code == 201, response.text + migrated_project = response.json + assert migrated_project["name"] == "New Migrated Project" + assert migrated_project["slug"] == "new-project-slug" + assert migrated_project["created_by"] == "user" + assert migrated_project["namespace"] == regular_user.namespace.slug + assert migrated_project["description"] == "Old project for migration" + assert migrated_project["visibility"] == "private" + assert migrated_project["keywords"] == ["old", "project"] + assert migrated_project["repositories"] == ["http://old-repository.com"] + + # events = await app_config.event_repo.get_pending_events() + # assert len(events) == 2 + # project_created_event = next(e for e in events if e.get_message_type() == "project.created") + # project_created = deserialize_event(project_created_event) + # assert project_created.name == v1_project["name"] + # assert project_created.slug == v1_project["slug"] + # project_auth_added_event = next(e for e in events if e.get_message_type() == "projectAuth.added") + # project_auth_added = deserialize_event(project_auth_added_event) + # assert project_auth_added.userId == "user" + # assert project_auth_added.role == MemberRole.OWNER + + # migrated_project_id = migrated_project["id"] + # _, response = await sanic_client.get(f"/api/data/projects/{migrated_project_id}", headers=user_headers) + # assert response.status_code == 200, response.text + # migrated_project = response.json + # assert migrated_project["name"] == "New Migrated Project" + # assert migrated_project["slug"] == "new-project-slug" + # assert migrated_project["created_by"] == "user" + # assert migrated_project["namespace"] == regular_user.namespace.slug + # assert migrated_project["description"] == "Old project for migration" + # assert migrated_project["visibility"] == "private" + # assert migrated_project["keywords"] == ["old", "project"] + # assert migrated_project["repositories"] == ["http://old-repository.com"]