From e417dd997e09b79e27aac0dec9730b7fa0037bfc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20Adriaenssens?= Date: Tue, 23 Sep 2025 17:47:21 +0200 Subject: [PATCH 01/13] Added project thumbnail endpoint --- ...c9a60_schema.py => 6d268641ef43_schema.py} | 7 ++-- backend/app/api/dependencies.py | 4 +-- backend/app/api/endpoints/projects.py | 24 +++++++++++++ backend/app/db/schema.py | 1 + backend/app/schemas/project.py | 4 ++- .../app/services/mappers/project_mapper.py | 19 ++++++---- backend/app/services/project_service.py | 36 +++++++++++++++++-- .../services/test_project_service.py | 32 +++++++++++++++-- backend/tests/unit/endpoints/test_projects.py | 16 ++++++++- .../services/mappers/test_project_mapper.py | 13 +++++++ 10 files changed, 138 insertions(+), 18 deletions(-) rename backend/app/alembic/versions/{f8a3138c9a60_schema.py => 6d268641ef43_schema.py} (97%) diff --git a/backend/app/alembic/versions/f8a3138c9a60_schema.py b/backend/app/alembic/versions/6d268641ef43_schema.py similarity index 97% rename from backend/app/alembic/versions/f8a3138c9a60_schema.py rename to backend/app/alembic/versions/6d268641ef43_schema.py index 4ac5a5e5e1..e71c2860d7 100644 --- a/backend/app/alembic/versions/f8a3138c9a60_schema.py +++ b/backend/app/alembic/versions/6d268641ef43_schema.py @@ -1,8 +1,8 @@ """schema -Revision ID: f8a3138c9a60 +Revision ID: 6d268641ef43 Revises: -Create Date: 2025-09-10 15:50:07.886005 +Create Date: 2025-09-23 13:16:18.793027 """ @@ -12,7 +12,7 @@ from alembic import op # revision identifiers, used by Alembic. -revision: str = "f8a3138c9a60" +revision: str = "6d268641ef43" down_revision: str | Sequence[str] | None = None branch_labels: str | Sequence[str] | None = None depends_on: str | Sequence[str] | None = None @@ -36,6 +36,7 @@ def upgrade() -> None: sa.Column("name", sa.String(length=255), nullable=False), sa.Column("task_type", sa.String(length=50), nullable=False), sa.Column("exclusive_labels", sa.Boolean(), nullable=False), + sa.Column("thumbnail_id", sa.String(length=255), nullable=True), sa.Column("created_at", sa.DateTime(), server_default=sa.text("(CURRENT_TIMESTAMP)"), nullable=False), sa.Column("updated_at", sa.DateTime(), server_default=sa.text("(CURRENT_TIMESTAMP)"), nullable=False), sa.PrimaryKeyConstraint("id"), diff --git a/backend/app/api/dependencies.py b/backend/app/api/dependencies.py index b0fa6a75b9..a78f88cb1f 100644 --- a/backend/app/api/dependencies.py +++ b/backend/app/api/dependencies.py @@ -166,9 +166,9 @@ def get_webrtc_manager(request: Request) -> WebRTCManager: @lru_cache -def get_project_service() -> ProjectService: +def get_project_service(request: Request) -> ProjectService: """Provides a ProjectService instance for managing projects.""" - return ProjectService() + return ProjectService(request.app.state.settings.data_dir) def get_label_service() -> type[LabelService]: diff --git a/backend/app/api/endpoints/projects.py b/backend/app/api/endpoints/projects.py index fb8b9b2111..26ac18872d 100644 --- a/backend/app/api/endpoints/projects.py +++ b/backend/app/api/endpoints/projects.py @@ -10,6 +10,7 @@ from fastapi import APIRouter, Body, Depends, status from fastapi.exceptions import HTTPException from fastapi.openapi.models import Example +from starlette.responses import FileResponse from app.api.dependencies import get_label_service, get_project_id, get_project_service from app.schemas import Label, PatchLabels, Project @@ -197,3 +198,26 @@ def update_labels( raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e)) except ResourceAlreadyExistsError as e: raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(e)) + + +@router.get( + "/{project_id}/thumbnail", + responses={ + status.HTTP_200_OK: {"description": "Project thumbnail found"}, + status.HTTP_204_NO_CONTENT: {"description": "No thumbnail available"}, + status.HTTP_400_BAD_REQUEST: {"description": "Invalid project ID"}, + status.HTTP_404_NOT_FOUND: {"description": "Project not found"}, + }, +) +def get_project_thumbnail( + project_id: Annotated[UUID, Depends(get_project_id)], + project_service: Annotated[ProjectService, Depends(get_project_service)], +) -> FileResponse: + """Get the project's thumbnail image""" + try: + thumbnail_path = project_service.get_project_thumbnail_path(project_id) + if thumbnail_path: + return FileResponse(path=thumbnail_path) + raise HTTPException(status_code=status.HTTP_204_NO_CONTENT, detail="No thumbnail available") + except ResourceNotFoundError as e: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e)) diff --git a/backend/app/db/schema.py b/backend/app/db/schema.py index 09edb806b6..ff1ad3ab37 100644 --- a/backend/app/db/schema.py +++ b/backend/app/db/schema.py @@ -31,6 +31,7 @@ class ProjectDB(Base): name: Mapped[str] = mapped_column(String(255), nullable=False) task_type: Mapped[str] = mapped_column(String(50), nullable=False) exclusive_labels: Mapped[bool] = mapped_column(Boolean, default=False) + thumbnail_id: Mapped[str | None] = mapped_column(String(255), nullable=True) created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.current_timestamp()) updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.current_timestamp()) diff --git a/backend/app/schemas/project.py b/backend/app/schemas/project.py index da58899eeb..300eb302d4 100644 --- a/backend/app/schemas/project.py +++ b/backend/app/schemas/project.py @@ -2,7 +2,7 @@ # SPDX-License-Identifier: Apache-2.0 from enum import StrEnum -from pydantic import BaseModel +from pydantic import BaseModel, Field from app.schemas.base import BaseIDNameModel from app.schemas.label import Label @@ -22,12 +22,14 @@ class Task(BaseModel): class Project(BaseIDNameModel): task: Task + thumbnail_id: str | None = Field(default=None) model_config = { "json_schema_extra": { "example": { "id": "7b073838-99d3-42ff-9018-4e901eb047fc", "name": "animals", + "thumbnail_id": "thumbnail_123", "task": { "task_type": "classification", "exclusive_labels": True, diff --git a/backend/app/services/mappers/project_mapper.py b/backend/app/services/mappers/project_mapper.py index 26bc919022..14d4b4564f 100644 --- a/backend/app/services/mappers/project_mapper.py +++ b/backend/app/services/mappers/project_mapper.py @@ -1,8 +1,10 @@ # Copyright (C) 2025 Intel Corporation # SPDX-License-Identifier: Apache-2.0 +from uuid import UUID from app.db.schema import ProjectDB from app.schemas import Project +from app.schemas.project import Task, TaskType from app.services.mappers.label_mapper import LabelMapper @@ -12,12 +14,16 @@ class ProjectMapper: @staticmethod def to_schema(project_db: ProjectDB) -> Project: """Convert Project db entity to schema.""" - task_dict = { - "task_type": project_db.task_type, - "exclusive_labels": project_db.exclusive_labels, - "labels": [LabelMapper.to_schema(db_label) for db_label in project_db.labels], - } - return Project.model_validate({"id": project_db.id, "name": project_db.name, "task": task_dict}) + return Project( + id=UUID(project_db.id), + name=project_db.name, + task=Task( + task_type=TaskType(project_db.task_type), + exclusive_labels=project_db.exclusive_labels, + labels=[LabelMapper.to_schema(db_label) for db_label in project_db.labels], + ), + thumbnail_id=project_db.thumbnail_id, + ) @staticmethod def from_schema(project: Project) -> ProjectDB: @@ -28,6 +34,7 @@ def from_schema(project: Project) -> ProjectDB: name=project.name, task_type=project.task.task_type, exclusive_labels=project.task.exclusive_labels, + thumbnail_id=project.thumbnail_id, ) project_db.labels = [LabelMapper.from_schema(label_schema) for label_schema in project.task.labels] diff --git a/backend/app/services/project_service.py b/backend/app/services/project_service.py index de6adff6b0..b05e4cfdbf 100644 --- a/backend/app/services/project_service.py +++ b/backend/app/services/project_service.py @@ -1,10 +1,11 @@ # Copyright (C) 2025 Intel Corporation # SPDX-License-Identifier: Apache-2.0 +from pathlib import Path from uuid import UUID from app.db import get_db_session -from app.repositories import PipelineRepository, ProjectRepository +from app.repositories import DatasetItemRepository, PipelineRepository, ProjectRepository from app.schemas import Project from app.services.base import ( GenericPersistenceService, @@ -20,10 +21,11 @@ class ProjectService: - def __init__(self) -> None: + def __init__(self, data_dir: Path) -> None: self._persistence: GenericPersistenceService[Project, ProjectRepository] = GenericPersistenceService( ServiceConfig(ProjectRepository, ProjectMapper, ResourceType.PROJECT) ) + self.projects_dir = data_dir / "projects" @parent_process_only def create_project(self, project: Project) -> Project: @@ -47,3 +49,33 @@ def delete_project_by_id(self, project_id: UUID) -> None: raise ResourceInUseError(ResourceType.PROJECT, str(project_id), MSG_ERR_DELETE_ACTIVE_PROJECT) self._persistence.delete_by_id(project_id, db) db.commit() + + def get_project_thumbnail_path(self, project_id: UUID) -> Path | None: + """Get the path to the project's thumbnail image, selecting one if none is set""" + project = self.get_project_by_id(project_id) + + if project.thumbnail_id: + thumbnail_path = self._get_thumbnail_path_for_item(project_id=project_id, thumbnail_id=project.thumbnail_id) + if thumbnail_path.exists(): + return thumbnail_path + else: + with get_db_session() as db: + project_repo = ProjectRepository(db) + dataset_repo = DatasetItemRepository(str(project_id), db) + + dataset_items = dataset_repo.list_items(limit=10, offset=0) + for item in dataset_items: + thumbnail_path = self._get_thumbnail_path_for_item(project_id=project_id, thumbnail_id=item.id) + if thumbnail_path.exists(): + # Found a valid thumbnail, link it to the project + project.thumbnail_id = item.id + project_repo.update(ProjectMapper.from_schema(project)) + db.commit() + return thumbnail_path + + # No thumbnails available + return None + + def _get_thumbnail_path_for_item(self, project_id: UUID, thumbnail_id: str) -> Path: + """Get the thumbnail path for a specific dataset item""" + return self.projects_dir / f"{project_id}/dataset/{thumbnail_id}-thumb.jpg" diff --git a/backend/tests/integration/services/test_project_service.py b/backend/tests/integration/services/test_project_service.py index be85c8944a..e1f09a4852 100644 --- a/backend/tests/integration/services/test_project_service.py +++ b/backend/tests/integration/services/test_project_service.py @@ -1,6 +1,8 @@ # Copyright (C) 2025 Intel Corporation # SPDX-License-Identifier: Apache-2.0 - +import shutil +from collections.abc import Generator +from pathlib import Path from unittest.mock import patch from uuid import UUID, uuid4 @@ -13,6 +15,16 @@ from app.services.project_service import ProjectService +@pytest.fixture(scope="session", autouse=True) +def projects_dir() -> Generator[Path]: + """Setup a temporary data directory for tests.""" + projects_dir = Path("data/projects") + if not projects_dir.exists(): + projects_dir.mkdir(parents=True) + yield projects_dir + shutil.rmtree(projects_dir) + + @pytest.fixture(autouse=True) def mock_get_db_session(db_session): """Mock the get_db_session to use test database.""" @@ -28,9 +40,9 @@ def mock_get_db_session(db_session): @pytest.fixture -def fxt_project_service() -> ProjectService: +def fxt_project_service(projects_dir: Path) -> ProjectService: """Fixture to create a ProjectService instance.""" - return ProjectService() + return ProjectService(projects_dir.parent) class TestProjectServiceIntegration: @@ -94,6 +106,20 @@ def test_get_project_by_id( assert str(fetched_project.id) == db_project.id assert fetched_project.name == db_project.name + def test_get_project_by_id_with_thumbnail(self, fxt_project_service: ProjectService, db_session: Session): + """Test retrieving a project returns correct thumbnail ID.""" + db_project = ProjectDB( + name="P1", + task_type=TaskType.CLASSIFICATION, + exclusive_labels=True, + thumbnail_id="thumb_123", + ) + db_project.id = str(uuid4()) + db_session.add(db_project) + db_session.flush() + fetched_project = fxt_project_service.get_project_by_id(UUID(db_project.id)) + assert fetched_project.thumbnail_id == "thumb_123" + def test_get_project_by_id_not_found(self, fxt_project_service: ProjectService): """Test retrieving a non-existent project raises error.""" non_existent_id = uuid4() diff --git a/backend/tests/unit/endpoints/test_projects.py b/backend/tests/unit/endpoints/test_projects.py index ec8b59a237..e828fba1de 100644 --- a/backend/tests/unit/endpoints/test_projects.py +++ b/backend/tests/unit/endpoints/test_projects.py @@ -1,6 +1,6 @@ # Copyright (C) 2025 Intel Corporation # SPDX-License-Identifier: Apache-2.0 - +import tempfile from unittest.mock import MagicMock, patch from uuid import uuid4 @@ -213,3 +213,17 @@ def test_update_labels_invalid_project_id(self, fxt_project_service, fxt_client) assert response.status_code == status.HTTP_400_BAD_REQUEST fxt_project_service.get_project_by_id.assert_not_called() + + def test_get_project_thumbnail(self, fxt_project, fxt_project_service, fxt_client): + with tempfile.NamedTemporaryFile(suffix=".jpg") as tmp_file: + fxt_project_service.get_project_thumbnail_path.return_value = tmp_file.name + response = fxt_client.get(f"/api/projects/{str(fxt_project.id)}/thumbnail") + + assert response.status_code == status.HTTP_200_OK + fxt_project_service.get_project_thumbnail_path.assert_called_once() + + def test_get_project_thumbnail_none(self, fxt_project, fxt_project_service, fxt_client): + fxt_project_service.get_project_thumbnail_path.return_value = None + response = fxt_client.get(f"/api/projects/{str(fxt_project.id)}/thumbnail") + assert response.status_code == status.HTTP_204_NO_CONTENT + fxt_project_service.get_project_thumbnail_path.assert_called_once() diff --git a/backend/tests/unit/services/mappers/test_project_mapper.py b/backend/tests/unit/services/mappers/test_project_mapper.py index 84e96f8b4f..e1290bbd00 100644 --- a/backend/tests/unit/services/mappers/test_project_mapper.py +++ b/backend/tests/unit/services/mappers/test_project_mapper.py @@ -1,6 +1,7 @@ # Copyright (C) 2025 Intel Corporation # SPDX-License-Identifier: Apache-2.0 + import pytest from app.db.schema import LabelDB, ProjectDB @@ -23,6 +24,16 @@ Project(name="Test Project", task=Task(task_type=TaskType.DETECTION, exclusive_labels=False, labels=[])), ProjectDB(name="Test Project", task_type=TaskType.DETECTION, exclusive_labels=False), ), + ( + Project( + name="Test Project", + task=Task(task_type=TaskType.SEGMENTATION, exclusive_labels=False, labels=[]), + thumbnail_id="thumbnail_123", + ), + ProjectDB( + name="Test Project", task_type=TaskType.SEGMENTATION, exclusive_labels=False, thumbnail_id="thumbnail_123" + ), + ), ] @@ -39,6 +50,7 @@ def test_from_schema(self, schema_instance, expected_db): assert actual_db.task_type == expected_db.task_type assert actual_db.exclusive_labels == expected_db.exclusive_labels assert {label.name for label in actual_db.labels} == {label.name for label in expected_db.labels} + assert actual_db.thumbnail_id == expected_db.thumbnail_id @pytest.mark.parametrize("db_instance,expected_schema", [(v, k) for (k, v) in SUPPORTED_PROJECT_MAPPING.copy()]) def test_to_schema(self, db_instance, expected_schema): @@ -53,3 +65,4 @@ def test_to_schema(self, db_instance, expected_schema): assert {label.name for label in actual_schema.task.labels} == { label.name for label in expected_schema.task.labels } + assert actual_schema.thumbnail_id == expected_schema.thumbnail_id From 7455347cb0af7bb391bf68d5917d1c19d5de4aae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20Adriaenssens?= Date: Wed, 24 Sep 2025 11:19:19 +0200 Subject: [PATCH 02/13] Fixed alembic version --- .../alembic/versions/6d268641ef43_schema.py | 133 ------------------ .../alembic/versions/daf666ef6f34_schema.py | 7 +- .../alembic/test_initial_schema_migration.py | 2 +- 3 files changed, 5 insertions(+), 137 deletions(-) delete mode 100644 backend/app/alembic/versions/6d268641ef43_schema.py diff --git a/backend/app/alembic/versions/6d268641ef43_schema.py b/backend/app/alembic/versions/6d268641ef43_schema.py deleted file mode 100644 index e71c2860d7..0000000000 --- a/backend/app/alembic/versions/6d268641ef43_schema.py +++ /dev/null @@ -1,133 +0,0 @@ -"""schema - -Revision ID: 6d268641ef43 -Revises: -Create Date: 2025-09-23 13:16:18.793027 - -""" - -from collections.abc import Sequence - -import sqlalchemy as sa -from alembic import op - -# revision identifiers, used by Alembic. -revision: str = "6d268641ef43" -down_revision: str | Sequence[str] | None = None -branch_labels: str | Sequence[str] | None = None -depends_on: str | Sequence[str] | None = None - - -def upgrade() -> None: - """Upgrade schema.""" - # ### commands auto generated by Alembic - please adjust! ### - op.create_table( - "models", - sa.Column("id", sa.Text(), nullable=False), - sa.Column("name", sa.String(length=255), nullable=False), - sa.Column("format", sa.String(length=50), nullable=False), - sa.Column("created_at", sa.DateTime(), server_default=sa.text("(CURRENT_TIMESTAMP)"), nullable=False), - sa.Column("updated_at", sa.DateTime(), server_default=sa.text("(CURRENT_TIMESTAMP)"), nullable=False), - sa.PrimaryKeyConstraint("id"), - ) - op.create_table( - "projects", - sa.Column("id", sa.Text(), nullable=False), - sa.Column("name", sa.String(length=255), nullable=False), - sa.Column("task_type", sa.String(length=50), nullable=False), - sa.Column("exclusive_labels", sa.Boolean(), nullable=False), - sa.Column("thumbnail_id", sa.String(length=255), nullable=True), - sa.Column("created_at", sa.DateTime(), server_default=sa.text("(CURRENT_TIMESTAMP)"), nullable=False), - sa.Column("updated_at", sa.DateTime(), server_default=sa.text("(CURRENT_TIMESTAMP)"), nullable=False), - sa.PrimaryKeyConstraint("id"), - ) - op.create_table( - "sinks", - sa.Column("id", sa.Text(), nullable=False), - sa.Column("name", sa.String(length=255), nullable=False), - sa.Column("sink_type", sa.String(length=50), nullable=False), - sa.Column("rate_limit", sa.Float(), nullable=True), - sa.Column("config_data", sa.JSON(), nullable=False), - sa.Column("output_formats", sa.JSON(), nullable=False), - sa.Column("created_at", sa.DateTime(), server_default=sa.text("(CURRENT_TIMESTAMP)"), nullable=False), - sa.Column("updated_at", sa.DateTime(), server_default=sa.text("(CURRENT_TIMESTAMP)"), nullable=False), - sa.PrimaryKeyConstraint("id"), - sa.UniqueConstraint("name"), - ) - op.create_table( - "sources", - sa.Column("id", sa.Text(), nullable=False), - sa.Column("name", sa.String(length=255), nullable=False), - sa.Column("source_type", sa.String(length=50), nullable=False), - sa.Column("config_data", sa.JSON(), nullable=False), - sa.Column("created_at", sa.DateTime(), server_default=sa.text("(CURRENT_TIMESTAMP)"), nullable=False), - sa.Column("updated_at", sa.DateTime(), server_default=sa.text("(CURRENT_TIMESTAMP)"), nullable=False), - sa.PrimaryKeyConstraint("id"), - sa.UniqueConstraint("name"), - ) - op.create_table( - "dataset_items", - sa.Column("id", sa.Text(), nullable=False), - sa.Column("project_id", sa.Text(), nullable=False), - sa.Column("name", sa.String(length=255), nullable=False), - sa.Column("format", sa.String(length=50), nullable=False), - sa.Column("width", sa.Integer(), nullable=False), - sa.Column("height", sa.Integer(), nullable=False), - sa.Column("size", sa.Integer(), nullable=False), - sa.Column("annotation_data", sa.JSON(), nullable=True), - sa.Column("user_reviewed", sa.Boolean(), nullable=False), - sa.Column("prediction_model_id", sa.Text(), nullable=True), - sa.Column("source_id", sa.Text(), nullable=True), - sa.Column("subset", sa.String(length=20), nullable=False), - sa.Column("subset_assigned_at", sa.DateTime(), nullable=True), - sa.Column("created_at", sa.DateTime(), server_default=sa.text("(CURRENT_TIMESTAMP)"), nullable=False), - sa.Column("updated_at", sa.DateTime(), server_default=sa.text("(CURRENT_TIMESTAMP)"), nullable=False), - sa.ForeignKeyConstraint(["prediction_model_id"], ["models.id"], ondelete="SET NULL"), - sa.ForeignKeyConstraint(["project_id"], ["projects.id"], ondelete="CASCADE"), - sa.ForeignKeyConstraint(["source_id"], ["sources.id"], ondelete="SET NULL"), - sa.PrimaryKeyConstraint("id"), - ) - op.create_table( - "labels", - sa.Column("id", sa.Text(), nullable=False), - sa.Column("project_id", sa.Text(), nullable=False), - sa.Column("name", sa.String(length=255), nullable=False), - sa.Column("created_at", sa.DateTime(), server_default=sa.text("(CURRENT_TIMESTAMP)"), nullable=False), - sa.Column("updated_at", sa.DateTime(), server_default=sa.text("(CURRENT_TIMESTAMP)"), nullable=False), - sa.Column("color", sa.String(length=7), nullable=True), - sa.Column("hotkey", sa.String(length=10), nullable=True), - sa.ForeignKeyConstraint(["project_id"], ["projects.id"], ondelete="CASCADE"), - sa.PrimaryKeyConstraint("id"), - sa.UniqueConstraint("project_id", "hotkey", name="uq_project_label_hotkey"), - sa.UniqueConstraint("project_id", "name", name="uq_project_label_name"), - ) - op.create_table( - "pipelines", - sa.Column("project_id", sa.Text(), nullable=False), - sa.Column("source_id", sa.Text(), nullable=True), - sa.Column("sink_id", sa.Text(), nullable=True), - sa.Column("model_id", sa.Text(), nullable=True), - sa.Column("is_running", sa.Boolean(), nullable=False), - sa.Column("data_collection_policies", sa.JSON(), nullable=False), - sa.Column("created_at", sa.DateTime(), server_default=sa.text("(CURRENT_TIMESTAMP)"), nullable=False), - sa.Column("updated_at", sa.DateTime(), server_default=sa.text("(CURRENT_TIMESTAMP)"), nullable=False), - sa.ForeignKeyConstraint(["model_id"], ["models.id"], ondelete="RESTRICT"), - sa.ForeignKeyConstraint(["project_id"], ["projects.id"], ondelete="CASCADE"), - sa.ForeignKeyConstraint(["sink_id"], ["sinks.id"], ondelete="RESTRICT"), - sa.ForeignKeyConstraint(["source_id"], ["sources.id"], ondelete="RESTRICT"), - sa.PrimaryKeyConstraint("project_id"), - ) - # ### end Alembic commands ### - - -def downgrade() -> None: - """Downgrade schema.""" - # ### commands auto generated by Alembic - please adjust! ### - op.drop_table("pipelines") - op.drop_table("labels") - op.drop_table("dataset_items") - op.drop_table("sources") - op.drop_table("sinks") - op.drop_table("projects") - op.drop_table("models") - # ### end Alembic commands ### diff --git a/backend/app/alembic/versions/daf666ef6f34_schema.py b/backend/app/alembic/versions/daf666ef6f34_schema.py index 52177a93c7..09a4772e50 100644 --- a/backend/app/alembic/versions/daf666ef6f34_schema.py +++ b/backend/app/alembic/versions/daf666ef6f34_schema.py @@ -1,8 +1,8 @@ """schema -Revision ID: daf666ef6f34 +Revision ID: 95b8e3d1e0a7 Revises: -Create Date: 2025-09-17 16:00:56.718056 +Create Date: 2025-09-24 11:16:21.691468 """ @@ -12,7 +12,7 @@ from alembic import op # revision identifiers, used by Alembic. -revision: str = "daf666ef6f34" +revision: str = "95b8e3d1e0a7" down_revision: str | Sequence[str] | None = None branch_labels: str | Sequence[str] | None = None depends_on: str | Sequence[str] | None = None @@ -27,6 +27,7 @@ def upgrade() -> None: sa.Column("name", sa.String(length=255), nullable=False), sa.Column("task_type", sa.String(length=50), nullable=False), sa.Column("exclusive_labels", sa.Boolean(), nullable=False), + sa.Column("thumbnail_id", sa.String(length=255), nullable=True), sa.Column("created_at", sa.DateTime(), server_default=sa.text("(CURRENT_TIMESTAMP)"), nullable=False), sa.Column("updated_at", sa.DateTime(), server_default=sa.text("(CURRENT_TIMESTAMP)"), nullable=False), sa.PrimaryKeyConstraint("id"), diff --git a/backend/tests/integration/alembic/test_initial_schema_migration.py b/backend/tests/integration/alembic/test_initial_schema_migration.py index f6a57705a4..af48ab9037 100644 --- a/backend/tests/integration/alembic/test_initial_schema_migration.py +++ b/backend/tests/integration/alembic/test_initial_schema_migration.py @@ -59,4 +59,4 @@ def test_database_migration_applied(alembic_session): assert "labels" in tables (result,) = alembic_session.execute(text("SELECT version_num FROM alembic_version")).fetchone() - assert result == "daf666ef6f34" + assert result == "95b8e3d1e0a7" From 7e3e53e677111719d214abf7f642f1503ee7d402 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20Adriaenssens?= Date: Wed, 24 Sep 2025 11:21:44 +0200 Subject: [PATCH 03/13] Rename alembic schema file --- .../versions/{daf666ef6f34_schema.py => 95b8e3d1e0a7_schema.py} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename backend/app/alembic/versions/{daf666ef6f34_schema.py => 95b8e3d1e0a7_schema.py} (100%) diff --git a/backend/app/alembic/versions/daf666ef6f34_schema.py b/backend/app/alembic/versions/95b8e3d1e0a7_schema.py similarity index 100% rename from backend/app/alembic/versions/daf666ef6f34_schema.py rename to backend/app/alembic/versions/95b8e3d1e0a7_schema.py From b460eb585349337bb027f340e57d6e479edd5597 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20Adriaenssens?= Date: Wed, 24 Sep 2025 11:41:49 +0200 Subject: [PATCH 04/13] Fixed test for missing project directory --- .../integration/services/test_dataset_service.py | 12 ++++++------ .../integration/services/test_project_service.py | 8 ++++---- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/backend/tests/integration/services/test_dataset_service.py b/backend/tests/integration/services/test_dataset_service.py index 3aaeddf3a5..f33584bfc1 100644 --- a/backend/tests/integration/services/test_dataset_service.py +++ b/backend/tests/integration/services/test_dataset_service.py @@ -24,8 +24,8 @@ logger = logging.getLogger(__name__) -@pytest.fixture(scope="session", autouse=True) -def projects_dir() -> Generator[Path]: +@pytest.fixture() +def fxt_projects_dir() -> Generator[Path]: """Setup a temporary data directory for tests.""" projects_dir = Path("data/projects") if not projects_dir.exists(): @@ -44,9 +44,9 @@ def mock_get_db_session(db_session): @pytest.fixture -def fxt_dataset_service(projects_dir: Path) -> DatasetService: +def fxt_dataset_service(fxt_projects_dir: Path) -> DatasetService: """Fixture to create a DatasetService instance.""" - return DatasetService(projects_dir.parent) + return DatasetService(fxt_projects_dir.parent) @pytest.fixture @@ -362,10 +362,10 @@ def test_delete_dataset_item( fxt_dataset_service: DatasetService, fxt_stored_projects: list[ProjectDB], fxt_stored_dataset_items: list[DatasetItemDB], - projects_dir, + fxt_projects_dir: Path, db_session: Session, ): - dataset_dir = projects_dir / fxt_stored_projects[0].id / "dataset" + dataset_dir = fxt_projects_dir / fxt_stored_projects[0].id / "dataset" dataset_dir.mkdir(parents=True, exist_ok=True) binary_path = dataset_dir / f"{fxt_stored_dataset_items[0].id}.{fxt_stored_dataset_items[0].format}" diff --git a/backend/tests/integration/services/test_project_service.py b/backend/tests/integration/services/test_project_service.py index e1f09a4852..9a78a90a84 100644 --- a/backend/tests/integration/services/test_project_service.py +++ b/backend/tests/integration/services/test_project_service.py @@ -15,8 +15,8 @@ from app.services.project_service import ProjectService -@pytest.fixture(scope="session", autouse=True) -def projects_dir() -> Generator[Path]: +@pytest.fixture() +def fxt_projects_dir() -> Generator[Path]: """Setup a temporary data directory for tests.""" projects_dir = Path("data/projects") if not projects_dir.exists(): @@ -40,9 +40,9 @@ def mock_get_db_session(db_session): @pytest.fixture -def fxt_project_service(projects_dir: Path) -> ProjectService: +def fxt_project_service(fxt_projects_dir: Path) -> ProjectService: """Fixture to create a ProjectService instance.""" - return ProjectService(projects_dir.parent) + return ProjectService(fxt_projects_dir.parent) class TestProjectServiceIntegration: From dc89f247fbe293e4974432c03ca67c24a4fbe0dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20Adriaenssens?= Date: Wed, 24 Sep 2025 15:25:44 +0200 Subject: [PATCH 05/13] Removed detail on 204 response --- backend/app/api/endpoints/projects.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/app/api/endpoints/projects.py b/backend/app/api/endpoints/projects.py index 26ac18872d..3288abd98b 100644 --- a/backend/app/api/endpoints/projects.py +++ b/backend/app/api/endpoints/projects.py @@ -218,6 +218,6 @@ def get_project_thumbnail( thumbnail_path = project_service.get_project_thumbnail_path(project_id) if thumbnail_path: return FileResponse(path=thumbnail_path) - raise HTTPException(status_code=status.HTTP_204_NO_CONTENT, detail="No thumbnail available") + raise HTTPException(status_code=status.HTTP_204_NO_CONTENT) except ResourceNotFoundError as e: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e)) From a2eb8ab3d145fe34cbe5ab660b7893182b5746ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20Adriaenssens?= Date: Thu, 25 Sep 2025 10:36:47 +0200 Subject: [PATCH 06/13] Refactored thumbnail ID to UUID --- .../alembic/versions/95b8e3d1e0a7_schema.py | 2 +- backend/app/db/schema.py | 4 ++- backend/app/schemas/project.py | 7 +++-- .../app/services/mappers/project_mapper.py | 2 +- backend/app/services/project_service.py | 7 +++-- .../services/test_project_service.py | 30 +++++++++++++++---- .../services/mappers/test_project_mapper.py | 11 +++++-- 7 files changed, 48 insertions(+), 15 deletions(-) diff --git a/backend/app/alembic/versions/95b8e3d1e0a7_schema.py b/backend/app/alembic/versions/95b8e3d1e0a7_schema.py index 09a4772e50..712b8013a1 100644 --- a/backend/app/alembic/versions/95b8e3d1e0a7_schema.py +++ b/backend/app/alembic/versions/95b8e3d1e0a7_schema.py @@ -27,7 +27,7 @@ def upgrade() -> None: sa.Column("name", sa.String(length=255), nullable=False), sa.Column("task_type", sa.String(length=50), nullable=False), sa.Column("exclusive_labels", sa.Boolean(), nullable=False), - sa.Column("thumbnail_id", sa.String(length=255), nullable=True), + sa.Column("thumbnail_id", sa.Text(), nullable=True), sa.Column("created_at", sa.DateTime(), server_default=sa.text("(CURRENT_TIMESTAMP)"), nullable=False), sa.Column("updated_at", sa.DateTime(), server_default=sa.text("(CURRENT_TIMESTAMP)"), nullable=False), sa.PrimaryKeyConstraint("id"), diff --git a/backend/app/db/schema.py b/backend/app/db/schema.py index 5ff34771a2..43e6ac0727 100644 --- a/backend/app/db/schema.py +++ b/backend/app/db/schema.py @@ -31,7 +31,9 @@ class ProjectDB(Base): name: Mapped[str] = mapped_column(String(255), nullable=False) task_type: Mapped[str] = mapped_column(String(50), nullable=False) exclusive_labels: Mapped[bool] = mapped_column(Boolean, default=False) - thumbnail_id: Mapped[str | None] = mapped_column(String(255), nullable=True) + thumbnail_id: Mapped[str | None] = mapped_column( + Text, ForeignKey("dataset_items.id", ondelete="SET NULL"), nullable=True + ) created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.current_timestamp()) updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.current_timestamp()) diff --git a/backend/app/schemas/project.py b/backend/app/schemas/project.py index 300eb302d4..14e8982ecf 100644 --- a/backend/app/schemas/project.py +++ b/backend/app/schemas/project.py @@ -1,6 +1,7 @@ # Copyright (C) 2025 Intel Corporation # SPDX-License-Identifier: Apache-2.0 from enum import StrEnum +from uuid import UUID from pydantic import BaseModel, Field @@ -22,14 +23,16 @@ class Task(BaseModel): class Project(BaseIDNameModel): task: Task - thumbnail_id: str | None = Field(default=None) + thumbnail_id: UUID | None = Field( + default=None, description="ID of the thumbnail image for the project, it matches a dataset item ID" + ) model_config = { "json_schema_extra": { "example": { "id": "7b073838-99d3-42ff-9018-4e901eb047fc", "name": "animals", - "thumbnail_id": "thumbnail_123", + "thumbnail_id": "7b073838-99d3-42ff-9018-4e901eb047fd", "task": { "task_type": "classification", "exclusive_labels": True, diff --git a/backend/app/services/mappers/project_mapper.py b/backend/app/services/mappers/project_mapper.py index 14d4b4564f..70c8a31c82 100644 --- a/backend/app/services/mappers/project_mapper.py +++ b/backend/app/services/mappers/project_mapper.py @@ -34,7 +34,7 @@ def from_schema(project: Project) -> ProjectDB: name=project.name, task_type=project.task.task_type, exclusive_labels=project.task.exclusive_labels, - thumbnail_id=project.thumbnail_id, + thumbnail_id=str(project.thumbnail_id) if project.thumbnail_id else None, ) project_db.labels = [LabelMapper.from_schema(label_schema) for label_schema in project.task.labels] diff --git a/backend/app/services/project_service.py b/backend/app/services/project_service.py index b05e4cfdbf..799c4a920c 100644 --- a/backend/app/services/project_service.py +++ b/backend/app/services/project_service.py @@ -55,7 +55,10 @@ def get_project_thumbnail_path(self, project_id: UUID) -> Path | None: project = self.get_project_by_id(project_id) if project.thumbnail_id: - thumbnail_path = self._get_thumbnail_path_for_item(project_id=project_id, thumbnail_id=project.thumbnail_id) + thumbnail_path = self._get_thumbnail_path_for_item( + project_id=project_id, + thumbnail_id=str(project.thumbnail_id), + ) if thumbnail_path.exists(): return thumbnail_path else: @@ -68,7 +71,7 @@ def get_project_thumbnail_path(self, project_id: UUID) -> Path | None: thumbnail_path = self._get_thumbnail_path_for_item(project_id=project_id, thumbnail_id=item.id) if thumbnail_path.exists(): # Found a valid thumbnail, link it to the project - project.thumbnail_id = item.id + project.thumbnail_id = UUID(item.id) project_repo.update(ProjectMapper.from_schema(project)) db.commit() return thumbnail_path diff --git a/backend/tests/integration/services/test_project_service.py b/backend/tests/integration/services/test_project_service.py index 9a78a90a84..7755ce4f86 100644 --- a/backend/tests/integration/services/test_project_service.py +++ b/backend/tests/integration/services/test_project_service.py @@ -9,7 +9,7 @@ import pytest from sqlalchemy.orm import Session -from app.db.schema import LabelDB, PipelineDB, ProjectDB +from app.db.schema import DatasetItemDB, LabelDB, PipelineDB, ProjectDB from app.schemas.project import Label, Project, Task, TaskType from app.services.base import ResourceInUseError, ResourceNotFoundError, ResourceType from app.services.project_service import ProjectService @@ -108,17 +108,37 @@ def test_get_project_by_id( def test_get_project_by_id_with_thumbnail(self, fxt_project_service: ProjectService, db_session: Session): """Test retrieving a project returns correct thumbnail ID.""" + # First create a project db_project = ProjectDB( + id=str(uuid4()), name="P1", task_type=TaskType.CLASSIFICATION, exclusive_labels=True, - thumbnail_id="thumb_123", ) - db_project.id = str(uuid4()) db_session.add(db_project) - db_session.flush() + db_session.commit() + + # Then create a dataset item linked to the project to be used as thumbnail + db_dataset_item = DatasetItemDB( + id=str(uuid4()), + project_id=db_project.id, + name="item1", + format="jpg", + width=1920, + height=1080, + size=1024, + subset="unassigned", + ) + db_session.add(db_dataset_item) + db_session.commit() + + # Lastly, update the project to set its thumbnail_id from the dataset item + db_project.thumbnail_id = db_dataset_item.id + db_session.add(db_project) + db_session.commit() + fetched_project = fxt_project_service.get_project_by_id(UUID(db_project.id)) - assert fetched_project.thumbnail_id == "thumb_123" + assert fetched_project.thumbnail_id == UUID(db_dataset_item.id) def test_get_project_by_id_not_found(self, fxt_project_service: ProjectService): """Test retrieving a non-existent project raises error.""" diff --git a/backend/tests/unit/services/mappers/test_project_mapper.py b/backend/tests/unit/services/mappers/test_project_mapper.py index e1290bbd00..bcdaa4a0cf 100644 --- a/backend/tests/unit/services/mappers/test_project_mapper.py +++ b/backend/tests/unit/services/mappers/test_project_mapper.py @@ -1,6 +1,6 @@ # Copyright (C) 2025 Intel Corporation # SPDX-License-Identifier: Apache-2.0 - +from uuid import UUID import pytest @@ -28,10 +28,13 @@ Project( name="Test Project", task=Task(task_type=TaskType.SEGMENTATION, exclusive_labels=False, labels=[]), - thumbnail_id="thumbnail_123", + thumbnail_id=UUID("7b073838-99d3-42ff-9018-4e901eb047fd"), ), ProjectDB( - name="Test Project", task_type=TaskType.SEGMENTATION, exclusive_labels=False, thumbnail_id="thumbnail_123" + name="Test Project", + task_type=TaskType.SEGMENTATION, + exclusive_labels=False, + thumbnail_id="7b073838-99d3-42ff-9018-4e901eb047fd", ), ), ] @@ -43,6 +46,7 @@ class TestProjectMapper: @pytest.mark.parametrize("schema_instance,expected_db", SUPPORTED_PROJECT_MAPPING.copy()) def test_from_schema(self, schema_instance, expected_db): expected_db.id = str(schema_instance.id) + expected_db.thumbnail_id = str(schema_instance.thumbnail_id) if schema_instance.thumbnail_id else None expected_db.labels = [LabelMapper.from_schema(schema_label) for schema_label in schema_instance.task.labels] actual_db = ProjectMapper.from_schema(schema_instance) assert actual_db.id == expected_db.id @@ -55,6 +59,7 @@ def test_from_schema(self, schema_instance, expected_db): @pytest.mark.parametrize("db_instance,expected_schema", [(v, k) for (k, v) in SUPPORTED_PROJECT_MAPPING.copy()]) def test_to_schema(self, db_instance, expected_schema): db_instance.id = str(expected_schema.id) + db_instance.thumbnail_id = str(expected_schema.thumbnail_id) if expected_schema.thumbnail_id else None db_instance.labels = [ LabelDB(id=str(schema_label.id), name=schema_label.name) for schema_label in expected_schema.task.labels ] From 0212541f19f62e83e7de6e902138f2501e748b43 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20Adriaenssens?= Date: Thu, 25 Sep 2025 10:37:42 +0200 Subject: [PATCH 07/13] Removed else --- backend/app/services/project_service.py | 26 ++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/backend/app/services/project_service.py b/backend/app/services/project_service.py index 799c4a920c..59d374f290 100644 --- a/backend/app/services/project_service.py +++ b/backend/app/services/project_service.py @@ -61,20 +61,20 @@ def get_project_thumbnail_path(self, project_id: UUID) -> Path | None: ) if thumbnail_path.exists(): return thumbnail_path - else: - with get_db_session() as db: - project_repo = ProjectRepository(db) - dataset_repo = DatasetItemRepository(str(project_id), db) - dataset_items = dataset_repo.list_items(limit=10, offset=0) - for item in dataset_items: - thumbnail_path = self._get_thumbnail_path_for_item(project_id=project_id, thumbnail_id=item.id) - if thumbnail_path.exists(): - # Found a valid thumbnail, link it to the project - project.thumbnail_id = UUID(item.id) - project_repo.update(ProjectMapper.from_schema(project)) - db.commit() - return thumbnail_path + with get_db_session() as db: + project_repo = ProjectRepository(db) + dataset_repo = DatasetItemRepository(str(project_id), db) + + dataset_items = dataset_repo.list_items(limit=10, offset=0) + for item in dataset_items: + thumbnail_path = self._get_thumbnail_path_for_item(project_id=project_id, thumbnail_id=item.id) + if thumbnail_path.exists(): + # Found a valid thumbnail, link it to the project + project.thumbnail_id = UUID(item.id) + project_repo.update(ProjectMapper.from_schema(project)) + db.commit() + return thumbnail_path # No thumbnails available return None From 7c7a3ece882e1e6216f439e581310430f7cd7e5f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20Adriaenssens?= Date: Thu, 25 Sep 2025 10:39:20 +0200 Subject: [PATCH 08/13] Add note regarding potential invalid items in dataset item listing --- backend/app/services/project_service.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/backend/app/services/project_service.py b/backend/app/services/project_service.py index 59d374f290..919f9f67f2 100644 --- a/backend/app/services/project_service.py +++ b/backend/app/services/project_service.py @@ -65,7 +65,8 @@ def get_project_thumbnail_path(self, project_id: UUID) -> Path | None: with get_db_session() as db: project_repo = ProjectRepository(db) dataset_repo = DatasetItemRepository(str(project_id), db) - + # Note: In theory, all items on the first page of 10 could be invalid. + # However, this is extremely rare, so we assume the happy path for simplicity. dataset_items = dataset_repo.list_items(limit=10, offset=0) for item in dataset_items: thumbnail_path = self._get_thumbnail_path_for_item(project_id=project_id, thumbnail_id=item.id) From 0a6d11ef4cf0180d9f9cdc1632634b4b6da46732 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20Adriaenssens?= Date: Thu, 25 Sep 2025 10:56:41 +0200 Subject: [PATCH 09/13] Fixed project mapper --- backend/app/services/mappers/project_mapper.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/app/services/mappers/project_mapper.py b/backend/app/services/mappers/project_mapper.py index 70c8a31c82..e1a97e06a1 100644 --- a/backend/app/services/mappers/project_mapper.py +++ b/backend/app/services/mappers/project_mapper.py @@ -22,7 +22,7 @@ def to_schema(project_db: ProjectDB) -> Project: exclusive_labels=project_db.exclusive_labels, labels=[LabelMapper.to_schema(db_label) for db_label in project_db.labels], ), - thumbnail_id=project_db.thumbnail_id, + thumbnail_id=UUID(project_db.thumbnail_id) if project_db.thumbnail_id else None, ) @staticmethod From ba16be44428dbbac728c9c7727624f8cbf863968 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20Adriaenssens?= Date: Thu, 25 Sep 2025 15:17:06 +0200 Subject: [PATCH 10/13] Changed logic to access first created dataset item rather than link a dataset item to the project. --- ...1e0a7_schema.py => da385d690aae_schema.py} | 7 ++-- backend/app/db/schema.py | 3 -- backend/app/repositories/dataset_item_repo.py | 17 ++++++++++ backend/app/schemas/project.py | 7 +--- .../app/services/mappers/project_mapper.py | 2 -- backend/app/services/project_service.py | 32 +++---------------- .../services/test_project_service.py | 25 ++++++++------- .../services/mappers/test_project_mapper.py | 18 ----------- 8 files changed, 40 insertions(+), 71 deletions(-) rename backend/app/alembic/versions/{95b8e3d1e0a7_schema.py => da385d690aae_schema.py} (97%) diff --git a/backend/app/alembic/versions/95b8e3d1e0a7_schema.py b/backend/app/alembic/versions/da385d690aae_schema.py similarity index 97% rename from backend/app/alembic/versions/95b8e3d1e0a7_schema.py rename to backend/app/alembic/versions/da385d690aae_schema.py index 712b8013a1..5a4838997d 100644 --- a/backend/app/alembic/versions/95b8e3d1e0a7_schema.py +++ b/backend/app/alembic/versions/da385d690aae_schema.py @@ -1,8 +1,8 @@ """schema -Revision ID: 95b8e3d1e0a7 +Revision ID: da385d690aae Revises: -Create Date: 2025-09-24 11:16:21.691468 +Create Date: 2025-09-25 14:55:31.182061 """ @@ -12,7 +12,7 @@ from alembic import op # revision identifiers, used by Alembic. -revision: str = "95b8e3d1e0a7" +revision: str = "da385d690aae" down_revision: str | Sequence[str] | None = None branch_labels: str | Sequence[str] | None = None depends_on: str | Sequence[str] | None = None @@ -121,6 +121,7 @@ def upgrade() -> None: sa.ForeignKeyConstraint(["project_id"], ["projects.id"], ondelete="CASCADE"), sa.ForeignKeyConstraint(["source_id"], ["sources.id"], ondelete="SET NULL"), sa.PrimaryKeyConstraint("id"), + sa.Index("idx_dataset_items_project_created_at", "project_id", "created_at"), ) op.create_table( "pipelines", diff --git a/backend/app/db/schema.py b/backend/app/db/schema.py index 43e6ac0727..fb5c2b4b6a 100644 --- a/backend/app/db/schema.py +++ b/backend/app/db/schema.py @@ -31,9 +31,6 @@ class ProjectDB(Base): name: Mapped[str] = mapped_column(String(255), nullable=False) task_type: Mapped[str] = mapped_column(String(50), nullable=False) exclusive_labels: Mapped[bool] = mapped_column(Boolean, default=False) - thumbnail_id: Mapped[str | None] = mapped_column( - Text, ForeignKey("dataset_items.id", ondelete="SET NULL"), nullable=True - ) created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.current_timestamp()) updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.current_timestamp()) diff --git a/backend/app/repositories/dataset_item_repo.py b/backend/app/repositories/dataset_item_repo.py index 8c9af48273..adc4c00fef 100644 --- a/backend/app/repositories/dataset_item_repo.py +++ b/backend/app/repositories/dataset_item_repo.py @@ -46,6 +46,23 @@ def list_items( query = query.filter(DatasetItemDB.created_at < end_date) return query.slice(offset, offset + limit).all() + def get_earliest(self) -> DatasetItemDB | None: + """ + Get the earliest dataset item based on creation date. + + This method efficiently uses the multicolumn index on (project_id, created_at) + for optimal query performance. + + Returns: + The earliest DatasetItemDB instance or None if no items exist. + """ + return ( + self.db.query(DatasetItemDB) + .filter(DatasetItemDB.project_id == self.project_id) + .order_by(DatasetItemDB.created_at.asc()) + .first() + ) + def get_by_id(self, obj_id: str) -> DatasetItemDB | None: return ( self.db.query(DatasetItemDB) diff --git a/backend/app/schemas/project.py b/backend/app/schemas/project.py index 14e8982ecf..da58899eeb 100644 --- a/backend/app/schemas/project.py +++ b/backend/app/schemas/project.py @@ -1,9 +1,8 @@ # Copyright (C) 2025 Intel Corporation # SPDX-License-Identifier: Apache-2.0 from enum import StrEnum -from uuid import UUID -from pydantic import BaseModel, Field +from pydantic import BaseModel from app.schemas.base import BaseIDNameModel from app.schemas.label import Label @@ -23,16 +22,12 @@ class Task(BaseModel): class Project(BaseIDNameModel): task: Task - thumbnail_id: UUID | None = Field( - default=None, description="ID of the thumbnail image for the project, it matches a dataset item ID" - ) model_config = { "json_schema_extra": { "example": { "id": "7b073838-99d3-42ff-9018-4e901eb047fc", "name": "animals", - "thumbnail_id": "7b073838-99d3-42ff-9018-4e901eb047fd", "task": { "task_type": "classification", "exclusive_labels": True, diff --git a/backend/app/services/mappers/project_mapper.py b/backend/app/services/mappers/project_mapper.py index e1a97e06a1..2fc53523eb 100644 --- a/backend/app/services/mappers/project_mapper.py +++ b/backend/app/services/mappers/project_mapper.py @@ -22,7 +22,6 @@ def to_schema(project_db: ProjectDB) -> Project: exclusive_labels=project_db.exclusive_labels, labels=[LabelMapper.to_schema(db_label) for db_label in project_db.labels], ), - thumbnail_id=UUID(project_db.thumbnail_id) if project_db.thumbnail_id else None, ) @staticmethod @@ -34,7 +33,6 @@ def from_schema(project: Project) -> ProjectDB: name=project.name, task_type=project.task.task_type, exclusive_labels=project.task.exclusive_labels, - thumbnail_id=str(project.thumbnail_id) if project.thumbnail_id else None, ) project_db.labels = [LabelMapper.from_schema(label_schema) for label_schema in project.task.labels] diff --git a/backend/app/services/project_service.py b/backend/app/services/project_service.py index 919f9f67f2..451e4e6d56 100644 --- a/backend/app/services/project_service.py +++ b/backend/app/services/project_service.py @@ -51,35 +51,11 @@ def delete_project_by_id(self, project_id: UUID) -> None: db.commit() def get_project_thumbnail_path(self, project_id: UUID) -> Path | None: - """Get the path to the project's thumbnail image, selecting one if none is set""" - project = self.get_project_by_id(project_id) - - if project.thumbnail_id: - thumbnail_path = self._get_thumbnail_path_for_item( - project_id=project_id, - thumbnail_id=str(project.thumbnail_id), - ) - if thumbnail_path.exists(): - return thumbnail_path - + """Get the path to the project's thumbnail image, as determined by the earliest dataset item""" with get_db_session() as db: - project_repo = ProjectRepository(db) dataset_repo = DatasetItemRepository(str(project_id), db) - # Note: In theory, all items on the first page of 10 could be invalid. - # However, this is extremely rare, so we assume the happy path for simplicity. - dataset_items = dataset_repo.list_items(limit=10, offset=0) - for item in dataset_items: - thumbnail_path = self._get_thumbnail_path_for_item(project_id=project_id, thumbnail_id=item.id) - if thumbnail_path.exists(): - # Found a valid thumbnail, link it to the project - project.thumbnail_id = UUID(item.id) - project_repo.update(ProjectMapper.from_schema(project)) - db.commit() - return thumbnail_path + earliest_dataset_item = dataset_repo.get_earliest() - # No thumbnails available + if earliest_dataset_item: + return self.projects_dir / f"{project_id}/dataset/{earliest_dataset_item.id}-thumb.jpg" return None - - def _get_thumbnail_path_for_item(self, project_id: UUID, thumbnail_id: str) -> Path: - """Get the thumbnail path for a specific dataset item""" - return self.projects_dir / f"{project_id}/dataset/{thumbnail_id}-thumb.jpg" diff --git a/backend/tests/integration/services/test_project_service.py b/backend/tests/integration/services/test_project_service.py index 7755ce4f86..a14abce6ec 100644 --- a/backend/tests/integration/services/test_project_service.py +++ b/backend/tests/integration/services/test_project_service.py @@ -106,7 +106,7 @@ def test_get_project_by_id( assert str(fetched_project.id) == db_project.id assert fetched_project.name == db_project.name - def test_get_project_by_id_with_thumbnail(self, fxt_project_service: ProjectService, db_session: Session): + def test_get_project_thumbnail(self, fxt_project_service: ProjectService, db_session: Session): """Test retrieving a project returns correct thumbnail ID.""" # First create a project db_project = ProjectDB( @@ -116,9 +116,13 @@ def test_get_project_by_id_with_thumbnail(self, fxt_project_service: ProjectServ exclusive_labels=True, ) db_session.add(db_project) - db_session.commit() + db_session.flush() + + # No dataset items yet, should return None + fetched_thumbnail_path = fxt_project_service.get_project_thumbnail_path(UUID(db_project.id)) + assert fetched_thumbnail_path is None - # Then create a dataset item linked to the project to be used as thumbnail + # Add a dataset item db_dataset_item = DatasetItemDB( id=str(uuid4()), project_id=db_project.id, @@ -130,15 +134,14 @@ def test_get_project_by_id_with_thumbnail(self, fxt_project_service: ProjectServ subset="unassigned", ) db_session.add(db_dataset_item) - db_session.commit() - - # Lastly, update the project to set its thumbnail_id from the dataset item - db_project.thumbnail_id = db_dataset_item.id - db_session.add(db_project) - db_session.commit() + db_session.flush() - fetched_project = fxt_project_service.get_project_by_id(UUID(db_project.id)) - assert fetched_project.thumbnail_id == UUID(db_dataset_item.id) + # Now it should return the path to the thumbnail + fetched_thumbnail_path = fxt_project_service.get_project_thumbnail_path(UUID(db_project.id)) + assert ( + fetched_thumbnail_path + == fxt_project_service.projects_dir / f"{db_project.id}/dataset/{db_dataset_item.id}-thumb.jpg" + ) def test_get_project_by_id_not_found(self, fxt_project_service: ProjectService): """Test retrieving a non-existent project raises error.""" diff --git a/backend/tests/unit/services/mappers/test_project_mapper.py b/backend/tests/unit/services/mappers/test_project_mapper.py index bcdaa4a0cf..84e96f8b4f 100644 --- a/backend/tests/unit/services/mappers/test_project_mapper.py +++ b/backend/tests/unit/services/mappers/test_project_mapper.py @@ -1,6 +1,5 @@ # Copyright (C) 2025 Intel Corporation # SPDX-License-Identifier: Apache-2.0 -from uuid import UUID import pytest @@ -24,19 +23,6 @@ Project(name="Test Project", task=Task(task_type=TaskType.DETECTION, exclusive_labels=False, labels=[])), ProjectDB(name="Test Project", task_type=TaskType.DETECTION, exclusive_labels=False), ), - ( - Project( - name="Test Project", - task=Task(task_type=TaskType.SEGMENTATION, exclusive_labels=False, labels=[]), - thumbnail_id=UUID("7b073838-99d3-42ff-9018-4e901eb047fd"), - ), - ProjectDB( - name="Test Project", - task_type=TaskType.SEGMENTATION, - exclusive_labels=False, - thumbnail_id="7b073838-99d3-42ff-9018-4e901eb047fd", - ), - ), ] @@ -46,7 +32,6 @@ class TestProjectMapper: @pytest.mark.parametrize("schema_instance,expected_db", SUPPORTED_PROJECT_MAPPING.copy()) def test_from_schema(self, schema_instance, expected_db): expected_db.id = str(schema_instance.id) - expected_db.thumbnail_id = str(schema_instance.thumbnail_id) if schema_instance.thumbnail_id else None expected_db.labels = [LabelMapper.from_schema(schema_label) for schema_label in schema_instance.task.labels] actual_db = ProjectMapper.from_schema(schema_instance) assert actual_db.id == expected_db.id @@ -54,12 +39,10 @@ def test_from_schema(self, schema_instance, expected_db): assert actual_db.task_type == expected_db.task_type assert actual_db.exclusive_labels == expected_db.exclusive_labels assert {label.name for label in actual_db.labels} == {label.name for label in expected_db.labels} - assert actual_db.thumbnail_id == expected_db.thumbnail_id @pytest.mark.parametrize("db_instance,expected_schema", [(v, k) for (k, v) in SUPPORTED_PROJECT_MAPPING.copy()]) def test_to_schema(self, db_instance, expected_schema): db_instance.id = str(expected_schema.id) - db_instance.thumbnail_id = str(expected_schema.thumbnail_id) if expected_schema.thumbnail_id else None db_instance.labels = [ LabelDB(id=str(schema_label.id), name=schema_label.name) for schema_label in expected_schema.task.labels ] @@ -70,4 +53,3 @@ def test_to_schema(self, db_instance, expected_schema): assert {label.name for label in actual_schema.task.labels} == { label.name for label in expected_schema.task.labels } - assert actual_schema.thumbnail_id == expected_schema.thumbnail_id From 2849e705a7b30f9282b4e1743d082b7c71dc09ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20Adriaenssens?= Date: Thu, 25 Sep 2025 15:27:33 +0200 Subject: [PATCH 11/13] Fixed migration test --- .../tests/integration/alembic/test_initial_schema_migration.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/tests/integration/alembic/test_initial_schema_migration.py b/backend/tests/integration/alembic/test_initial_schema_migration.py index af48ab9037..480851b9a8 100644 --- a/backend/tests/integration/alembic/test_initial_schema_migration.py +++ b/backend/tests/integration/alembic/test_initial_schema_migration.py @@ -59,4 +59,4 @@ def test_database_migration_applied(alembic_session): assert "labels" in tables (result,) = alembic_session.execute(text("SELECT version_num FROM alembic_version")).fetchone() - assert result == "95b8e3d1e0a7" + assert result == "da385d690aae" From 29084f47bb4bd8e026d72746e6e11e27bd93a997 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20Adriaenssens?= Date: Thu, 25 Sep 2025 16:30:53 +0200 Subject: [PATCH 12/13] Reverted adding thumbnail_id column to project's table --- backend/app/alembic/versions/da385d690aae_schema.py | 1 - 1 file changed, 1 deletion(-) diff --git a/backend/app/alembic/versions/da385d690aae_schema.py b/backend/app/alembic/versions/da385d690aae_schema.py index 5a4838997d..e7b93e9e47 100644 --- a/backend/app/alembic/versions/da385d690aae_schema.py +++ b/backend/app/alembic/versions/da385d690aae_schema.py @@ -27,7 +27,6 @@ def upgrade() -> None: sa.Column("name", sa.String(length=255), nullable=False), sa.Column("task_type", sa.String(length=50), nullable=False), sa.Column("exclusive_labels", sa.Boolean(), nullable=False), - sa.Column("thumbnail_id", sa.Text(), nullable=True), sa.Column("created_at", sa.DateTime(), server_default=sa.text("(CURRENT_TIMESTAMP)"), nullable=False), sa.Column("updated_at", sa.DateTime(), server_default=sa.text("(CURRENT_TIMESTAMP)"), nullable=False), sa.PrimaryKeyConstraint("id"), From af1a72f69371ba336edc888b8926df4cb97ebcb2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20Adriaenssens?= Date: Thu, 25 Sep 2025 16:37:50 +0200 Subject: [PATCH 13/13] Removed request as parameter for LRU cached services --- backend/app/api/dependencies.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/backend/app/api/dependencies.py b/backend/app/api/dependencies.py index a78f88cb1f..84d9a5f78a 100644 --- a/backend/app/api/dependencies.py +++ b/backend/app/api/dependencies.py @@ -19,8 +19,11 @@ SystemService, ) from app.services.label_service import LabelService +from app.settings import get_settings from app.webrtc.manager import WebRTCManager +settings = get_settings() + def is_valid_uuid(identifier: str) -> bool: """ @@ -143,21 +146,18 @@ def get_system_service() -> SystemService: @lru_cache -def get_model_service( - request: Request, - scheduler: Annotated[Scheduler, Depends(get_scheduler)], -) -> ModelService: +def get_model_service(scheduler: Annotated[Scheduler, Depends(get_scheduler)]) -> ModelService: """Provides a ModelService instance with the model reload event from the scheduler.""" return ModelService( - data_dir=request.app.state.settings.data_dir, + data_dir=settings.data_dir, mp_model_reload_event=scheduler.mp_model_reload_event, ) @lru_cache -def get_dataset_service(request: Request) -> DatasetService: +def get_dataset_service() -> DatasetService: """Provides a DatasetService instance.""" - return DatasetService(request.app.state.settings.data_dir) + return DatasetService(settings.data_dir) def get_webrtc_manager(request: Request) -> WebRTCManager: @@ -166,9 +166,9 @@ def get_webrtc_manager(request: Request) -> WebRTCManager: @lru_cache -def get_project_service(request: Request) -> ProjectService: +def get_project_service() -> ProjectService: """Provides a ProjectService instance for managing projects.""" - return ProjectService(request.app.state.settings.data_dir) + return ProjectService(settings.data_dir) def get_label_service() -> type[LabelService]: