infrahub-server 1.3.0b6__py3-none-any.whl → 1.3.2__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. infrahub/cli/db.py +7 -5
  2. infrahub/cli/upgrade.py +6 -1
  3. infrahub/core/attribute.py +5 -0
  4. infrahub/core/diff/calculator.py +4 -1
  5. infrahub/core/diff/coordinator.py +8 -1
  6. infrahub/core/diff/query/field_specifiers.py +1 -1
  7. infrahub/core/diff/query/merge.py +2 -2
  8. infrahub/core/diff/query_parser.py +23 -32
  9. infrahub/core/graph/__init__.py +1 -1
  10. infrahub/core/migrations/graph/__init__.py +2 -0
  11. infrahub/core/migrations/graph/m012_convert_account_generic.py +1 -1
  12. infrahub/core/migrations/graph/m023_deduplicate_cardinality_one_relationships.py +2 -2
  13. infrahub/core/migrations/graph/m029_duplicates_cleanup.py +2 -2
  14. infrahub/core/migrations/graph/m031_check_number_attributes.py +102 -0
  15. infrahub/core/migrations/query/attribute_rename.py +1 -1
  16. infrahub/core/node/__init__.py +5 -1
  17. infrahub/core/node/constraints/grouped_uniqueness.py +88 -132
  18. infrahub/core/query/delete.py +3 -3
  19. infrahub/core/schema/attribute_parameters.py +12 -5
  20. infrahub/core/schema/basenode_schema.py +107 -1
  21. infrahub/core/schema/schema_branch.py +17 -5
  22. infrahub/core/validators/attribute/min_max.py +7 -2
  23. infrahub/core/validators/uniqueness/model.py +17 -0
  24. infrahub/core/validators/uniqueness/query.py +212 -1
  25. infrahub/graphql/app.py +5 -1
  26. infrahub/graphql/mutations/main.py +18 -2
  27. infrahub/services/adapters/message_bus/nats.py +5 -1
  28. infrahub/services/scheduler.py +5 -1
  29. infrahub_sdk/node/__init__.py +2 -0
  30. infrahub_sdk/node/node.py +33 -2
  31. infrahub_sdk/node/related_node.py +7 -0
  32. {infrahub_server-1.3.0b6.dist-info → infrahub_server-1.3.2.dist-info}/METADATA +1 -1
  33. {infrahub_server-1.3.0b6.dist-info → infrahub_server-1.3.2.dist-info}/RECORD +36 -35
  34. {infrahub_server-1.3.0b6.dist-info → infrahub_server-1.3.2.dist-info}/LICENSE.txt +0 -0
  35. {infrahub_server-1.3.0b6.dist-info → infrahub_server-1.3.2.dist-info}/WHEEL +0 -0
  36. {infrahub_server-1.3.0b6.dist-info → infrahub_server-1.3.2.dist-info}/entry_points.txt +0 -0
infrahub/cli/db.py CHANGED
@@ -287,10 +287,10 @@ async def index(
287
287
  await dbdriver.close()
288
288
 
289
289
 
290
- async def migrate_database(db: InfrahubDatabase, initialize: bool = False, check: bool = False) -> None:
290
+ async def migrate_database(db: InfrahubDatabase, initialize: bool = False, check: bool = False) -> bool:
291
291
  """Apply the latest migrations to the database, this function will print the status directly in the console.
292
292
 
293
- This function is expected to run on an empty
293
+ Returns a boolean indicating whether a migration failed or if all migrations succeeded.
294
294
 
295
295
  Args:
296
296
  db: The database object.
@@ -306,14 +306,14 @@ async def migrate_database(db: InfrahubDatabase, initialize: bool = False, check
306
306
 
307
307
  if not migrations:
308
308
  rprint(f"Database up-to-date (v{root_node.graph_version}), no migration to execute.")
309
- return
309
+ return True
310
310
 
311
311
  rprint(
312
312
  f"Database needs to be updated (v{root_node.graph_version} -> v{GRAPH_VERSION}), {len(migrations)} migrations pending"
313
313
  )
314
314
 
315
315
  if check:
316
- return
316
+ return True
317
317
 
318
318
  for migration in migrations:
319
319
  execution_result = await migration.execute(db=db)
@@ -333,7 +333,9 @@ async def migrate_database(db: InfrahubDatabase, initialize: bool = False, check
333
333
  if validation_result and not validation_result.success:
334
334
  for error in validation_result.errors:
335
335
  rprint(f" {error}")
336
- break
336
+ return False
337
+
338
+ return True
337
339
 
338
340
 
339
341
  async def initialize_internal_schema() -> None:
infrahub/cli/upgrade.py CHANGED
@@ -75,7 +75,12 @@ async def upgrade_cmd(
75
75
  # -------------------------------------------
76
76
  # Upgrade Infrahub Database and Schema
77
77
  # -------------------------------------------
78
- await migrate_database(db=dbdriver, initialize=False, check=check)
78
+
79
+ if not await migrate_database(db=dbdriver, initialize=False, check=check):
80
+ # A migration failed, stop the upgrade process
81
+ rprint("Upgrade cancelled due to migration failure.")
82
+ await dbdriver.close()
83
+ return
79
84
 
80
85
  await initialize_internal_schema()
81
86
  await update_core_schema(db=dbdriver, service=service, initialize=False)
@@ -31,6 +31,7 @@ from infrahub.helpers import hash_password
31
31
 
32
32
  from ..types import ATTRIBUTE_TYPES, LARGE_ATTRIBUTE_TYPES
33
33
  from .constants.relationship_label import RELATIONSHIP_TO_NODE_LABEL, RELATIONSHIP_TO_VALUE_LABEL
34
+ from .schema.attribute_parameters import NumberAttributeParameters
34
35
 
35
36
  if TYPE_CHECKING:
36
37
  from infrahub.core.branch import Branch
@@ -255,6 +256,10 @@ class BaseAttribute(FlagPropertyMixin, NodePropertyMixin):
255
256
  if len(value) > max_length:
256
257
  raise ValidationError({name: f"{value} must have a maximum length of {max_length!r}"})
257
258
 
259
+ # Some invalid values may exist due to https://github.com/opsmill/infrahub/issues/6714.
260
+ if config.SETTINGS.main.schema_strict_mode and isinstance(schema.parameters, NumberAttributeParameters):
261
+ schema.parameters.check_valid_value(value=value, name=name)
262
+
258
263
  if schema.enum:
259
264
  try:
260
265
  schema.convert_value_to_enum(value)
@@ -181,8 +181,11 @@ class DiffCalculator:
181
181
  log.info("Diff property-level calculation queries for branch complete")
182
182
 
183
183
  if base_branch.name != diff_branch.name:
184
- current_node_field_specifiers = diff_parser.get_current_node_field_specifiers()
185
184
  new_node_field_specifiers = diff_parser.get_new_node_field_specifiers()
185
+ current_node_field_specifiers = None
186
+ if previous_node_specifiers is not None:
187
+ current_node_field_specifiers = previous_node_specifiers - new_node_field_specifiers
188
+
186
189
  base_calculation_request = DiffCalculationRequest(
187
190
  base_branch=base_branch,
188
191
  diff_branch=base_branch,
@@ -4,7 +4,10 @@ from dataclasses import dataclass, field
4
4
  from typing import TYPE_CHECKING, Iterable, Literal, Sequence, overload
5
5
  from uuid import uuid4
6
6
 
7
+ from prefect import flow
8
+
7
9
  from infrahub import lock
10
+ from infrahub.core.branch import Branch # noqa: TC001
8
11
  from infrahub.core.timestamp import Timestamp
9
12
  from infrahub.exceptions import ValidationError
10
13
  from infrahub.log import get_logger
@@ -22,7 +25,6 @@ from .model.path import (
22
25
  )
23
26
 
24
27
  if TYPE_CHECKING:
25
- from infrahub.core.branch import Branch
26
28
  from infrahub.core.node import Node
27
29
 
28
30
  from .calculator import DiffCalculator
@@ -301,6 +303,11 @@ class DiffCoordinator:
301
303
  force_branch_refresh: Literal[False] = ...,
302
304
  ) -> tuple[EnrichedDiffs | EnrichedDiffsMetadata, set[NodeIdentifier]]: ...
303
305
 
306
+ @flow( # type: ignore[misc]
307
+ name="update-diff",
308
+ flow_run_name="Update diff for {base_branch.name} - {diff_branch.name}: ({from_time}-{to_time}),tracking_id={tracking_id}",
309
+ validate_parameters=False,
310
+ )
304
311
  async def _update_diffs(
305
312
  self,
306
313
  base_branch: Branch,
@@ -15,7 +15,7 @@ class EnrichedDiffFieldSpecifiersQuery(Query):
15
15
  async def query_init(self, db: InfrahubDatabase, **kwargs: Any) -> None: # noqa: ARG002
16
16
  self.params["diff_id"] = self.diff_id
17
17
  query = """
18
- CALL {
18
+ CALL () {
19
19
  MATCH (root:DiffRoot {uuid: $diff_id})-[:DIFF_HAS_NODE]->(node:DiffNode)-[:DIFF_HAS_ATTRIBUTE]->(attr:DiffAttribute)
20
20
  WHERE (root.is_merged IS NULL OR root.is_merged <> TRUE)
21
21
  RETURN node.uuid AS node_uuid, node.kind AS node_kind, attr.name AS field_name
@@ -710,14 +710,14 @@ class DiffMergeRollbackQuery(Query):
710
710
  // ---------------------------
711
711
  // reset to times on target branch
712
712
  // ---------------------------
713
- CALL {
713
+ CALL () {
714
714
  OPTIONAL MATCH ()-[r_to {to: $at, branch: $target_branch}]-()
715
715
  SET r_to.to = NULL
716
716
  }
717
717
  // ---------------------------
718
718
  // reset from times on target branch
719
719
  // ---------------------------
720
- CALL {
720
+ CALL () {
721
721
  OPTIONAL MATCH ()-[r_from {from: $at, branch: $target_branch}]-()
722
722
  DELETE r_from
723
723
  }
@@ -486,6 +486,7 @@ class DiffQueryParser:
486
486
  self._previous_node_field_specifiers = previous_node_field_specifiers or NodeFieldSpecifierMap()
487
487
  self._new_node_field_specifiers: NodeFieldSpecifierMap | None = None
488
488
  self._current_node_field_specifiers: NodeFieldSpecifierMap | None = None
489
+ self._diff_node_field_specifiers: NodeFieldSpecifierMap = NodeFieldSpecifierMap()
489
490
 
490
491
  def get_branches(self) -> set[str]:
491
492
  return set(self._final_diff_root_by_branch.keys())
@@ -497,33 +498,17 @@ class DiffQueryParser:
497
498
  return self._final_diff_root_by_branch[branch]
498
499
  return DiffRoot(from_time=self.from_time, to_time=self.to_time, uuid=str(uuid4()), branch=branch, nodes=[])
499
500
 
500
- def get_diff_node_field_specifiers(self) -> NodeFieldSpecifierMap:
501
- node_field_specifiers_map = NodeFieldSpecifierMap()
502
- if self.diff_branch_name not in self._diff_root_by_branch:
503
- return node_field_specifiers_map
504
- diff_root = self._diff_root_by_branch[self.diff_branch_name]
505
- for node in diff_root.nodes_by_identifier.values():
506
- for attribute_name in node.attributes_by_name:
507
- node_field_specifiers_map.add_entry(node_uuid=node.uuid, kind=node.kind, field_name=attribute_name)
508
- for relationship_diff in node.relationships_by_identifier.values():
509
- node_field_specifiers_map.add_entry(
510
- node_uuid=node.uuid, kind=node.kind, field_name=relationship_diff.identifier
511
- )
512
- return node_field_specifiers_map
513
-
514
501
  def get_new_node_field_specifiers(self) -> NodeFieldSpecifierMap:
515
- if self._new_node_field_specifiers is not None:
516
- return self._new_node_field_specifiers
517
- branch_node_specifiers = self.get_diff_node_field_specifiers()
518
- self._new_node_field_specifiers = branch_node_specifiers - self._previous_node_field_specifiers
519
- return self._new_node_field_specifiers
520
-
521
- def get_current_node_field_specifiers(self) -> NodeFieldSpecifierMap:
522
- if self._current_node_field_specifiers is not None:
523
- return self._current_node_field_specifiers
524
- new_node_field_specifiers = self.get_new_node_field_specifiers()
525
- self._current_node_field_specifiers = self._previous_node_field_specifiers - new_node_field_specifiers
526
- return self._current_node_field_specifiers
502
+ return self._diff_node_field_specifiers - self._previous_node_field_specifiers
503
+
504
+ def is_new_node_field_specifier(self, node_uuid: str, kind: str, field_name: str) -> bool:
505
+ if not self._diff_node_field_specifiers.has_entry(node_uuid=node_uuid, kind=kind, field_name=field_name):
506
+ return False
507
+ if self._previous_node_field_specifiers and self._previous_node_field_specifiers.has_entry(
508
+ node_uuid=node_uuid, kind=kind, field_name=field_name
509
+ ):
510
+ return False
511
+ return True
527
512
 
528
513
  def read_result(self, query_result: QueryResult) -> None:
529
514
  try:
@@ -533,8 +518,6 @@ class DiffQueryParser:
533
518
  return
534
519
  database_path = DatabasePath.from_cypher_path(cypher_path=path)
535
520
  self._parse_path(database_path=database_path)
536
- self._current_node_field_specifiers = None
537
- self._new_node_field_specifiers = None
538
521
 
539
522
  def parse(self, include_unchanged: bool = False) -> None:
540
523
  self._new_node_field_specifiers = None
@@ -617,11 +600,15 @@ class DiffQueryParser:
617
600
  branch_name = database_path.deepest_branch
618
601
  from_time = self.from_time
619
602
  if branch_name == self.base_branch_name:
620
- new_node_field_specifiers = self.get_new_node_field_specifiers()
621
- if new_node_field_specifiers.has_entry(
603
+ if self.is_new_node_field_specifier(
622
604
  node_uuid=diff_node.uuid, kind=diff_node.kind, field_name=attribute_name
623
605
  ):
624
606
  from_time = self.diff_branched_from_time
607
+ else:
608
+ # Add to diff node field specifiers if this is the diff branch
609
+ self._diff_node_field_specifiers.add_entry(
610
+ node_uuid=diff_node.uuid, kind=diff_node.kind, field_name=attribute_name
611
+ )
625
612
  if attribute_name not in diff_node.attributes_by_name:
626
613
  diff_node.attributes_by_name[attribute_name] = DiffAttributeIntermediate(
627
614
  uuid=database_path.attribute_id,
@@ -663,11 +650,15 @@ class DiffQueryParser:
663
650
  branch_name = database_path.deepest_branch
664
651
  from_time = self.from_time
665
652
  if branch_name == self.base_branch_name:
666
- new_node_field_specifiers = self.get_new_node_field_specifiers()
667
- if new_node_field_specifiers.has_entry(
653
+ if self.is_new_node_field_specifier(
668
654
  node_uuid=diff_node.uuid, kind=diff_node.kind, field_name=relationship_schema.get_identifier()
669
655
  ):
670
656
  from_time = self.diff_branched_from_time
657
+ else:
658
+ # Add to diff node field specifiers if this is the diff branch
659
+ self._diff_node_field_specifiers.add_entry(
660
+ node_uuid=diff_node.uuid, kind=diff_node.kind, field_name=relationship_schema.get_identifier()
661
+ )
671
662
  diff_relationship = DiffRelationshipIntermediate(
672
663
  name=relationship_schema.name,
673
664
  cardinality=relationship_schema.cardinality,
@@ -1 +1 @@
1
- GRAPH_VERSION = 30
1
+ GRAPH_VERSION = 31
@@ -32,6 +32,7 @@ from .m027_delete_isolated_nodes import Migration027
32
32
  from .m028_delete_diffs import Migration028
33
33
  from .m029_duplicates_cleanup import Migration029
34
34
  from .m030_illegal_edges import Migration030
35
+ from .m031_check_number_attributes import Migration031
35
36
 
36
37
  if TYPE_CHECKING:
37
38
  from infrahub.core.root import Root
@@ -69,6 +70,7 @@ MIGRATIONS: list[type[GraphMigration | InternalSchemaMigration | ArbitraryMigrat
69
70
  Migration028,
70
71
  Migration029,
71
72
  Migration030,
73
+ Migration031,
72
74
  ]
73
75
 
74
76
 
@@ -61,7 +61,7 @@ class Migration012RenameTypeAttributeData(AttributeRenameQuery):
61
61
  def render_match(self) -> str:
62
62
  query = """
63
63
  // Find all the active nodes
64
- CALL {
64
+ CALL () {
65
65
  MATCH (node:%(node_kind)s)
66
66
  WHERE exists((node)-[:HAS_ATTRIBUTE]-(:Attribute { name: $prev_attr.name }))
67
67
  AND NOT exists((node)-[:HAS_ATTRIBUTE]-(:Attribute { name: $new_attr.name }))
@@ -52,7 +52,7 @@ class DedupCardinalityOneRelsQuery(Query):
52
52
  # of a one-to-many BIDIR relationship.
53
53
  query = """
54
54
 
55
- CALL {
55
+ CALL () {
56
56
  MATCH (rel_node: Relationship)-[edge:IS_RELATED]->(n: Node)<-[edge_2:IS_RELATED]-(rel_node_2: Relationship {name: rel_node.name})
57
57
  WHERE rel_node.name in $rel_one_identifiers_inbound[n.kind]
58
58
  AND edge.branch = edge_2.branch
@@ -64,7 +64,7 @@ class DedupCardinalityOneRelsQuery(Query):
64
64
  DETACH DELETE rel_node
65
65
  }
66
66
 
67
- CALL {
67
+ CALL () {
68
68
  MATCH (rel_node_3: Relationship)<-[edge_3:IS_RELATED]-(n: Node)-[edge_4:IS_RELATED]->(rel_node_4: Relationship {name: rel_node_3.name})
69
69
  WHERE rel_node_3.name in $rel_one_identifiers_outbound[n.kind]
70
70
  AND edge_3.branch = edge_4.branch
@@ -535,12 +535,12 @@ class PerformHardDeletes(Query):
535
535
 
536
536
  async def query_init(self, db: InfrahubDatabase, **kwargs: dict[str, Any]) -> None: # noqa: ARG002
537
537
  query = """
538
- CALL {
538
+ CALL () {
539
539
  MATCH (n)
540
540
  WHERE n.to_delete = TRUE
541
541
  DETACH DELETE n
542
542
  }
543
- CALL {
543
+ CALL () {
544
544
  MATCH ()-[e]-()
545
545
  WHERE e.to_delete = TRUE
546
546
  DELETE e
@@ -0,0 +1,102 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING, Sequence
4
+
5
+ from infrahub import config
6
+ from infrahub.core import registry
7
+ from infrahub.core.branch import Branch
8
+ from infrahub.core.constants import SchemaPathType
9
+ from infrahub.core.initialization import initialization
10
+ from infrahub.core.migrations.shared import InternalSchemaMigration, MigrationResult, SchemaMigration
11
+ from infrahub.core.path import SchemaPath
12
+ from infrahub.core.schema import GenericSchema, NodeSchema
13
+ from infrahub.core.schema.attribute_parameters import NumberAttributeParameters
14
+ from infrahub.core.validators.attribute.min_max import AttributeNumberChecker
15
+ from infrahub.core.validators.enum import ConstraintIdentifier
16
+ from infrahub.core.validators.model import SchemaConstraintValidatorRequest
17
+ from infrahub.lock import initialize_lock
18
+ from infrahub.log import get_logger
19
+ from infrahub.types import Number
20
+
21
+ if TYPE_CHECKING:
22
+ from infrahub.database import InfrahubDatabase
23
+
24
+ log = get_logger()
25
+
26
+
27
+ class Migration031(InternalSchemaMigration):
28
+ """
29
+ Some nodes with invalid number attributes may have been created as min/max/excluded_values were not working properly.
30
+ This migration indicates corrupted nodes. If strict mode is disabled, both this migration and min/max/excludes_values constraints are disabled,
31
+ so that users can carry one with their corrupted data without any failure.
32
+ """
33
+
34
+ name: str = "031_check_number_attributes"
35
+ minimum_version: int = 30
36
+ migrations: Sequence[SchemaMigration] = []
37
+
38
+ async def execute(self, db: InfrahubDatabase) -> MigrationResult:
39
+ """Retrieve all number attributes that have a min/max/excluded_values
40
+ For any of these attributes, check if corresponding existing nodes are valid."""
41
+
42
+ if not config.SETTINGS.main.schema_strict_mode:
43
+ return MigrationResult()
44
+
45
+ # load schemas from database into registry
46
+ initialize_lock()
47
+ await initialization(db=db)
48
+
49
+ node_id_to_error_message = {}
50
+
51
+ branches = await Branch.get_list(db=db)
52
+ for branch in branches: # noqa
53
+ schema_branch = await registry.schema.load_schema_from_db(db=db, branch=branch)
54
+ for node_schema_kind in schema_branch.node_names:
55
+ schema = schema_branch.get_node(name=node_schema_kind, duplicate=False)
56
+ if not isinstance(schema, (NodeSchema, GenericSchema)):
57
+ continue
58
+
59
+ for attr in schema.attributes:
60
+ if attr.kind != Number.label:
61
+ continue
62
+
63
+ # Check if the attribute has a min/max/excluded_values being violated
64
+ if isinstance(attr.parameters, NumberAttributeParameters) and (
65
+ attr.parameters.min_value is not None
66
+ or attr.parameters.max_value is not None
67
+ or attr.parameters.excluded_values
68
+ ):
69
+ request = SchemaConstraintValidatorRequest(
70
+ branch=branch,
71
+ constraint_name=ConstraintIdentifier.ATTRIBUTE_PARAMETERS_MIN_VALUE_UPDATE.value,
72
+ node_schema=schema,
73
+ schema_path=SchemaPath(
74
+ path_type=SchemaPathType.ATTRIBUTE, schema_kind=schema.kind, field_name=attr.name
75
+ ),
76
+ schema_branch=db.schema.get_schema_branch(name=registry.default_branch),
77
+ )
78
+
79
+ constraint_checker = AttributeNumberChecker(db=db, branch=branch)
80
+ grouped_data_paths = await constraint_checker.check(request)
81
+ data_paths = grouped_data_paths[0].get_all_data_paths()
82
+ for data_path in data_paths:
83
+ # Avoid having duplicated error messages for nodes present on multiple branches.
84
+ if data_path.node_id not in node_id_to_error_message:
85
+ node_id_to_error_message[data_path.node_id] = (
86
+ f"Node {data_path.node_id} on branch {branch.name} "
87
+ f"has an invalid Number attribute {data_path.field_name}: {data_path.value}"
88
+ )
89
+
90
+ if len(node_id_to_error_message) == 0:
91
+ return MigrationResult()
92
+
93
+ error_str = (
94
+ "Following nodes attributes values must be updated to not violate corresponding min_value, "
95
+ "max_value or excluded_values schema constraints"
96
+ )
97
+ errors_messages = list(node_id_to_error_message.values())
98
+ return MigrationResult(errors=[error_str] + errors_messages)
99
+
100
+ async def validate_migration(self, db: InfrahubDatabase) -> MigrationResult: # noqa: ARG002
101
+ result = MigrationResult()
102
+ return result
@@ -38,7 +38,7 @@ class AttributeRenameQuery(Query):
38
38
  def render_match(self) -> str:
39
39
  query = """
40
40
  // Find all the active nodes
41
- CALL {
41
+ CALL () {
42
42
  MATCH (node:%(node_kind)s)
43
43
  WHERE exists((node)-[:HAS_ATTRIBUTE]-(:Attribute { name: $prev_attr.name }))
44
44
  RETURN node
@@ -70,7 +70,9 @@ log = get_logger()
70
70
 
71
71
  class Node(BaseNode, metaclass=BaseNodeMeta):
72
72
  @classmethod
73
- def __init_subclass_with_meta__(cls, _meta=None, default_filter=None, **options) -> None:
73
+ def __init_subclass_with_meta__(
74
+ cls, _meta: BaseNodeOptions | None = None, default_filter: None = None, **options: dict[str, Any]
75
+ ) -> None:
74
76
  if not _meta:
75
77
  _meta = BaseNodeOptions(cls)
76
78
 
@@ -959,6 +961,8 @@ class Node(BaseNode, metaclass=BaseNodeMeta):
959
961
  if relationship.kind == RelationshipKind.PARENT:
960
962
  return relationship.name
961
963
 
964
+ return None
965
+
962
966
  async def get_object_template(self, db: InfrahubDatabase) -> CoreObjectTemplate | None:
963
967
  object_template: RelationshipManager = getattr(self, OBJECT_TEMPLATE_RELATIONSHIP_NAME, None)
964
968
  return (