orchestrator-core 3.1.2rc4__py3-none-any.whl → 3.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. orchestrator/__init__.py +1 -1
  2. orchestrator/api/api_v1/endpoints/processes.py +6 -9
  3. orchestrator/cli/generator/generator/workflow.py +13 -1
  4. orchestrator/cli/generator/templates/modify_product.j2 +9 -0
  5. orchestrator/db/__init__.py +2 -0
  6. orchestrator/db/loaders.py +51 -3
  7. orchestrator/db/models.py +13 -0
  8. orchestrator/db/queries/__init__.py +0 -0
  9. orchestrator/db/queries/subscription.py +85 -0
  10. orchestrator/db/queries/subscription_instance.py +28 -0
  11. orchestrator/domain/base.py +162 -44
  12. orchestrator/domain/context_cache.py +62 -0
  13. orchestrator/domain/helpers.py +41 -1
  14. orchestrator/domain/subscription_instance_transform.py +114 -0
  15. orchestrator/graphql/resolvers/process.py +3 -3
  16. orchestrator/graphql/resolvers/product.py +2 -2
  17. orchestrator/graphql/resolvers/product_block.py +2 -2
  18. orchestrator/graphql/resolvers/resource_type.py +2 -2
  19. orchestrator/graphql/resolvers/workflow.py +2 -2
  20. orchestrator/graphql/utils/get_query_loaders.py +6 -48
  21. orchestrator/graphql/utils/get_subscription_product_blocks.py +8 -1
  22. orchestrator/migrations/versions/schema/2025-03-06_42b3d076a85b_subscription_instance_as_json_function.py +33 -0
  23. orchestrator/migrations/versions/schema/2025-03-06_42b3d076a85b_subscription_instance_as_json_function.sql +40 -0
  24. orchestrator/migrations/versions/schema/2025-04-09_fc5c993a4b4a_add_cascade_constraint_on_processes_.py +44 -0
  25. orchestrator/services/processes.py +28 -9
  26. orchestrator/services/subscriptions.py +36 -6
  27. orchestrator/settings.py +3 -0
  28. orchestrator/utils/functional.py +9 -0
  29. orchestrator/utils/redis.py +6 -0
  30. orchestrator/workflow.py +29 -6
  31. orchestrator/workflows/utils.py +40 -5
  32. {orchestrator_core-3.1.2rc4.dist-info → orchestrator_core-3.2.0.dist-info}/METADATA +9 -8
  33. {orchestrator_core-3.1.2rc4.dist-info → orchestrator_core-3.2.0.dist-info}/RECORD +36 -28
  34. /orchestrator/migrations/versions/schema/{2025-10-19_4fjdn13f83ga_add_validate_product_type_task.py → 2025-01-19_4fjdn13f83ga_add_validate_product_type_task.py} +0 -0
  35. {orchestrator_core-3.1.2rc4.dist-info → orchestrator_core-3.2.0.dist-info}/WHEEL +0 -0
  36. {orchestrator_core-3.1.2rc4.dist-info → orchestrator_core-3.2.0.dist-info}/licenses/LICENSE +0 -0
orchestrator/__init__.py CHANGED
@@ -13,7 +13,7 @@
13
13
 
14
14
  """This is the orchestrator workflow engine."""
15
15
 
16
- __version__ = "3.1.2rc4"
16
+ __version__ = "3.2.0"
17
17
 
18
18
  from orchestrator.app import OrchestratorCore
19
19
  from orchestrator.settings import app_settings
@@ -1,4 +1,4 @@
1
- # Copyright 2019-2025 SURF, GÉANT.
1
+ # Copyright 2019-2025 SURF, GÉANT, ESnet.
2
2
  # Licensed under the Apache License, Version 2.0 (the "License");
3
3
  # you may not use this file except in compliance with the License.
4
4
  # You may obtain a copy of the License at
@@ -40,13 +40,7 @@ from orchestrator.db.filters import Filter
40
40
  from orchestrator.db.filters.process import filter_processes
41
41
  from orchestrator.db.sorting import Sort, SortOrder
42
42
  from orchestrator.db.sorting.process import sort_processes
43
- from orchestrator.schemas import (
44
- ProcessIdSchema,
45
- ProcessResumeAllSchema,
46
- ProcessSchema,
47
- ProcessStatusCounts,
48
- Reporter,
49
- )
43
+ from orchestrator.schemas import ProcessIdSchema, ProcessResumeAllSchema, ProcessSchema, ProcessStatusCounts, Reporter
50
44
  from orchestrator.security import authenticate
51
45
  from orchestrator.services.process_broadcast_thread import api_broadcast_process_data
52
46
  from orchestrator.services.processes import (
@@ -139,9 +133,12 @@ async def new_process(
139
133
  request: Request,
140
134
  json_data: list[dict[str, Any]] | None = Body(...),
141
135
  user: str = Depends(user_name),
136
+ user_model: OIDCUserModel | None = Depends(authenticate),
142
137
  ) -> dict[str, UUID]:
143
138
  broadcast_func = api_broadcast_process_data(request)
144
- process_id = start_process(workflow_key, user_inputs=json_data, user=user, broadcast_func=broadcast_func)
139
+ process_id = start_process(
140
+ workflow_key, user_inputs=json_data, user_model=user_model, user=user, broadcast_func=broadcast_func
141
+ )
145
142
 
146
143
  return {"id": process_id}
147
144
 
@@ -10,12 +10,13 @@
10
10
  # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11
11
  # See the License for the specific language governing permissions and
12
12
  # limitations under the License.
13
-
14
13
  from collections.abc import Callable
15
14
  from functools import partial, wraps
15
+ from importlib import metadata
16
16
  from pathlib import Path
17
17
  from typing import Any
18
18
 
19
+ import semver
19
20
  import structlog
20
21
  from jinja2 import Environment
21
22
 
@@ -123,7 +124,17 @@ def get_product_workflow_path(config: dict, workflow_type: str) -> Path:
123
124
  return product_workflow_folder(config) / Path(f"{workflow_type}_{file_name}").with_suffix(".py")
124
125
 
125
126
 
127
+ def eval_pydantic_forms_version() -> bool:
128
+ updated_version = semver.Version.parse("2.0.0")
129
+
130
+ installed_version = metadata.version("pydantic-forms")
131
+ installed_semver = semver.Version.parse(installed_version)
132
+
133
+ return installed_semver >= updated_version
134
+
135
+
126
136
  def render_template(environment: Environment, config: dict, template: str, workflow: str = "") -> str:
137
+ use_updated_readonly_field = eval_pydantic_forms_version()
127
138
  product_block = root_product_block(config)
128
139
  types_to_import = get_name_spaced_types_to_import(product_block["fields"])
129
140
  fields = get_input_fields(product_block)
@@ -152,6 +163,7 @@ def render_template(environment: Environment, config: dict, template: str, workf
152
163
  product_types_module=get_product_types_module(),
153
164
  workflows_module=get_workflows_module(),
154
165
  workflow_validations=workflow_validations if workflow else [],
166
+ use_updated_readonly_field=use_updated_readonly_field,
155
167
  )
156
168
 
157
169
 
@@ -7,7 +7,12 @@ from typing import Annotated
7
7
  import structlog
8
8
  from pydantic import AfterValidator, ConfigDict, model_validator
9
9
  from pydantic_forms.types import FormGenerator, State, UUIDstr
10
+
11
+ {%- if use_updated_readonly_field is true %}
12
+ from pydantic_forms.validators import read_only_field
13
+ {%- else %}
10
14
  from pydantic_forms.validators import ReadOnlyField
15
+ {%- endif %}
11
16
 
12
17
  from orchestrator.forms import FormPage
13
18
  from orchestrator.forms.validators import CustomerId, Divider
@@ -52,7 +57,11 @@ def initial_input_form_generator(subscription_id: UUIDstr) -> FormGenerator:
52
57
  divider_1: Divider
53
58
 
54
59
  {% for field in fields if not field.modifiable is defined -%}
60
+ {% if use_updated_readonly_field is true -%}
61
+ {{ field.name }}: read_only_field({{ product_block.name }}.{{ field.name }})
62
+ {% else -%}
55
63
  {{ field.name }}: ReadOnlyField({{ product_block.name }}.{{ field.name }})
64
+ {% endif -%}
56
65
  {% endfor -%}
57
66
 
58
67
  {% for field in fields if field.modifiable is defined -%}
@@ -19,6 +19,7 @@ from orchestrator.db.database import Database, transactional
19
19
  from orchestrator.db.models import ( # noqa: F401
20
20
  EngineSettingsTable,
21
21
  FixedInputTable,
22
+ InputStateTable,
22
23
  ProcessStepTable,
23
24
  ProcessSubscriptionTable,
24
25
  ProcessTable,
@@ -85,6 +86,7 @@ __all__ = [
85
86
  "SubscriptionMetadataTable",
86
87
  "ResourceTypeTable",
87
88
  "FixedInputTable",
89
+ "InputStateTable",
88
90
  "EngineSettingsTable",
89
91
  "WorkflowTable",
90
92
  "SubscriptionCustomerDescriptionTable",
@@ -1,5 +1,5 @@
1
1
  from functools import reduce
2
- from typing import Any, Callable, Iterator, NamedTuple, cast
2
+ from typing import Any, Callable, Iterable, Iterator, NamedTuple, cast
3
3
 
4
4
  import structlog
5
5
  from sqlalchemy import inspect
@@ -131,12 +131,15 @@ def init_model_loaders() -> None:
131
131
  _MODEL_LOADERS[model] = dict(_inspect_model(model))
132
132
 
133
133
 
134
- def lookup_attr_loaders(model: type[DbBaseModel], attr: str) -> list[AttrLoader]:
134
+ def _lookup_attr_loaders(model: type[DbBaseModel], attr: str) -> list[AttrLoader]:
135
135
  """Return loader(s) for an attribute on the given model."""
136
+ if not _MODEL_LOADERS:
137
+ # Ensure loaders are always initialized
138
+ init_model_loaders()
136
139
  return _MODEL_LOADERS.get(model, {}).get(attr, [])
137
140
 
138
141
 
139
- def join_attr_loaders(loaders: list[AttrLoader]) -> Load | None:
142
+ def _join_attr_loaders(loaders: list[AttrLoader]) -> Load | None:
140
143
  """Given 1 or more attribute loaders, instantiate and chain them together."""
141
144
  if not loaders:
142
145
  return None
@@ -151,3 +154,48 @@ def join_attr_loaders(loaders: list[AttrLoader]) -> Load | None:
151
154
  return getattr(final_loader, next.loader_fn.__name__)(next.attr)
152
155
 
153
156
  return reduce(chain_loader_func, other_loaders, loader_fn)
157
+
158
+
159
+ def _split_path(query_path: str) -> Iterable[str]:
160
+ yield from (field for field in query_path.split("."))
161
+
162
+
163
+ def get_query_loaders_for_model_paths(root_model: type[DbBaseModel], model_paths: list[str]) -> list[Load]:
164
+ """Get sqlalchemy query loaders to use for the model based on the paths."""
165
+ # Sort by length to find the longest match first
166
+ model_paths.sort(key=lambda x: x.count("."), reverse=True)
167
+
168
+ def get_loader_for_path(model_path: str) -> tuple[str, Load | None]:
169
+ next_model = root_model
170
+
171
+ matched_fields: list[str] = []
172
+ path_loaders: list[AttrLoader] = []
173
+
174
+ for field in _split_path(model_path):
175
+ if not (attr_loaders := _lookup_attr_loaders(next_model, field)):
176
+ break
177
+
178
+ matched_fields.append(field)
179
+ path_loaders.extend(attr_loaders)
180
+ next_model = attr_loaders[-1].next_model
181
+
182
+ return ".".join(matched_fields), _join_attr_loaders(path_loaders)
183
+
184
+ query_loaders: dict[str, Load] = {}
185
+
186
+ for path in model_paths:
187
+ matched_path, loader = get_loader_for_path(path)
188
+ if not matched_path or not loader or matched_path in query_loaders:
189
+ continue
190
+ if any(known_path.startswith(f"{matched_path}.") for known_path in query_loaders):
191
+ continue
192
+ query_loaders[matched_path] = loader
193
+
194
+ loaders = list(query_loaders.values())
195
+ logger.debug(
196
+ "Generated query loaders for paths",
197
+ root_model=root_model,
198
+ model_paths=model_paths,
199
+ query_loaders=[str(i.path) for i in loaders],
200
+ )
201
+ return loaders
orchestrator/db/models.py CHANGED
@@ -15,6 +15,7 @@ from __future__ import annotations
15
15
 
16
16
  import enum
17
17
  from datetime import datetime, timezone
18
+ from uuid import UUID
18
19
 
19
20
  import sqlalchemy
20
21
  import structlog
@@ -43,6 +44,7 @@ from sqlalchemy.exc import DontWrapMixin
43
44
  from sqlalchemy.ext.associationproxy import association_proxy
44
45
  from sqlalchemy.ext.orderinglist import ordering_list
45
46
  from sqlalchemy.orm import Mapped, deferred, mapped_column, object_session, relationship, undefer
47
+ from sqlalchemy.sql.functions import GenericFunction
46
48
  from sqlalchemy_utils import TSVectorType, UUIDType
47
49
 
48
50
  from orchestrator.config.assignee import Assignee
@@ -667,3 +669,14 @@ class EngineSettingsTable(BaseModel):
667
669
  global_lock = mapped_column(Boolean(), default=False, nullable=False, primary_key=True)
668
670
  running_processes = mapped_column(Integer(), default=0, nullable=False)
669
671
  __table_args__: tuple = (CheckConstraint(running_processes >= 0, name="check_running_processes_positive"), {})
672
+
673
+
674
+ class SubscriptionInstanceAsJsonFunction(GenericFunction):
675
+ # Added in migration 42b3d076a85b
676
+ name = "subscription_instance_as_json"
677
+
678
+ type = pg.JSONB()
679
+ inherit_cache = True
680
+
681
+ def __init__(self, sub_inst_id: UUID):
682
+ super().__init__(sub_inst_id)
File without changes
@@ -0,0 +1,85 @@
1
+ # Copyright 2019-2025 SURF.
2
+ # Licensed under the Apache License, Version 2.0 (the "License");
3
+ # you may not use this file except in compliance with the License.
4
+ # You may obtain a copy of the License at
5
+ #
6
+ # http://www.apache.org/licenses/LICENSE-2.0
7
+ #
8
+ # Unless required by applicable law or agreed to in writing, software
9
+ # distributed under the License is distributed on an "AS IS" BASIS,
10
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11
+ # See the License for the specific language governing permissions and
12
+ # limitations under the License.
13
+
14
+ from uuid import UUID
15
+
16
+ from sqlalchemy import UUID as SA_UUID
17
+ from sqlalchemy import cast as sa_cast
18
+ from sqlalchemy import select
19
+ from sqlalchemy.orm import raiseload
20
+
21
+ from orchestrator.db import SubscriptionInstanceRelationTable, SubscriptionInstanceTable, db
22
+
23
+
24
+ def _eagerload_subscription_instances(
25
+ subscription_id: UUID | str, instance_attributes: list[str]
26
+ ) -> list[SubscriptionInstanceTable]:
27
+ """Given a subscription id, recursively query all depends_on subscription instances with the instance_attributes eagerloaded.
28
+
29
+ Note: accessing instance attributes on the result that were not explicitly loaded will
30
+ trigger a sqlalchemy error.
31
+ """
32
+ from orchestrator.db.loaders import get_query_loaders_for_model_paths
33
+
34
+ # CTE to recursively get all subscription instance ids the subscription depends on
35
+ instance_ids_cte = (
36
+ select(
37
+ sa_cast(None, SA_UUID(as_uuid=True)).label("in_use_by_id"),
38
+ SubscriptionInstanceTable.subscription_instance_id.label("depends_on_id"),
39
+ )
40
+ .where(SubscriptionInstanceTable.subscription_id == subscription_id)
41
+ .cte(name="recursive_instance_ids", recursive=True)
42
+ )
43
+
44
+ cte_alias = instance_ids_cte.alias()
45
+ rel_alias = select(SubscriptionInstanceRelationTable).alias()
46
+
47
+ instance_ids = instance_ids_cte.union(
48
+ select(rel_alias.c.in_use_by_id, rel_alias.c.depends_on_id).where(
49
+ rel_alias.c.in_use_by_id == cte_alias.c.depends_on_id
50
+ )
51
+ )
52
+
53
+ select_all_instance_ids = select(instance_ids.c.depends_on_id).subquery()
54
+
55
+ # Eagerload specified instance attributes
56
+ query_loaders = get_query_loaders_for_model_paths(SubscriptionInstanceTable, instance_attributes)
57
+ # Prevent unwanted lazyloading of all other attributes
58
+ query_loaders += [raiseload("*")] # type: ignore[list-item] # todo fix this type
59
+ stmt = (
60
+ select(SubscriptionInstanceTable)
61
+ .where(SubscriptionInstanceTable.subscription_instance_id.in_(select(select_all_instance_ids)))
62
+ .options(*query_loaders)
63
+ )
64
+
65
+ return db.session.scalars(stmt).all() # type: ignore[return-value] # todo fix this type
66
+
67
+
68
+ def eagerload_all_subscription_instances(subscription_id: UUID | str) -> list[SubscriptionInstanceTable]:
69
+ """Recursively find the subscription's depends_on instances and resolve relations for SubscriptionModel.from_subscription_id()."""
70
+ instance_attributes = [
71
+ "subscription.product",
72
+ "product_block",
73
+ "values.resource_type",
74
+ "depends_on",
75
+ "in_use_by",
76
+ ]
77
+ return _eagerload_subscription_instances(subscription_id, instance_attributes)
78
+
79
+
80
+ def eagerload_all_subscription_instances_only_inuseby(subscription_id: UUID | str) -> list[SubscriptionInstanceTable]:
81
+ """Recursively find the subscription's depends_on instances and resolve their in_use_by relations."""
82
+ instance_attributes = [
83
+ "in_use_by",
84
+ ]
85
+ return _eagerload_subscription_instances(subscription_id, instance_attributes)
@@ -0,0 +1,28 @@
1
+ # Copyright 2019-2025 SURF.
2
+ # Licensed under the Apache License, Version 2.0 (the "License");
3
+ # you may not use this file except in compliance with the License.
4
+ # You may obtain a copy of the License at
5
+ #
6
+ # http://www.apache.org/licenses/LICENSE-2.0
7
+ #
8
+ # Unless required by applicable law or agreed to in writing, software
9
+ # distributed under the License is distributed on an "AS IS" BASIS,
10
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11
+ # See the License for the specific language governing permissions and
12
+ # limitations under the License.
13
+
14
+ from uuid import UUID
15
+
16
+ from sqlalchemy import select
17
+
18
+ from orchestrator.db import db
19
+ from orchestrator.db.models import SubscriptionInstanceAsJsonFunction
20
+
21
+
22
+ def get_subscription_instance_dict(subscription_instance_id: UUID) -> dict:
23
+ """Query the subscription instance as aggregated JSONB and returns it as a dict.
24
+
25
+ Note: all values are returned as lists and have to be transformed by the caller.
26
+ It was attempted to do this in the DB query but this gave worse performance.
27
+ """
28
+ return db.session.execute(select(SubscriptionInstanceAsJsonFunction(subscription_instance_id))).scalar_one()