Skip to content

Commit 28cef56

Browse files
authored
ITEP-32484 - Implement GET project_configuration endpoint (#220)
1 parent 3cc9a3f commit 28cef56

File tree

10 files changed

+372
-0
lines changed

10 files changed

+372
-0
lines changed
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
# Copyright (C) 2022-2025 Intel Corporation
2+
# LIMITED EDGE SOFTWARE DISTRIBUTION LICENSE
3+
4+
from typing import Any
5+
6+
from geti_configuration_tools.project_configuration import NullProjectConfiguration
7+
8+
from communication.views.project_configuration_rest_views import ProjectConfigurationRESTViews
9+
from storage.repos.project_configuration_repo import ProjectConfigurationRepo
10+
11+
from geti_fastapi_tools.exceptions import ProjectNotFoundException
12+
from geti_telemetry_tools import unified_tracing
13+
from geti_types import ProjectIdentifier
14+
15+
16+
class ProjectConfigurationRESTController:
17+
@staticmethod
18+
@unified_tracing
19+
def get_configuration(
20+
project_identifier: ProjectIdentifier,
21+
) -> dict[str, Any]:
22+
"""
23+
Retrieves configuration related to a specific project.
24+
25+
:param project_identifier: Identifier for the project (containing organization_id, workspace_id, and project_id)
26+
:return: Dictionary representation of the project configuration
27+
"""
28+
project_config = ProjectConfigurationRepo(project_identifier).get_project_configuration()
29+
if isinstance(project_config, NullProjectConfiguration):
30+
raise ProjectNotFoundException(project_identifier.project_id)
31+
return ProjectConfigurationRESTViews.project_configuration_to_rest(project_config)
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
# Copyright (C) 2022-2025 Intel Corporation
2+
# LIMITED EDGE SOFTWARE DISTRIBUTION LICENSE
3+
4+
import logging
5+
from http import HTTPStatus
6+
from typing import Annotated, Any
7+
8+
from fastapi import APIRouter, Depends
9+
10+
from communication.controllers.project_configuration_controller import ProjectConfigurationRESTController
11+
from features.feature_flag_provider import FeatureFlag, FeatureFlagProvider
12+
13+
from geti_fastapi_tools.dependencies import get_project_identifier, setup_session_fastapi
14+
from geti_fastapi_tools.exceptions import GetiBaseException
15+
from geti_types import ProjectIdentifier
16+
17+
logger = logging.getLogger(__name__)
18+
19+
project_configuration_prefix_url = (
20+
"/api/v1/organizations/{organization_id}/workspaces/{workspace_id}/projects/{project_id}"
21+
)
22+
project_configuration_router = APIRouter(
23+
prefix=project_configuration_prefix_url,
24+
tags=["Configuration"],
25+
dependencies=[Depends(setup_session_fastapi)],
26+
)
27+
28+
29+
@project_configuration_router.get("/project_configuration")
30+
def get_project_configuration(
31+
project_identifier: Annotated[ProjectIdentifier, Depends(get_project_identifier)],
32+
) -> dict[str, Any]:
33+
"""Retrieve the configuration for a specific project."""
34+
if not FeatureFlagProvider.is_enabled(FeatureFlag.FEATURE_FLAG_NEW_CONFIGURABLE_PARAMETERS):
35+
raise GetiBaseException(
36+
message="Feature not available",
37+
error_code="feature_not_available",
38+
http_status=HTTPStatus.FORBIDDEN,
39+
)
40+
return ProjectConfigurationRESTController().get_configuration(project_identifier=project_identifier)

interactive_ai/services/director/app/communication/main.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
from communication.endpoints.model_tests_endpoints import model_test_router
2525
from communication.endpoints.optimization_endpoints import optimization_router
2626
from communication.endpoints.prediction_endpoints import prediction_router
27+
from communication.endpoints.project_configuration_endpoints import project_configuration_router
2728
from communication.endpoints.status_endpoints import status_router
2829
from communication.endpoints.supported_algorithms_endpoints import supported_algorithms_router
2930
from communication.endpoints.training_endpoints import training_router
@@ -85,6 +86,7 @@ async def lifespan(app: FastAPI): # type: ignore # noqa: ANN201
8586
app.include_router(status_router)
8687
app.include_router(training_router)
8788
app.include_router(supported_algorithms_router)
89+
app.include_router(project_configuration_router)
8890

8991
base_dir = os.path.dirname(__file__) + "/../../../api/schemas/"
9092
mongo_id_schema = RestApiValidator().load_schema_file_as_dict(base_dir + "mongo_id.yaml")
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
# Copyright (C) 2022-2025 Intel Corporation
2+
# LIMITED EDGE SOFTWARE DISTRIBUTION LICENSE
3+
4+
from typing import Any
5+
6+
from pydantic import BaseModel
7+
8+
PYDANTIC_TYPES_MAPPING = {
9+
"integer": "int",
10+
"number": "float",
11+
"boolean": "bool",
12+
"string": "str",
13+
}
14+
15+
16+
class ConfigurableParametersRESTViews:
17+
"""
18+
Base class for converting configurable parameters to REST views.
19+
20+
This class provides methods to transform Pydantic models and their fields
21+
into REST-compatible dictionary representations.
22+
"""
23+
24+
@staticmethod
25+
def _parameter_to_rest(key: str, value: float | str | bool, json_schema: dict) -> dict[str, Any]:
26+
"""
27+
Convert a single parameter to its REST representation.
28+
29+
:param key: The parameter name/key
30+
:param value: The parameter value (int, float, string, or boolean)
31+
:param json_schema: The JSON schema for the parameter from the Pydantic model
32+
:return: Dictionary containing the REST representation of the parameter
33+
"""
34+
rest_view = {
35+
"key": key,
36+
"name": json_schema.get("title"),
37+
"description": json_schema.get("description"),
38+
"value": value,
39+
"default_value": json_schema.get("default"),
40+
}
41+
# optional parameter may contain `'anyOf': [{'exclusiveMinimum': 0, 'type': 'integer'}, {'type': 'null'}]`
42+
type_any_of = json_schema.get("anyOf", [{}])[0]
43+
rest_view["type"] = PYDANTIC_TYPES_MAPPING.get(json_schema.get("type", type_any_of.get("type")))
44+
if rest_view["type"] in ["int", "float"]:
45+
rest_view["min_value"] = json_schema.get("minimum", type_any_of.get("exclusiveMinimum"))
46+
rest_view["max_value"] = json_schema.get("maximum", type_any_of.get("exclusiveMaximum"))
47+
return rest_view
48+
49+
@classmethod
50+
def configurable_parameters_to_rest(
51+
cls, configurable_parameters: BaseModel
52+
) -> dict[str, Any] | list[dict[str, Any]]:
53+
"""
54+
Convert a Pydantic model of configurable parameters to its REST representation.
55+
56+
This method processes a Pydantic model containing configuration parameters and transforms it
57+
into a REST view. It handles both simple fields and nested models:
58+
59+
- Simple fields (int, float, str, bool) are converted to a list of dictionaries with metadata
60+
including key, name, description, value, type, and constraints
61+
- Nested Pydantic models are processed recursively and maintained as nested structures
62+
63+
The return format depends on the content:
64+
- If only simple parameters exist: returns a list of parameter dictionaries
65+
- If only nested models exist: returns a dictionary mapping nested model names to their contents
66+
- If both exist: returns a list containing parameter dictionaries and nested model dictionary
67+
68+
:param configurable_parameters: Pydantic model containing configurable parameters
69+
:return: REST representation as either a dictionary of nested models,
70+
a list of parameter dictionaries, or a combined list of both
71+
"""
72+
nested_params: dict[str, Any] = {}
73+
list_params: list[dict[str, Any]] = []
74+
75+
for field_name in configurable_parameters.model_fields:
76+
field = getattr(configurable_parameters, field_name)
77+
if isinstance(field, BaseModel):
78+
# If the field is a nested Pydantic model, process it recursively
79+
nested_params[field_name] = cls.configurable_parameters_to_rest(field)
80+
else:
81+
# If the field is a simple type, convert directly to REST view
82+
json_model = configurable_parameters.model_json_schema()
83+
list_params.append(
84+
cls._parameter_to_rest(
85+
key=field_name,
86+
value=field,
87+
json_schema=json_model["properties"][field_name],
88+
)
89+
)
90+
91+
# Return combined or individual results based on content
92+
if nested_params and list_params:
93+
return [*list_params, nested_params]
94+
return list_params or nested_params
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
# Copyright (C) 2022-2025 Intel Corporation
2+
# LIMITED EDGE SOFTWARE DISTRIBUTION LICENSE
3+
4+
from geti_configuration_tools.project_configuration import ProjectConfiguration, TaskConfig
5+
6+
from communication.views.configurable_parameters_to_rest import ConfigurableParametersRESTViews
7+
8+
9+
class ProjectConfigurationRESTViews(ConfigurableParametersRESTViews):
10+
"""
11+
Converters between ProjectConfiguration models and their corresponding REST views
12+
"""
13+
14+
@classmethod
15+
def task_config_to_rest(cls, task_config: TaskConfig) -> dict:
16+
"""
17+
Get the REST view of a task configuration
18+
19+
:param task_config: Task configuration object
20+
:return: REST view of the task configuration
21+
"""
22+
return {
23+
"task_id": task_config.task_id,
24+
"training": cls.configurable_parameters_to_rest(task_config.training),
25+
"auto_training": cls.configurable_parameters_to_rest(task_config.auto_training),
26+
}
27+
28+
@classmethod
29+
def project_configuration_to_rest(cls, project_configuration: ProjectConfiguration) -> dict:
30+
"""
31+
Get the REST view of a project configuration
32+
33+
:param project_configuration: Project configuration object
34+
:return: REST view of the project configuration
35+
"""
36+
return {
37+
"task_configs": [
38+
cls.task_config_to_rest(task_config) for task_config in project_configuration.task_configs
39+
],
40+
}

interactive_ai/services/director/chart/templates/http_route.yaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,9 @@ spec:
4242
- path:
4343
type: RegularExpression
4444
value: /api/v(.*)/organizations/([^/]*)/workspaces/(.*)/projects/.*/supported_algorithms
45+
- path:
46+
type: RegularExpression
47+
value: /api/v(.*)/organizations/([^/]*)/workspaces/(.*)/projects/.*/project_configuration
4548
backendRefs:
4649
- name: {{ .Release.Namespace }}-{{ .Chart.Name }}
4750
port: {{ .Values.service.ports.director.port }}

interactive_ai/services/director/chart/values.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ ingress:
3535
- /api/v(.*)/organizations/([^/]*)/workspaces/(.*)/projects/(.*):train
3636
- /api/v(.*)/organizations/([^/]*)/workspaces/(.*)/projects/(.*)/model_groups/(.*)/models/(.*):optimize
3737
- /api/v(.*)/organizations/([^/]*)/workspaces/(.*)/projects/(.*)/supported_algorithms
38+
- /api/v(.*)/organizations/([^/]*)/workspaces/(.*)/projects/(.*)/project_configuration
3839

3940
serviceAccount:
4041
create: true

interactive_ai/services/director/tests/fixtures/project_configuration.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,3 +36,65 @@ def fxt_project_configuration(fxt_project_identifier):
3636
),
3737
],
3838
)
39+
40+
41+
@pytest.fixture
42+
def fxt_project_configuration_rest_view(fxt_project_configuration):
43+
tasks_rest_view = []
44+
for task_config in fxt_project_configuration.task_configs:
45+
min_images_per_label_schema = task_config.training.constraints.model_json_schema()["properties"][
46+
"min_images_per_label"
47+
]
48+
auto_training_schema = task_config.auto_training.model_json_schema()
49+
tasks_rest_view.append(
50+
{
51+
"task_id": task_config.task_id,
52+
"training": {
53+
"constraints": [
54+
{
55+
"key": "min_images_per_label",
56+
"name": min_images_per_label_schema["title"],
57+
"description": min_images_per_label_schema["description"],
58+
"type": "int",
59+
"value": task_config.training.constraints.min_images_per_label,
60+
"default_value": min_images_per_label_schema["default"],
61+
"max_value": min_images_per_label_schema.get("maximum"),
62+
"min_value": min_images_per_label_schema.get("minimum"),
63+
}
64+
]
65+
},
66+
"auto_training": [
67+
{
68+
"key": "enable",
69+
"name": auto_training_schema["properties"]["enable"]["title"],
70+
"description": auto_training_schema["properties"]["enable"]["description"],
71+
"type": "bool",
72+
"value": task_config.auto_training.enable,
73+
"default_value": auto_training_schema["properties"]["enable"]["default"],
74+
},
75+
{
76+
"key": "enable_dynamic_required_annotations",
77+
"name": auto_training_schema["properties"]["enable_dynamic_required_annotations"]["title"],
78+
"description": (
79+
auto_training_schema["properties"]["enable_dynamic_required_annotations"]["description"]
80+
),
81+
"type": "bool",
82+
"value": task_config.auto_training.enable_dynamic_required_annotations,
83+
"default_value": (
84+
auto_training_schema["properties"]["enable_dynamic_required_annotations"]["default"]
85+
),
86+
},
87+
{
88+
"key": "min_images_per_label",
89+
"name": auto_training_schema["properties"]["min_images_per_label"]["title"],
90+
"description": auto_training_schema["properties"]["min_images_per_label"]["description"],
91+
"type": "int",
92+
"value": task_config.auto_training.min_images_per_label,
93+
"default_value": auto_training_schema["properties"]["min_images_per_label"]["default"],
94+
"max_value": None,
95+
"min_value": 0,
96+
},
97+
],
98+
}
99+
)
100+
yield {"task_configs": tasks_rest_view}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
# Copyright (C) 2022-2025 Intel Corporation
2+
# LIMITED EDGE SOFTWARE DISTRIBUTION LICENSE
3+
4+
import pytest
5+
from testfixtures import compare
6+
7+
from communication.controllers.project_configuration_controller import ProjectConfigurationRESTController
8+
from storage.repos.project_configuration_repo import ProjectConfigurationRepo
9+
10+
from geti_fastapi_tools.exceptions import ProjectNotFoundException
11+
from geti_types import ID, ProjectIdentifier
12+
13+
14+
@pytest.fixture
15+
def project_configuration_controller():
16+
return ProjectConfigurationRESTController()
17+
18+
19+
class TestProjectConfigurationRESTController:
20+
def test_get_configuration(
21+
self,
22+
request,
23+
fxt_project_identifier,
24+
project_configuration_controller,
25+
fxt_project_configuration,
26+
fxt_project_configuration_rest_view,
27+
) -> None:
28+
# Arrange
29+
repo = ProjectConfigurationRepo(fxt_project_identifier)
30+
request.addfinalizer(lambda: repo.delete_all())
31+
repo.save(fxt_project_configuration)
32+
33+
# Act
34+
result = project_configuration_controller.get_configuration(project_identifier=fxt_project_identifier)
35+
36+
# Convert to dict to compare with expected output
37+
compare(result, fxt_project_configuration_rest_view, ignore_eq=True)
38+
39+
def test_get_configuration_not_found(self, project_configuration_controller) -> None:
40+
project_id = ID("dummy_project_id")
41+
workspace_id = ID("dummy_workspace_id")
42+
project_identifier = ProjectIdentifier(workspace_id=workspace_id, project_id=project_id)
43+
44+
with pytest.raises(ProjectNotFoundException):
45+
project_configuration_controller.get_configuration(project_identifier=project_identifier)

0 commit comments

Comments
 (0)