Skip to content

ITEP-32484 - Implement GET project_configuration endpoint #220

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 12 commits into from
May 20, 2025
Merged
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# Copyright (C) 2022-2025 Intel Corporation
# LIMITED EDGE SOFTWARE DISTRIBUTION LICENSE

from typing import Any

from geti_configuration_tools.project_configuration import NullProjectConfiguration

from communication.views.project_configuration_rest_views import ProjectConfigurationRESTViews
from storage.repos.project_configuration_repo import ProjectConfigurationRepo

from geti_fastapi_tools.exceptions import ProjectNotFoundException
from geti_telemetry_tools import unified_tracing
from geti_types import ProjectIdentifier


class ProjectConfigurationRESTController:
@staticmethod
@unified_tracing
def get_configuration(
project_identifier: ProjectIdentifier,
) -> dict[str, Any]:
"""
Retrieves configuration related to a specific project.

:param project_identifier: Identifier for the project (containing organization_id, workspace_id, and project_id)
:return: Dictionary representation of the project configuration
"""
project_config = ProjectConfigurationRepo(project_identifier).get_project_configuration()
if isinstance(project_config, NullProjectConfiguration):
raise ProjectNotFoundException(project_identifier.project_id)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perhaps it is cleaner to create a new ProjectConfigurationNotFoundException or to use a custom message indicating that the configuration could not be found.

return ProjectConfigurationRESTViews.project_configuration_to_rest(project_config)
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# Copyright (C) 2022-2025 Intel Corporation
# LIMITED EDGE SOFTWARE DISTRIBUTION LICENSE

import logging
from http import HTTPStatus
from typing import Annotated, Any

from fastapi import APIRouter, Depends

from communication.controllers.project_configuration_controller import ProjectConfigurationRESTController
from features.feature_flag_provider import FeatureFlag, FeatureFlagProvider

from geti_fastapi_tools.dependencies import get_project_identifier, setup_session_fastapi
from geti_fastapi_tools.exceptions import GetiBaseException
from geti_types import ProjectIdentifier

logger = logging.getLogger(__name__)

project_configuration_prefix_url = (
"/api/v1/organizations/{organization_id}/workspaces/{workspace_id}/projects/{project_id}"
)
project_configuration_router = APIRouter(
prefix=project_configuration_prefix_url,
tags=["Configuration"],
dependencies=[Depends(setup_session_fastapi)],
)


@project_configuration_router.get("/project_configuration")
def get_project_configuration(
project_identifier: Annotated[ProjectIdentifier, Depends(get_project_identifier)],
) -> dict[str, Any]:
"""Retrieve the configuration for a specific project."""
if not FeatureFlagProvider.is_enabled(FeatureFlag.FEATURE_FLAG_NEW_CONFIGURABLE_PARAMETERS):
raise GetiBaseException(
message="Feature not available",
error_code="feature_not_available",
http_status=HTTPStatus.FORBIDDEN,
)
return ProjectConfigurationRESTController().get_configuration(project_identifier=project_identifier)
2 changes: 2 additions & 0 deletions interactive_ai/services/director/app/communication/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
from communication.endpoints.model_tests_endpoints import model_test_router
from communication.endpoints.optimization_endpoints import optimization_router
from communication.endpoints.prediction_endpoints import prediction_router
from communication.endpoints.project_configuration_endpoints import project_configuration_router
from communication.endpoints.status_endpoints import status_router
from communication.endpoints.supported_algorithms_endpoints import supported_algorithms_router
from communication.endpoints.training_endpoints import training_router
Expand Down Expand Up @@ -85,6 +86,7 @@ async def lifespan(app: FastAPI): # type: ignore # noqa: ANN201
app.include_router(status_router)
app.include_router(training_router)
app.include_router(supported_algorithms_router)
app.include_router(project_configuration_router)

base_dir = os.path.dirname(__file__) + "/../../../api/schemas/"
mongo_id_schema = RestApiValidator().load_schema_file_as_dict(base_dir + "mongo_id.yaml")
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
# Copyright (C) 2022-2025 Intel Corporation
# LIMITED EDGE SOFTWARE DISTRIBUTION LICENSE

from typing import Any

from pydantic import BaseModel

PYDANTIC_TYPES_MAPPING = {
"integer": "int",
"number": "float",
"boolean": "bool",
"string": "str",
}


class ConfigurableParametersRESTViews:
"""
Base class for converting configurable parameters to REST views.

This class provides methods to transform Pydantic models and their fields
into REST-compatible dictionary representations.
"""

@staticmethod
def _parameter_to_rest(key: str, value: float | str | bool, json_schema: dict) -> dict[str, Any]:
"""
Convert a single parameter to its REST representation.

:param key: The parameter name/key
:param value: The parameter value (int, float, string, or boolean)
:param json_schema: The JSON schema for the parameter from the Pydantic model
:return: Dictionary containing the REST representation of the parameter
"""
rest_view = {
"key": key,
"name": json_schema.get("title"),
"description": json_schema.get("description"),
"value": value,
"default_value": json_schema.get("default"),
}
# optional parameter may contain `'anyOf': [{'exclusiveMinimum': 0, 'type': 'integer'}, {'type': 'null'}]`
type_any_of = json_schema.get("anyOf", [{}])[0]
rest_view["type"] = PYDANTIC_TYPES_MAPPING.get(json_schema.get("type", type_any_of.get("type")))
if rest_view["type"] in ["int", "float"]:
rest_view["min_value"] = json_schema.get("minimum", type_any_of.get("exclusiveMinimum"))
rest_view["max_value"] = json_schema.get("maximum", type_any_of.get("exclusiveMaximum"))
return rest_view

@classmethod
def configurable_parameters_to_rest(
cls, configurable_parameters: BaseModel
) -> dict[str, Any] | list[dict[str, Any]]:
"""
Convert a Pydantic model of configurable parameters to its REST representation.

This method processes a Pydantic model containing configuration parameters and transforms it
into a REST view. It handles both simple fields and nested models:

- Simple fields (int, float, str, bool) are converted to a list of dictionaries with metadata
including key, name, description, value, type, and constraints
- Nested Pydantic models are processed recursively and maintained as nested structures

The return format depends on the content:
- If only simple parameters exist: returns a list of parameter dictionaries
- If only nested models exist: returns a dictionary mapping nested model names to their contents
- If both exist: returns a list containing parameter dictionaries and nested model dictionary

:param configurable_parameters: Pydantic model containing configurable parameters
:return: REST representation as either a dictionary of nested models,
a list of parameter dictionaries, or a combined list of both
"""
nested_params: dict[str, Any] = {}
list_params: list[dict[str, Any]] = []

for field_name in configurable_parameters.model_fields:
field = getattr(configurable_parameters, field_name)
if isinstance(field, BaseModel):
# If the field is a nested Pydantic model, process it recursively
nested_params[field_name] = cls.configurable_parameters_to_rest(field)
else:
# If the field is a simple type, convert directly to REST view
json_model = configurable_parameters.model_json_schema()
list_params.append(
cls._parameter_to_rest(
key=field_name,
value=field,
json_schema=json_model["properties"][field_name],
)
)

# Return combined or individual results based on content
if nested_params and list_params:
return [*list_params, nested_params]
return list_params or nested_params
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# Copyright (C) 2022-2025 Intel Corporation
# LIMITED EDGE SOFTWARE DISTRIBUTION LICENSE

from geti_configuration_tools.project_configuration import ProjectConfiguration, TaskConfig

from communication.views.configurable_parameters_to_rest import ConfigurableParametersRESTViews


class ProjectConfigurationRESTViews(ConfigurableParametersRESTViews):
"""
Converters between ProjectConfiguration models and their corresponding REST views
"""

@classmethod
def task_config_to_rest(cls, task_config: TaskConfig) -> dict:
"""
Get the REST view of a task configuration

:param task_config: Task configuration object
:return: REST view of the task configuration
"""
return {
"task_id": task_config.task_id,
"training": cls.configurable_parameters_to_rest(task_config.training),
"auto_training": cls.configurable_parameters_to_rest(task_config.auto_training),
}

@classmethod
def project_configuration_to_rest(cls, project_configuration: ProjectConfiguration) -> dict:
"""
Get the REST view of a project configuration

:param project_configuration: Project configuration object
:return: REST view of the project configuration
"""
return {
"task_configs": [
cls.task_config_to_rest(task_config) for task_config in project_configuration.task_configs
],
}
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,9 @@ spec:
- path:
type: RegularExpression
value: /api/v(.*)/organizations/([^/]*)/workspaces/(.*)/projects/.*/supported_algorithms
- path:
type: RegularExpression
value: /api/v(.*)/organizations/([^/]*)/workspaces/(.*)/projects/.*/project_configuration
backendRefs:
- name: {{ .Release.Namespace }}-{{ .Chart.Name }}
port: {{ .Values.service.ports.director.port }}
Expand Down
1 change: 1 addition & 0 deletions interactive_ai/services/director/chart/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ ingress:
- /api/v(.*)/organizations/([^/]*)/workspaces/(.*)/projects/(.*):train
- /api/v(.*)/organizations/([^/]*)/workspaces/(.*)/projects/(.*)/model_groups/(.*)/models/(.*):optimize
- /api/v(.*)/organizations/([^/]*)/workspaces/(.*)/projects/(.*)/supported_algorithms
- /api/v(.*)/organizations/([^/]*)/workspaces/(.*)/projects/(.*)/project_configuration

serviceAccount:
create: true
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,65 @@ def fxt_project_configuration(fxt_project_identifier):
),
],
)


@pytest.fixture
def fxt_project_configuration_rest_view(fxt_project_configuration):
tasks_rest_view = []
for task_config in fxt_project_configuration.task_configs:
min_images_per_label_schema = task_config.training.constraints.model_json_schema()["properties"][
"min_images_per_label"
]
auto_training_schema = task_config.auto_training.model_json_schema()
tasks_rest_view.append(
{
"task_id": task_config.task_id,
"training": {
"constraints": [
{
"key": "min_images_per_label",
"name": min_images_per_label_schema["title"],
"description": min_images_per_label_schema["description"],
"type": "int",
"value": task_config.training.constraints.min_images_per_label,
"default_value": min_images_per_label_schema["default"],
"max_value": min_images_per_label_schema.get("maximum"),
"min_value": min_images_per_label_schema.get("minimum"),
}
]
},
"auto_training": [
{
"key": "enable",
"name": auto_training_schema["properties"]["enable"]["title"],
"description": auto_training_schema["properties"]["enable"]["description"],
"type": "bool",
"value": task_config.auto_training.enable,
"default_value": auto_training_schema["properties"]["enable"]["default"],
},
{
"key": "enable_dynamic_required_annotations",
"name": auto_training_schema["properties"]["enable_dynamic_required_annotations"]["title"],
"description": (
auto_training_schema["properties"]["enable_dynamic_required_annotations"]["description"]
),
"type": "bool",
"value": task_config.auto_training.enable_dynamic_required_annotations,
"default_value": (
auto_training_schema["properties"]["enable_dynamic_required_annotations"]["default"]
),
},
{
"key": "min_images_per_label",
"name": auto_training_schema["properties"]["min_images_per_label"]["title"],
"description": auto_training_schema["properties"]["min_images_per_label"]["description"],
"type": "int",
"value": task_config.auto_training.min_images_per_label,
"default_value": auto_training_schema["properties"]["min_images_per_label"]["default"],
"max_value": None,
"min_value": 0,
},
],
}
)
yield {"task_configs": tasks_rest_view}
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# Copyright (C) 2022-2025 Intel Corporation
# LIMITED EDGE SOFTWARE DISTRIBUTION LICENSE

import pytest
from testfixtures import compare

from communication.controllers.project_configuration_controller import ProjectConfigurationRESTController
from storage.repos.project_configuration_repo import ProjectConfigurationRepo

from geti_fastapi_tools.exceptions import ProjectNotFoundException
from geti_types import ID, ProjectIdentifier


@pytest.fixture
def project_configuration_controller():
return ProjectConfigurationRESTController()


class TestProjectConfigurationRESTController:
def test_get_configuration(
self,
request,
fxt_project_identifier,
project_configuration_controller,
fxt_project_configuration,
fxt_project_configuration_rest_view,
) -> None:
# Arrange
repo = ProjectConfigurationRepo(fxt_project_identifier)
request.addfinalizer(lambda: repo.delete_all())
repo.save(fxt_project_configuration)

# Act
result = project_configuration_controller.get_configuration(project_identifier=fxt_project_identifier)

# Convert to dict to compare with expected output
compare(result, fxt_project_configuration_rest_view, ignore_eq=True)

def test_get_configuration_not_found(self, project_configuration_controller) -> None:
project_id = ID("dummy_project_id")
workspace_id = ID("dummy_workspace_id")
project_identifier = ProjectIdentifier(workspace_id=workspace_id, project_id=project_id)

with pytest.raises(ProjectNotFoundException):
project_configuration_controller.get_configuration(project_identifier=project_identifier)
Loading
Loading