orchestrator-core 3.1.2rc3__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 (68) hide show
  1. orchestrator/__init__.py +2 -2
  2. orchestrator/api/api_v1/api.py +1 -1
  3. orchestrator/api/api_v1/endpoints/processes.py +6 -9
  4. orchestrator/api/api_v1/endpoints/settings.py +1 -1
  5. orchestrator/api/api_v1/endpoints/subscriptions.py +1 -1
  6. orchestrator/app.py +1 -1
  7. orchestrator/cli/database.py +1 -1
  8. orchestrator/cli/generator/generator/migration.py +2 -5
  9. orchestrator/cli/generator/generator/workflow.py +13 -1
  10. orchestrator/cli/generator/templates/modify_product.j2 +9 -0
  11. orchestrator/cli/migrate_tasks.py +13 -0
  12. orchestrator/config/assignee.py +1 -1
  13. orchestrator/db/__init__.py +2 -0
  14. orchestrator/db/loaders.py +51 -3
  15. orchestrator/db/models.py +14 -1
  16. orchestrator/db/queries/__init__.py +0 -0
  17. orchestrator/db/queries/subscription.py +85 -0
  18. orchestrator/db/queries/subscription_instance.py +28 -0
  19. orchestrator/devtools/populator.py +1 -1
  20. orchestrator/domain/__init__.py +2 -3
  21. orchestrator/domain/base.py +236 -49
  22. orchestrator/domain/context_cache.py +62 -0
  23. orchestrator/domain/helpers.py +41 -1
  24. orchestrator/domain/lifecycle.py +1 -1
  25. orchestrator/domain/subscription_instance_transform.py +114 -0
  26. orchestrator/graphql/resolvers/process.py +3 -3
  27. orchestrator/graphql/resolvers/product.py +2 -2
  28. orchestrator/graphql/resolvers/product_block.py +2 -2
  29. orchestrator/graphql/resolvers/resource_type.py +2 -2
  30. orchestrator/graphql/resolvers/workflow.py +2 -2
  31. orchestrator/graphql/schema.py +1 -1
  32. orchestrator/graphql/types.py +1 -1
  33. orchestrator/graphql/utils/get_query_loaders.py +6 -48
  34. orchestrator/graphql/utils/get_subscription_product_blocks.py +21 -1
  35. orchestrator/migrations/env.py +1 -1
  36. orchestrator/migrations/helpers.py +6 -6
  37. orchestrator/migrations/versions/schema/2025-03-06_42b3d076a85b_subscription_instance_as_json_function.py +33 -0
  38. orchestrator/migrations/versions/schema/2025-03-06_42b3d076a85b_subscription_instance_as_json_function.sql +40 -0
  39. orchestrator/migrations/versions/schema/2025-04-09_fc5c993a4b4a_add_cascade_constraint_on_processes_.py +44 -0
  40. orchestrator/schemas/engine_settings.py +1 -1
  41. orchestrator/schemas/subscription.py +1 -1
  42. orchestrator/security.py +1 -1
  43. orchestrator/services/celery.py +1 -1
  44. orchestrator/services/processes.py +28 -9
  45. orchestrator/services/products.py +1 -1
  46. orchestrator/services/subscriptions.py +37 -7
  47. orchestrator/services/tasks.py +1 -1
  48. orchestrator/settings.py +5 -23
  49. orchestrator/targets.py +1 -1
  50. orchestrator/types.py +1 -1
  51. orchestrator/utils/errors.py +1 -1
  52. orchestrator/utils/functional.py +9 -0
  53. orchestrator/utils/redis.py +6 -0
  54. orchestrator/utils/state.py +1 -1
  55. orchestrator/websocket/websocket_manager.py +1 -1
  56. orchestrator/workflow.py +29 -6
  57. orchestrator/workflows/modify_note.py +1 -1
  58. orchestrator/workflows/steps.py +1 -1
  59. orchestrator/workflows/tasks/cleanup_tasks_log.py +1 -1
  60. orchestrator/workflows/tasks/resume_workflows.py +1 -1
  61. orchestrator/workflows/tasks/validate_product_type.py +1 -1
  62. orchestrator/workflows/tasks/validate_products.py +1 -1
  63. orchestrator/workflows/utils.py +40 -5
  64. {orchestrator_core-3.1.2rc3.dist-info → orchestrator_core-3.2.0.dist-info}/METADATA +10 -9
  65. {orchestrator_core-3.1.2rc3.dist-info → orchestrator_core-3.2.0.dist-info}/RECORD +68 -60
  66. {orchestrator_core-3.1.2rc3.dist-info → orchestrator_core-3.2.0.dist-info}/WHEEL +1 -1
  67. /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
  68. {orchestrator_core-3.1.2rc3.dist-info → orchestrator_core-3.2.0.dist-info}/licenses/LICENSE +0 -0
@@ -1,4 +1,4 @@
1
- # Copyright 2019-2020 SURF.
1
+ # Copyright 2019-2020 SURF, GÉANT.
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
@@ -1,4 +1,4 @@
1
- # Copyright 2019-2020 SURF.
1
+ # Copyright 2019-2020 SURF, GÉANT.
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
@@ -22,6 +22,7 @@ from uuid import UUID
22
22
 
23
23
  import more_itertools
24
24
  import structlog
25
+ from more_itertools import first
25
26
  from sqlalchemy import Text, cast, not_, select
26
27
  from sqlalchemy.exc import SQLAlchemyError
27
28
  from sqlalchemy.orm import Query, aliased, joinedload
@@ -41,7 +42,11 @@ from orchestrator.db.models import (
41
42
  SubscriptionInstanceRelationTable,
42
43
  SubscriptionMetadataTable,
43
44
  )
45
+ from orchestrator.db.queries.subscription import (
46
+ eagerload_all_subscription_instances_only_inuseby,
47
+ )
44
48
  from orchestrator.domain.base import SubscriptionModel
49
+ from orchestrator.domain.context_cache import cache_subscription_models
45
50
  from orchestrator.targets import Target
46
51
  from orchestrator.types import SubscriptionLifecycle
47
52
  from orchestrator.utils.datetime import nowtz
@@ -594,13 +599,15 @@ def convert_to_in_use_by_relation(obj: Any) -> dict[str, str]:
594
599
 
595
600
  def build_extended_domain_model(subscription_model: SubscriptionModel) -> dict:
596
601
  """Create a subscription dict from the SubscriptionModel with additional keys."""
597
- stmt = select(SubscriptionCustomerDescriptionTable).filter(
602
+ from orchestrator.settings import app_settings
603
+
604
+ stmt = select(SubscriptionCustomerDescriptionTable).where(
598
605
  SubscriptionCustomerDescriptionTable.subscription_id == subscription_model.subscription_id
599
606
  )
600
607
  customer_descriptions = list(db.session.scalars(stmt))
601
608
 
602
- subscription = subscription_model.model_dump()
603
- paths = product_block_paths(subscription)
609
+ with cache_subscription_models():
610
+ subscription = subscription_model.model_dump()
604
611
 
605
612
  def inject_in_use_by_ids(path_to_block: str) -> None:
606
613
  if not (in_use_by_subs := getattr_in(subscription_model, f"{path_to_block}.in_use_by")):
@@ -611,15 +618,38 @@ def build_extended_domain_model(subscription_model: SubscriptionModel) -> dict:
611
618
  update_in(subscription, f"{path_to_block}.in_use_by_ids", in_use_by_ids)
612
619
  update_in(subscription, f"{path_to_block}.in_use_by_relations", in_use_by_relations)
613
620
 
614
- # find all product blocks, check if they have in_use_by and inject the in_use_by_ids into the subscription dict.
615
- for path in paths:
616
- inject_in_use_by_ids(path)
621
+ if app_settings.ENABLE_SUBSCRIPTION_MODEL_OPTIMIZATIONS:
622
+ # TODO #900 remove toggle and make this path the default
623
+ # query all subscription instances and inject the in_use_by_ids/in_use_by_relations into the subscription dict.
624
+ instance_to_in_use_by = {
625
+ instance.subscription_instance_id: instance.in_use_by
626
+ for instance in eagerload_all_subscription_instances_only_inuseby(subscription_model.subscription_id)
627
+ }
628
+ inject_in_use_by_ids_v2(subscription, instance_to_in_use_by)
629
+ else:
630
+ # find all product blocks, check if they have in_use_by and inject the in_use_by_ids into the subscription dict.
631
+ for path in product_block_paths(subscription):
632
+ inject_in_use_by_ids(path)
617
633
 
618
634
  subscription["customer_descriptions"] = customer_descriptions
619
635
 
620
636
  return subscription
621
637
 
622
638
 
639
+ def inject_in_use_by_ids_v2(dikt: dict, instance_to_in_use_by: dict[UUID, Sequence[SubscriptionInstanceTable]]) -> None:
640
+ for value in dikt.values():
641
+ if isinstance(value, dict):
642
+ inject_in_use_by_ids_v2(value, instance_to_in_use_by)
643
+ elif isinstance(value, list) and isinstance(first(value, None), dict):
644
+ for item in value:
645
+ inject_in_use_by_ids_v2(item, instance_to_in_use_by)
646
+
647
+ if subscription_instance_id := dikt.get("subscription_instance_id"):
648
+ in_use_by_subs = instance_to_in_use_by[subscription_instance_id]
649
+ dikt["in_use_by_ids"] = [i.subscription_instance_id for i in in_use_by_subs]
650
+ dikt["in_use_by_relations"] = [convert_to_in_use_by_relation(instance) for instance in in_use_by_subs]
651
+
652
+
623
653
  def format_special_types(subscription: dict) -> dict:
624
654
  """Modifies the subscription dict in-place, formatting special types to string.
625
655
 
@@ -1,4 +1,4 @@
1
- # Copyright 2019-2020 SURF.
1
+ # Copyright 2019-2020 SURF, GÉANT.
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
orchestrator/settings.py CHANGED
@@ -1,4 +1,4 @@
1
- # Copyright 2019-2020 SURF.
1
+ # Copyright 2019-2020 SURF, GÉANT.
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
@@ -13,7 +13,6 @@
13
13
 
14
14
  import secrets
15
15
  import string
16
- import warnings
17
16
  from pathlib import Path
18
17
  from typing import Literal
19
18
 
@@ -24,10 +23,6 @@ from oauth2_lib.settings import oauth2lib_settings
24
23
  from pydantic_forms.types import strEnum
25
24
 
26
25
 
27
- class OrchestratorDeprecationWarning(DeprecationWarning):
28
- pass
29
-
30
-
31
26
  class ExecutorType(strEnum):
32
27
  WORKER = "celery"
33
28
  THREADPOOL = "threadpool"
@@ -54,7 +49,7 @@ class AppSettings(BaseSettings):
54
49
  EXECUTOR: str = ExecutorType.THREADPOOL
55
50
  WORKFLOWS_SWAGGER_HOST: str = "localhost"
56
51
  WORKFLOWS_GUI_URI: str = "http://localhost:3000"
57
- DATABASE_URI: PostgresDsn = "postgresql+psycopg://nwa:nwa@localhost/orchestrator-core" # type: ignore
52
+ DATABASE_URI: PostgresDsn = "postgresql://nwa:nwa@localhost/orchestrator-core" # type: ignore
58
53
  MAX_WORKERS: int = 5
59
54
  MAIL_SERVER: str = "localhost"
60
55
  MAIL_PORT: int = 25
@@ -92,22 +87,9 @@ class AppSettings(BaseSettings):
92
87
  ENABLE_GRAPHQL_STATS_EXTENSION: bool = False
93
88
  VALIDATE_OUT_OF_SYNC_SUBSCRIPTIONS: bool = False
94
89
  FILTER_BY_MODE: Literal["partial", "exact"] = "exact"
95
-
96
- def __init__(self) -> None:
97
- super(AppSettings, self).__init__()
98
- self.DATABASE_URI = PostgresDsn(convert_database_uri(str(self.DATABASE_URI)))
99
-
100
-
101
- def convert_database_uri(db_uri: str) -> str:
102
- if db_uri.startswith(("postgresql://", "postgresql+psycopg2://")):
103
- db_uri = "postgresql+psycopg" + db_uri[db_uri.find("://") :]
104
- warnings.filterwarnings("always", category=OrchestratorDeprecationWarning)
105
- warnings.warn(
106
- "DATABASE_URI converted to postgresql+psycopg:// format, please update your enviroment variable",
107
- OrchestratorDeprecationWarning,
108
- stacklevel=2,
109
- )
110
- return db_uri
90
+ ENABLE_SUBSCRIPTION_MODEL_OPTIMIZATIONS: bool = (
91
+ True # True=ignore cache + optimized DB queries; False=use cache + unoptimized DB queries. Remove in #900
92
+ )
111
93
 
112
94
 
113
95
  app_settings = AppSettings()
orchestrator/targets.py CHANGED
@@ -1,4 +1,4 @@
1
- # Copyright 2019-2020 SURF.
1
+ # Copyright 2019-2020 SURF, GÉANT.
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
orchestrator/types.py CHANGED
@@ -1,4 +1,4 @@
1
- # Copyright 2019-2020 SURF.
1
+ # Copyright 2019-2020 SURF, GÉANT.
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
@@ -1,4 +1,4 @@
1
- # Copyright 2019-2020 SURF.
1
+ # Copyright 2019-2020 SURF, GÉANT.
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
@@ -241,3 +241,12 @@ def to_ranges(i: Iterable[int]) -> Iterable[range]:
241
241
  for _, g in itertools.groupby(enumerate(i), lambda t: t[1] - t[0]):
242
242
  group = list(g)
243
243
  yield range(group[0][1], group[-1][1] + 1)
244
+
245
+
246
+ K = TypeVar("K")
247
+ V = TypeVar("V")
248
+
249
+
250
+ def group_by_key(items: Iterable[tuple[K, V]]) -> dict[K, list[V]]:
251
+ groups = itertools.groupby(items, key=lambda item: item[0])
252
+ return {key: [item[1] for item in group] for key, group in groups}
@@ -55,6 +55,12 @@ def to_redis(subscription: dict[str, Any]) -> str | None:
55
55
 
56
56
  def from_redis(subscription_id: UUID) -> tuple[PY_JSON_TYPES, str] | None:
57
57
  log = logger.bind(subscription_id=subscription_id)
58
+
59
+ if app_settings.ENABLE_SUBSCRIPTION_MODEL_OPTIMIZATIONS:
60
+ # TODO #900 remove toggle and remove usage of this function in get_subscription_dict
61
+ log.warning("Using SubscriptionModel optimization, not loading subscription from cache")
62
+ return None
63
+
58
64
  if caching_models_enabled():
59
65
  log.debug("Try to retrieve subscription from cache")
60
66
  obj = cache.get(f"orchestrator:domain:{subscription_id}")
@@ -1,4 +1,4 @@
1
- # Copyright 2019-2020 SURF, ESnet
1
+ # Copyright 2019-2020 SURF, ESnet, GÉANT.
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
@@ -1,4 +1,4 @@
1
- # Copyright 2019-2020 SURF.
1
+ # Copyright 2019-2020 SURF, GÉANT.
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
orchestrator/workflow.py CHANGED
@@ -1,4 +1,4 @@
1
- # Copyright 2019-2020 SURF.
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
@@ -39,6 +39,7 @@ from structlog.contextvars import bound_contextvars
39
39
  from structlog.stdlib import BoundLogger
40
40
 
41
41
  from nwastdlib import const, identity
42
+ from oauth2_lib.fastapi import OIDCUserModel
42
43
  from orchestrator.config.assignee import Assignee
43
44
  from orchestrator.db import db, transactional
44
45
  from orchestrator.services.settings import get_engine_settings
@@ -89,6 +90,7 @@ class Workflow(Protocol):
89
90
  __qualname__: str
90
91
  name: str
91
92
  description: str
93
+ authorize_callback: Callable[[OIDCUserModel | None], bool]
92
94
  initial_input_form: InputFormGenerator | None = None
93
95
  target: Target
94
96
  steps: StepList
@@ -178,12 +180,18 @@ def _handle_simple_input_form_generator(f: StateInputStepFunc) -> StateInputForm
178
180
  return form_generator
179
181
 
180
182
 
183
+ def allow(_: OIDCUserModel | None) -> bool:
184
+ """Default function to return True in absence of user-defined authorize function."""
185
+ return True
186
+
187
+
181
188
  def make_workflow(
182
189
  f: Callable,
183
190
  description: str,
184
191
  initial_input_form: InputStepFunc | None,
185
192
  target: Target,
186
193
  steps: StepList,
194
+ authorize_callback: Callable[[OIDCUserModel | None], bool] | None = None,
187
195
  ) -> Workflow:
188
196
  @functools.wraps(f)
189
197
  def wrapping_function() -> NoReturn:
@@ -193,6 +201,7 @@ def make_workflow(
193
201
 
194
202
  wrapping_function.name = f.__name__ # default, will be changed by LazyWorkflowInstance
195
203
  wrapping_function.description = description
204
+ wrapping_function.authorize_callback = allow if authorize_callback is None else authorize_callback
196
205
 
197
206
  if initial_input_form is None:
198
207
  # We always need a form to prevent starting a workflow when no input is needed.
@@ -214,7 +223,11 @@ def step(name: str) -> Callable[[StepFunc], Step]:
214
223
  def decorator(func: StepFunc) -> Step:
215
224
  @functools.wraps(func)
216
225
  def wrapper(state: State) -> Process:
217
- with bound_contextvars(func=func.__qualname__):
226
+ with bound_contextvars(
227
+ func=func.__qualname__,
228
+ workflow_name=state.get("workflow_name"),
229
+ process_id=state.get("process_id"),
230
+ ):
218
231
  step_in_inject_args = inject_args(func)
219
232
  try:
220
233
  with transactional(db, logger):
@@ -239,7 +252,11 @@ def retrystep(name: str) -> Callable[[StepFunc], Step]:
239
252
  def decorator(func: StepFunc) -> Step:
240
253
  @functools.wraps(func)
241
254
  def wrapper(state: State) -> Process:
242
- with bound_contextvars(func=func.__qualname__):
255
+ with bound_contextvars(
256
+ func=func.__qualname__,
257
+ workflow_name=state.get("workflow_name"),
258
+ process_id=state.get("process_id"),
259
+ ):
243
260
  step_in_inject_args = inject_args(func)
244
261
  try:
245
262
  with transactional(db, logger):
@@ -459,7 +476,10 @@ def focussteps(key: str) -> Callable[[Step | StepList], StepList]:
459
476
 
460
477
 
461
478
  def workflow(
462
- description: str, initial_input_form: InputStepFunc | None = None, target: Target = Target.SYSTEM
479
+ description: str,
480
+ initial_input_form: InputStepFunc | None = None,
481
+ target: Target = Target.SYSTEM,
482
+ authorize_callback: Callable[[OIDCUserModel | None], bool] | None = None,
463
483
  ) -> Callable[[Callable[[], StepList]], Workflow]:
464
484
  """Transform an initial_input_form and a step list into a workflow.
465
485
 
@@ -479,7 +499,9 @@ def workflow(
479
499
  initial_input_form_in_form_inject_args = form_inject_args(initial_input_form)
480
500
 
481
501
  def _workflow(f: Callable[[], StepList]) -> Workflow:
482
- return make_workflow(f, description, initial_input_form_in_form_inject_args, target, f())
502
+ return make_workflow(
503
+ f, description, initial_input_form_in_form_inject_args, target, f(), authorize_callback=authorize_callback
504
+ )
483
505
 
484
506
  return _workflow
485
507
 
@@ -491,13 +513,14 @@ class ProcessStat:
491
513
  state: Process
492
514
  log: StepList
493
515
  current_user: str
516
+ user_model: OIDCUserModel | None = None
494
517
 
495
518
  def update(self, **vs: Any) -> ProcessStat:
496
519
  """Update ProcessStat.
497
520
 
498
521
  >>> pstat = ProcessStat('', None, {}, [], "")
499
522
  >>> pstat.update(state={"a": "b"})
500
- ProcessStat(process_id='', workflow=None, state={'a': 'b'}, log=[], current_user='')
523
+ ProcessStat(process_id='', workflow=None, state={'a': 'b'}, log=[], current_user='', user_model=None)
501
524
  """
502
525
  return ProcessStat(**{**asdict(self), **vs})
503
526
 
@@ -1,4 +1,4 @@
1
- # Copyright 2019-2020 SURF.
1
+ # Copyright 2019-2020 SURF, GÉANT.
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
@@ -1,4 +1,4 @@
1
- # Copyright 2019-2020 SURF.
1
+ # Copyright 2019-2020 SURF, GÉANT.
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
@@ -1,4 +1,4 @@
1
- # Copyright 2019-2020 SURF.
1
+ # Copyright 2019-2020 SURF, GÉANT.
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
@@ -1,4 +1,4 @@
1
- # Copyright 2019-2020 SURF.
1
+ # Copyright 2019-2020 SURF, GÉANT.
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
@@ -1,4 +1,4 @@
1
- # Copyright 2019-2024 SURF.
1
+ # Copyright 2019-2024 SURF, GÉANT.
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
@@ -1,4 +1,4 @@
1
- # Copyright 2019-2020 SURF.
1
+ # Copyright 2019-2020 SURF, GÉANT.
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
@@ -1,4 +1,4 @@
1
- # Copyright 2019-2020 SURF.
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
@@ -20,6 +20,7 @@ from more_itertools import first_true
20
20
  from pydantic import field_validator, model_validator
21
21
  from sqlalchemy import select
22
22
 
23
+ from oauth2_lib.fastapi import OIDCUserModel
23
24
  from orchestrator.db import ProductTable, SubscriptionTable, db
24
25
  from orchestrator.forms.validators import ProductId
25
26
  from orchestrator.services import subscriptions
@@ -30,7 +31,7 @@ from orchestrator.utils.errors import StaleDataError
30
31
  from orchestrator.utils.redis import caching_models_enabled
31
32
  from orchestrator.utils.state import form_inject_args
32
33
  from orchestrator.utils.validate_data_version import validate_data_version
33
- from orchestrator.workflow import StepList, Workflow, conditional, done, init, make_workflow, step
34
+ from orchestrator.workflow import Step, StepList, Workflow, begin, conditional, done, init, make_workflow, step
34
35
  from orchestrator.workflows.steps import (
35
36
  cache_domain_models,
36
37
  refresh_subscription_search_index,
@@ -205,6 +206,7 @@ def create_workflow(
205
206
  initial_input_form: InputStepFunc | None = None,
206
207
  status: SubscriptionLifecycle = SubscriptionLifecycle.ACTIVE,
207
208
  additional_steps: StepList | None = None,
209
+ authorize_callback: Callable[[OIDCUserModel | None], bool] | None = None,
208
210
  ) -> Callable[[Callable[[], StepList]], Workflow]:
209
211
  """Transform an initial_input_form and a step list into a workflow with a target=Target.CREATE.
210
212
 
@@ -231,7 +233,14 @@ def create_workflow(
231
233
  >> done
232
234
  )
233
235
 
234
- return make_workflow(f, description, create_initial_input_form_generator, Target.CREATE, steplist)
236
+ return make_workflow(
237
+ f,
238
+ description,
239
+ create_initial_input_form_generator,
240
+ Target.CREATE,
241
+ steplist,
242
+ authorize_callback=authorize_callback,
243
+ )
235
244
 
236
245
  return _create_workflow
237
246
 
@@ -240,6 +249,7 @@ def modify_workflow(
240
249
  description: str,
241
250
  initial_input_form: InputStepFunc | None = None,
242
251
  additional_steps: StepList | None = None,
252
+ authorize_callback: Callable[[OIDCUserModel | None], bool] | None = None,
243
253
  ) -> Callable[[Callable[[], StepList]], Workflow]:
244
254
  """Transform an initial_input_form and a step list into a workflow.
245
255
 
@@ -269,7 +279,14 @@ def modify_workflow(
269
279
  >> done
270
280
  )
271
281
 
272
- return make_workflow(f, description, wrapped_modify_initial_input_form_generator, Target.MODIFY, steplist)
282
+ return make_workflow(
283
+ f,
284
+ description,
285
+ wrapped_modify_initial_input_form_generator,
286
+ Target.MODIFY,
287
+ steplist,
288
+ authorize_callback=authorize_callback,
289
+ )
273
290
 
274
291
  return _modify_workflow
275
292
 
@@ -278,6 +295,7 @@ def terminate_workflow(
278
295
  description: str,
279
296
  initial_input_form: InputStepFunc | None = None,
280
297
  additional_steps: StepList | None = None,
298
+ authorize_callback: Callable[[OIDCUserModel | None], bool] | None = None,
281
299
  ) -> Callable[[Callable[[], StepList]], Workflow]:
282
300
  """Transform an initial_input_form and a step list into a workflow.
283
301
 
@@ -308,7 +326,14 @@ def terminate_workflow(
308
326
  >> done
309
327
  )
310
328
 
311
- return make_workflow(f, description, wrapped_terminate_initial_input_form_generator, Target.TERMINATE, steplist)
329
+ return make_workflow(
330
+ f,
331
+ description,
332
+ wrapped_terminate_initial_input_form_generator,
333
+ Target.TERMINATE,
334
+ steplist,
335
+ authorize_callback=authorize_callback,
336
+ )
312
337
 
313
338
  return _terminate_workflow
314
339
 
@@ -344,6 +369,16 @@ def validate_workflow(description: str) -> Callable[[Callable[[], StepList]], Wo
344
369
  return _validate_workflow
345
370
 
346
371
 
372
+ def ensure_provisioning_status(modify_steps: Step | StepList) -> StepList:
373
+ """Decorator to ensure subscription modifications are executed only during Provisioning status."""
374
+ return (
375
+ begin
376
+ >> set_status(SubscriptionLifecycle.PROVISIONING)
377
+ >> modify_steps
378
+ >> set_status(SubscriptionLifecycle.ACTIVE)
379
+ )
380
+
381
+
347
382
  @step("Equalize workflow step count")
348
383
  def obsolete_step() -> None:
349
384
  """Equalize workflow step counts.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: orchestrator-core
3
- Version: 3.1.2rc3
3
+ Version: 3.2.0
4
4
  Summary: This is the orchestrator workflow engine.
5
5
  Requires-Python: >=3.11,<3.14
6
6
  Classifier: Intended Audience :: Information Technology
@@ -28,7 +28,7 @@ Classifier: Programming Language :: Python :: 3.11
28
28
  Classifier: Topic :: Internet :: WWW/HTTP :: HTTP Servers
29
29
  Classifier: Topic :: Internet :: WWW/HTTP
30
30
  License-File: LICENSE
31
- Requires-Dist: alembic==1.15.1
31
+ Requires-Dist: alembic==1.15.2
32
32
  Requires-Dist: anyio>=3.7.0
33
33
  Requires-Dist: click==8.*
34
34
  Requires-Dist: deprecated
@@ -38,17 +38,18 @@ Requires-Dist: fastapi-etag==0.4.0
38
38
  Requires-Dist: more-itertools~=10.6.0
39
39
  Requires-Dist: itsdangerous
40
40
  Requires-Dist: Jinja2==3.1.6
41
- Requires-Dist: orjson==3.10.15
42
- Requires-Dist: psycopg[binary]==3.2.6
41
+ Requires-Dist: orjson==3.10.16
42
+ Requires-Dist: psycopg2-binary==2.9.10
43
43
  Requires-Dist: pydantic[email]~=2.8.2
44
44
  Requires-Dist: pydantic-settings~=2.8.0
45
45
  Requires-Dist: python-dateutil==2.8.2
46
46
  Requires-Dist: python-rapidjson>=1.18,<1.21
47
- Requires-Dist: pytz==2025.1
47
+ Requires-Dist: pytz==2025.2
48
48
  Requires-Dist: redis==5.1.1
49
49
  Requires-Dist: schedule==1.1.0
50
- Requires-Dist: sentry-sdk[fastapi]~=2.22.0
51
- Requires-Dist: SQLAlchemy==2.0.39
50
+ Requires-Dist: semver==3.0.4
51
+ Requires-Dist: sentry-sdk[fastapi]~=2.25.1
52
+ Requires-Dist: SQLAlchemy==2.0.40
52
53
  Requires-Dist: SQLAlchemy-Utils==0.41.2
53
54
  Requires-Dist: structlog
54
55
  Requires-Dist: typer==0.15.2
@@ -57,8 +58,8 @@ Requires-Dist: nwa-stdlib~=1.9.0
57
58
  Requires-Dist: oauth2-lib~=2.4.0
58
59
  Requires-Dist: tabulate==0.9.0
59
60
  Requires-Dist: strawberry-graphql>=0.246.2
60
- Requires-Dist: pydantic-forms~=1.4.0
61
- Requires-Dist: celery~=5.4.0 ; extra == "celery"
61
+ Requires-Dist: pydantic-forms>=1.4.0, <=2.0.0
62
+ Requires-Dist: celery~=5.5.1 ; extra == "celery"
62
63
  Requires-Dist: toml ; extra == "dev"
63
64
  Requires-Dist: bumpversion ; extra == "dev"
64
65
  Requires-Dist: mypy_extensions ; extra == "dev"