Skip to content
Merged
Show file tree
Hide file tree
Changes from 15 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
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
"""schema

Revision ID: daf666ef6f34
Revision ID: da385d690aae
Revises:
Create Date: 2025-09-17 16:00:56.718056
Create Date: 2025-09-25 14:55:31.182061

"""

Expand All @@ -12,7 +12,7 @@
from alembic import op

# revision identifiers, used by Alembic.
revision: str = "daf666ef6f34"
revision: str = "da385d690aae"
down_revision: str | Sequence[str] | None = None
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
Expand All @@ -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.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"),
Expand Down Expand Up @@ -120,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",
Expand Down
4 changes: 2 additions & 2 deletions backend/app/api/dependencies.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]:
Expand Down
24 changes: 24 additions & 0 deletions backend/app/api/endpoints/projects.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
except ResourceNotFoundError as e:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
17 changes: 17 additions & 0 deletions backend/app/repositories/dataset_item_repo.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
17 changes: 11 additions & 6 deletions backend/app/services/mappers/project_mapper.py
Original file line number Diff line number Diff line change
@@ -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


Expand All @@ -12,12 +14,15 @@ 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],
),
)

@staticmethod
def from_schema(project: Project) -> ProjectDB:
Expand Down
16 changes: 14 additions & 2 deletions backend/app/services/project_service.py
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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:
Expand All @@ -47,3 +49,13 @@ 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, as determined by the earliest dataset item"""
with get_db_session() as db:
dataset_repo = DatasetItemRepository(str(project_id), db)
earliest_dataset_item = dataset_repo.get_earliest()

if earliest_dataset_item:
return self.projects_dir / f"{project_id}/dataset/{earliest_dataset_item.id}-thumb.jpg"
return None
Original file line number Diff line number Diff line change
Expand Up @@ -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 == "da385d690aae"
12 changes: 6 additions & 6 deletions backend/tests/integration/services/test_dataset_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand All @@ -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
Expand Down Expand Up @@ -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}"
Expand Down
57 changes: 53 additions & 4 deletions backend/tests/integration/services/test_project_service.py
Original file line number Diff line number Diff line change
@@ -1,18 +1,30 @@
# 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

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


@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():
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."""
Expand All @@ -28,9 +40,9 @@ def mock_get_db_session(db_session):


@pytest.fixture
def fxt_project_service() -> ProjectService:
def fxt_project_service(fxt_projects_dir: Path) -> ProjectService:
"""Fixture to create a ProjectService instance."""
return ProjectService()
return ProjectService(fxt_projects_dir.parent)


class TestProjectServiceIntegration:
Expand Down Expand Up @@ -94,6 +106,43 @@ 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_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,
)
db_session.add(db_project)
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

# Add a dataset item
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.flush()

# 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."""
non_existent_id = uuid4()
Expand Down
16 changes: 15 additions & 1 deletion backend/tests/unit/endpoints/test_projects.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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()
Loading