From 61cd56feae831458dc59eb3646090b7381c01d12 Mon Sep 17 00:00:00 2001 From: Alex Gittings Date: Thu, 9 Oct 2025 10:32:46 +0100 Subject: [PATCH 1/8] Refactor profile relationship settings and enhance profile handling in forms --- backend/infrahub/core/schema/schema_branch.py | 26 ++-- backend/infrahub/profiles/node_applier.py | 8 +- .../generateObjectEditFormQuery.ts | 9 +- .../object-template/object-template-form.tsx | 2 +- .../shared/components/form/object-form.tsx | 4 +- .../components/form/profiles-selector.tsx | 120 +++++++++--------- 6 files changed, 94 insertions(+), 75 deletions(-) diff --git a/backend/infrahub/core/schema/schema_branch.py b/backend/infrahub/core/schema/schema_branch.py index 4f26e0b09e..4dd519abdf 100644 --- a/backend/infrahub/core/schema/schema_branch.py +++ b/backend/infrahub/core/schema/schema_branch.py @@ -69,6 +69,14 @@ log = get_logger() +profiles_rel_settings: dict[str, Any] = { + "name": "profiles", + "identifier": PROFILE_NODE_RELATIONSHIP_IDENTIFIER, + "peer": InfrahubKind.PROFILE, + "kind": RelationshipKind.PROFILE, + "cardinality": RelationshipCardinality.MANY, + "branch": BranchSupportType.AWARE, +} class SchemaBranch: def __init__( @@ -1933,15 +1941,6 @@ def manage_profile_relationships(self) -> None: ): continue - profiles_rel_settings: dict[str, Any] = { - "name": "profiles", - "identifier": PROFILE_NODE_RELATIONSHIP_IDENTIFIER, - "peer": InfrahubKind.PROFILE, - "kind": RelationshipKind.PROFILE, - "cardinality": RelationshipCardinality.MANY, - "branch": BranchSupportType.AWARE, - } - # Add relationship between node and profile if "profiles" not in node.relationship_names: node_schema = self.get(name=node_name, duplicate=True) @@ -2136,6 +2135,15 @@ def add_relationships_to_template(self, node: NodeSchema | GenericSchema) -> Non template_schema.human_friendly_id = [parent_hfid] + template_schema.human_friendly_id template_schema.uniqueness_constraints[0].append(relationship.name) + if getattr(node, "generate_profile", False) and getattr(node, "generate_template", False): + profile_kind = self._get_profile_kind(node_kind=node.kind) + if "profiles" not in [r.name for r in template_schema.relationships]: + template_schema.relationships.append( + RelationshipSchema(**profiles_rel_settings) + ) + + self.set(name=template_schema.kind, schema=template_schema) + def generate_object_template_from_node( self, node: NodeSchema | GenericSchema, need_templates: set[NodeSchema | GenericSchema] ) -> TemplateSchema | GenericSchema: diff --git a/backend/infrahub/profiles/node_applier.py b/backend/infrahub/profiles/node_applier.py index f8aebf79e8..bbf39dde4f 100644 --- a/backend/infrahub/profiles/node_applier.py +++ b/backend/infrahub/profiles/node_applier.py @@ -4,6 +4,7 @@ from infrahub.core.branch import Branch from infrahub.core.node import Node from infrahub.database import InfrahubDatabase +from infrahub.core.schema import TemplateSchema from .queries.get_profile_data import GetProfileDataQuery, ProfileData @@ -29,7 +30,12 @@ async def _get_attr_names_for_profiles(self, node: Node) -> list[str]: for attr_schema in node_schema.attributes: attr_name = attr_schema.name node_attr: BaseAttribute = getattr(node, attr_name) - if node_attr.is_from_profile or node_attr.is_default: + is_template = None + if node_attr.source_id: + await node_attr.get_source(db=self.db) + if isinstance(node_attr.source.get_schema(), TemplateSchema): + is_template = True + if node_attr.is_from_profile or node_attr.is_default or is_template: attr_names_for_profiles.append(attr_name) return attr_names_for_profiles diff --git a/frontend/app/src/entities/nodes/object-item-edit/generateObjectEditFormQuery.ts b/frontend/app/src/entities/nodes/object-item-edit/generateObjectEditFormQuery.ts index 84848d699a..e82b667f07 100644 --- a/frontend/app/src/entities/nodes/object-item-edit/generateObjectEditFormQuery.ts +++ b/frontend/app/src/entities/nodes/object-item-edit/generateObjectEditFormQuery.ts @@ -2,7 +2,7 @@ import { jsonToGraphQLQuery } from "json-to-graphql-query"; import { addAttributesToRequest, addRelationshipsToRequest } from "@/shared/api/graphql/utils"; import { getRelationshipsForForm } from "@/shared/components/form/utils/getRelationshipsForForm"; - +import { getSchema } from "@/entities/schema/domain/get-schema"; import type { NodeSchema, ProfileSchema } from "@/entities/schema/types"; export const generateObjectEditFormQuery = ({ @@ -12,6 +12,10 @@ export const generateObjectEditFormQuery = ({ schema: NodeSchema | ProfileSchema; objectId: string; }): string => { + let parentSchema: NodeSchema | ProfileSchema | undefined = undefined; + if (schema.kind && schema.kind.includes("Template")) { + parentSchema = getSchema(schema.name).schema; + } const request = { query: { __name: "GetObjectForEditForm", @@ -32,7 +36,8 @@ export const generateObjectEditFormQuery = ({ getRelationshipsForForm(schema.relationships ?? [], true, schema), { withMetadata: true } ), - ...("generate_profile" in schema && schema.generate_profile + ...(("generate_profile" in schema && schema.generate_profile) || + (parentSchema && "generate_profile" in parentSchema && parentSchema.generate_profile) ? { profiles: { edges: { diff --git a/frontend/app/src/entities/nodes/object-template/object-template-form.tsx b/frontend/app/src/entities/nodes/object-template/object-template-form.tsx index 1b5a74b919..e3b8b83db9 100644 --- a/frontend/app/src/entities/nodes/object-template/object-template-form.tsx +++ b/frontend/app/src/entities/nodes/object-template/object-template-form.tsx @@ -89,7 +89,7 @@ export default function ObjectTemplateForm({ } if (selectedObjectTemplate !== undefined) { - return ; + return edge?.node) ?? []} objectTemplate={selectedObjectTemplate} />; } return ( diff --git a/frontend/app/src/shared/components/form/object-form.tsx b/frontend/app/src/shared/components/form/object-form.tsx index 4c727db8c7..29a663072e 100644 --- a/frontend/app/src/shared/components/form/object-form.tsx +++ b/frontend/app/src/shared/components/form/object-form.tsx @@ -63,7 +63,7 @@ export interface ObjectFormProps extends Omit { - const { schema, isNode, isGeneric } = useSchema(kind); + const { schema, isNode, isGeneric, isTemplate } = useSchema(kind); if (!schema) { return ( @@ -148,7 +148,7 @@ const ObjectForm = ({ kind, currentProfiles, ...props }: ObjectFormProps) => { ); } - if (isNode && schema.generate_profile) { + if ((isNode && schema.generate_profile) || isTemplate) { return ; } diff --git a/frontend/app/src/shared/components/form/profiles-selector.tsx b/frontend/app/src/shared/components/form/profiles-selector.tsx index b274eec6a7..d176f94a9f 100644 --- a/frontend/app/src/shared/components/form/profiles-selector.tsx +++ b/frontend/app/src/shared/components/form/profiles-selector.tsx @@ -42,92 +42,92 @@ export const ProfilesSelector = ({ }: ProfilesSelectorProps) => { const id = useId(); - useEffect(() => { - if (!value && defaultValue) { - onChange(defaultValue); - } - }, [defaultValue]); - + const genericSchemas = useAtomValue(genericSchemasAtom); const profileSchemas = useAtomValue(profileSchemasAtom); - + const nodeGenerics = schema?.inherit_from ?? []; - + // Get all available generic profiles const nodeGenericsProfiles = nodeGenerics - // Find all generic schema - .map((nodeGeneric) => genericSchemas.find((generic) => generic.kind === nodeGeneric)) - // Filter for generate_profile ones - .filter((generic) => generic?.generate_profile) - // Get only the kind - .map((generic) => generic?.kind) - .filter(Boolean); - + // Find all generic schema + .map((nodeGeneric) => genericSchemas.find((generic) => generic.kind === nodeGeneric)) + // Filter for generate_profile ones + .filter((generic) => generic?.generate_profile) + // Get only the kind + .map((generic) => generic?.kind) + .filter(Boolean); + // The profiles should include the current object profile + all generic profiles const kindList = [schema.kind, ...nodeGenericsProfiles]; // Add attributes for each profile to get the values in the form const profilesList = kindList - .map((profile) => { - // Get the profile schema for the current kind - const profileSchema = profileSchemas.find((profileSchema) => profileSchema.name === profile); - - // Get attributes for query + form data - const attributes = getObjectAttributes({ schema: profileSchema, forProfiles: true }); - - if (!attributes.length) return null; - - return { - name: profileSchema?.kind, - schema: profileSchema, - attributes, - }; - }) - .filter(Boolean); - + .map((profile) => { + // Get the profile schema for the current kind + const profileSchema = profileSchemas.find((profileSchema) => profileSchema.name === profile?.replace("Template", "")); + + // Get attributes for query + form data + const attributes = getObjectAttributes({ schema: profileSchema, forProfiles: true }); + + if (!attributes.length) return null; + + return { + name: profileSchema?.kind, + schema: profileSchema, + attributes, + }; + }) + .filter(Boolean); + if (!profilesList.length) return ; - + const queryString = getProfiles({ profiles: profilesList }); - + const query = gql` ${queryString} `; - const { data, error, loading } = useQuery(query); +const { data, error, loading } = useQuery(query); - if (loading) return ; +if (loading) return ; - if (error) return ; +if (error) return ; - // Get all profiles name to retrieve the information from the result - const profilesNameList: string[] = profilesList - .map((profile) => profile?.name ?? "") - .filter(Boolean); +// Get all profiles name to retrieve the information from the result +const profilesNameList: string[] = profilesList +.map((profile) => profile?.name ?? "") +.filter(Boolean); - // Get data for each profile in the query result - const profiles = profilesNameList.reduce>( - (acc, profile) => [ - ...acc, - ...(data?.[profile!]?.edges.map((edge: { node: ProfileData }) => edge.node) ?? []), - ], - [] - ); +// Get data for each profile in the query result +const profiles = profilesNameList.reduce>( + (acc, profile) => [ + ...acc, + ...(data?.[profile!]?.edges.map((edge: { node: ProfileData }) => edge.node) ?? []), + ], + [] +); + +if (!value && defaultValue) { + onChange(profiles.filter((profile) => defaultValue.some((def) => def.id === profile.id))); +} - if (!profiles || profiles.length === 0) return null; - const selectedValues = value ?? []; +if (!profiles || profiles.length === 0) return null; - const handleChange = (profile: ProfileData) => { - onChange([...selectedValues, profile]); - }; +const selectedValues = value ?? []; - const handleRemove = (profile: ProfileData) => { - onChange(selectedValues.filter((item) => item.id !== profile.id)); - }; +const handleChange = (profile: ProfileData) => { + onChange([...selectedValues, profile]); +}; + +const handleRemove = (profile: ProfileData) => { + onChange(selectedValues.filter((item) => item.id !== profile.id)); +}; - return ( -
+return ( +
From 75c3dd3234a8bd272081dee1293c59ed2c86ace1 Mon Sep 17 00:00:00 2001 From: Alex Gittings Date: Thu, 9 Oct 2025 14:51:41 +0100 Subject: [PATCH 2/8] Formatting --- backend/infrahub/core/schema/schema_branch.py | 6 +- backend/infrahub/profiles/node_applier.py | 2 +- .../generateObjectEditFormQuery.ts | 5 +- .../object-template/object-template-form.tsx | 10 +- .../components/form/profiles-selector.tsx | 120 +++++++++--------- 5 files changed, 75 insertions(+), 68 deletions(-) diff --git a/backend/infrahub/core/schema/schema_branch.py b/backend/infrahub/core/schema/schema_branch.py index 4dd519abdf..b3e1bb1933 100644 --- a/backend/infrahub/core/schema/schema_branch.py +++ b/backend/infrahub/core/schema/schema_branch.py @@ -78,6 +78,7 @@ "branch": BranchSupportType.AWARE, } + class SchemaBranch: def __init__( self, @@ -2136,11 +2137,8 @@ def add_relationships_to_template(self, node: NodeSchema | GenericSchema) -> Non template_schema.uniqueness_constraints[0].append(relationship.name) if getattr(node, "generate_profile", False) and getattr(node, "generate_template", False): - profile_kind = self._get_profile_kind(node_kind=node.kind) if "profiles" not in [r.name for r in template_schema.relationships]: - template_schema.relationships.append( - RelationshipSchema(**profiles_rel_settings) - ) + template_schema.relationships.append(RelationshipSchema(**profiles_rel_settings)) self.set(name=template_schema.kind, schema=template_schema) diff --git a/backend/infrahub/profiles/node_applier.py b/backend/infrahub/profiles/node_applier.py index bbf39dde4f..f8a9faa974 100644 --- a/backend/infrahub/profiles/node_applier.py +++ b/backend/infrahub/profiles/node_applier.py @@ -3,8 +3,8 @@ from infrahub.core.attribute import BaseAttribute from infrahub.core.branch import Branch from infrahub.core.node import Node -from infrahub.database import InfrahubDatabase from infrahub.core.schema import TemplateSchema +from infrahub.database import InfrahubDatabase from .queries.get_profile_data import GetProfileDataQuery, ProfileData diff --git a/frontend/app/src/entities/nodes/object-item-edit/generateObjectEditFormQuery.ts b/frontend/app/src/entities/nodes/object-item-edit/generateObjectEditFormQuery.ts index e82b667f07..1fb3534f0f 100644 --- a/frontend/app/src/entities/nodes/object-item-edit/generateObjectEditFormQuery.ts +++ b/frontend/app/src/entities/nodes/object-item-edit/generateObjectEditFormQuery.ts @@ -2,6 +2,7 @@ import { jsonToGraphQLQuery } from "json-to-graphql-query"; import { addAttributesToRequest, addRelationshipsToRequest } from "@/shared/api/graphql/utils"; import { getRelationshipsForForm } from "@/shared/components/form/utils/getRelationshipsForForm"; + import { getSchema } from "@/entities/schema/domain/get-schema"; import type { NodeSchema, ProfileSchema } from "@/entities/schema/types"; @@ -12,7 +13,7 @@ export const generateObjectEditFormQuery = ({ schema: NodeSchema | ProfileSchema; objectId: string; }): string => { - let parentSchema: NodeSchema | ProfileSchema | undefined = undefined; + let parentSchema: NodeSchema | ProfileSchema | undefined; if (schema.kind && schema.kind.includes("Template")) { parentSchema = getSchema(schema.name).schema; } @@ -37,7 +38,7 @@ export const generateObjectEditFormQuery = ({ { withMetadata: true } ), ...(("generate_profile" in schema && schema.generate_profile) || - (parentSchema && "generate_profile" in parentSchema && parentSchema.generate_profile) + (parentSchema && "generate_profile" in parentSchema && parentSchema.generate_profile) ? { profiles: { edges: { diff --git a/frontend/app/src/entities/nodes/object-template/object-template-form.tsx b/frontend/app/src/entities/nodes/object-template/object-template-form.tsx index e3b8b83db9..964cbf2da6 100644 --- a/frontend/app/src/entities/nodes/object-template/object-template-form.tsx +++ b/frontend/app/src/entities/nodes/object-template/object-template-form.tsx @@ -89,7 +89,15 @@ export default function ObjectTemplateForm({ } if (selectedObjectTemplate !== undefined) { - return edge?.node) ?? []} objectTemplate={selectedObjectTemplate} />; + return ( + edge?.node) ?? [] + } + objectTemplate={selectedObjectTemplate} + /> + ); } return ( diff --git a/frontend/app/src/shared/components/form/profiles-selector.tsx b/frontend/app/src/shared/components/form/profiles-selector.tsx index d176f94a9f..06d61f638f 100644 --- a/frontend/app/src/shared/components/form/profiles-selector.tsx +++ b/frontend/app/src/shared/components/form/profiles-selector.tsx @@ -1,7 +1,7 @@ import { gql } from "@apollo/client"; import { Icon } from "@iconify-icon/react"; import { useAtomValue } from "jotai/index"; -import { useEffect, useId } from "react"; +import { useId } from "react"; import useQuery from "@/shared/api/graphql/useQuery"; import { Button } from "@/shared/components/buttons/button-primitive"; @@ -42,92 +42,92 @@ export const ProfilesSelector = ({ }: ProfilesSelectorProps) => { const id = useId(); - const genericSchemas = useAtomValue(genericSchemasAtom); const profileSchemas = useAtomValue(profileSchemasAtom); - + const nodeGenerics = schema?.inherit_from ?? []; - + // Get all available generic profiles const nodeGenericsProfiles = nodeGenerics - // Find all generic schema - .map((nodeGeneric) => genericSchemas.find((generic) => generic.kind === nodeGeneric)) - // Filter for generate_profile ones - .filter((generic) => generic?.generate_profile) - // Get only the kind - .map((generic) => generic?.kind) - .filter(Boolean); - + // Find all generic schema + .map((nodeGeneric) => genericSchemas.find((generic) => generic.kind === nodeGeneric)) + // Filter for generate_profile ones + .filter((generic) => generic?.generate_profile) + // Get only the kind + .map((generic) => generic?.kind) + .filter(Boolean); + // The profiles should include the current object profile + all generic profiles const kindList = [schema.kind, ...nodeGenericsProfiles]; // Add attributes for each profile to get the values in the form const profilesList = kindList - .map((profile) => { - // Get the profile schema for the current kind - const profileSchema = profileSchemas.find((profileSchema) => profileSchema.name === profile?.replace("Template", "")); - - // Get attributes for query + form data - const attributes = getObjectAttributes({ schema: profileSchema, forProfiles: true }); - - if (!attributes.length) return null; - - return { - name: profileSchema?.kind, - schema: profileSchema, - attributes, - }; - }) - .filter(Boolean); - + .map((profile) => { + // Get the profile schema for the current kind + const profileSchema = profileSchemas.find( + (profileSchema) => profileSchema.name === profile?.replace("Template", "") + ); + + // Get attributes for query + form data + const attributes = getObjectAttributes({ schema: profileSchema, forProfiles: true }); + + if (!attributes.length) return null; + + return { + name: profileSchema?.kind, + schema: profileSchema, + attributes, + }; + }) + .filter(Boolean); + if (!profilesList.length) return ; - + const queryString = getProfiles({ profiles: profilesList }); - + const query = gql` ${queryString} `; -const { data, error, loading } = useQuery(query); - -if (loading) return ; + const { data, error, loading } = useQuery(query); -if (error) return ; + if (loading) return ; -// Get all profiles name to retrieve the information from the result -const profilesNameList: string[] = profilesList -.map((profile) => profile?.name ?? "") -.filter(Boolean); + if (error) return ; -// Get data for each profile in the query result -const profiles = profilesNameList.reduce>( - (acc, profile) => [ - ...acc, - ...(data?.[profile!]?.edges.map((edge: { node: ProfileData }) => edge.node) ?? []), - ], - [] -); + // Get all profiles name to retrieve the information from the result + const profilesNameList: string[] = profilesList + .map((profile) => profile?.name ?? "") + .filter(Boolean); -if (!value && defaultValue) { - onChange(profiles.filter((profile) => defaultValue.some((def) => def.id === profile.id))); -} + // Get data for each profile in the query result + const profiles = profilesNameList.reduce>( + (acc, profile) => [ + ...acc, + ...(data?.[profile!]?.edges.map((edge: { node: ProfileData }) => edge.node) ?? []), + ], + [] + ); + if (!value && defaultValue) { + onChange(profiles.filter((profile) => defaultValue.some((def) => def.id === profile.id))); + } -if (!profiles || profiles.length === 0) return null; + if (!profiles || profiles.length === 0) return null; -const selectedValues = value ?? []; + const selectedValues = value ?? []; -const handleChange = (profile: ProfileData) => { - onChange([...selectedValues, profile]); -}; + const handleChange = (profile: ProfileData) => { + onChange([...selectedValues, profile]); + }; -const handleRemove = (profile: ProfileData) => { - onChange(selectedValues.filter((item) => item.id !== profile.id)); -}; + const handleRemove = (profile: ProfileData) => { + onChange(selectedValues.filter((item) => item.id !== profile.id)); + }; -return ( -
+ return ( +
From 7f12906c6369db49f6960974846ec20ad5d72415 Mon Sep 17 00:00:00 2001 From: Alex Gittings Date: Mon, 3 Nov 2025 11:50:56 +0000 Subject: [PATCH 3/8] Set existing source ID if there is already one present when creating a node from template. --- backend/infrahub/core/node/__init__.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/backend/infrahub/core/node/__init__.py b/backend/infrahub/core/node/__init__.py index ef54ae9ac0..10cf2d77c9 100644 --- a/backend/infrahub/core/node/__init__.py +++ b/backend/infrahub/core/node/__init__.py @@ -429,9 +429,10 @@ async def handle_object_template(self, fields: dict, db: InfrahubDatabase, error for attribute_name in template._attributes: if attribute_name in list(fields) + [OBJECT_TEMPLATE_NAME_ATTR]: continue - attr_value = getattr(template, attribute_name).value + attr = getattr(template, attribute_name) + attr_value = attr.value if attr_value is not None: - fields[attribute_name] = {"value": attr_value, "source": template.id} + fields[attribute_name] = {"value": attr_value, "source": attr.source_id if attr.source_id else template.id} for relationship_name in template._relationships: relationship_schema = template._schema.get_relationship(name=relationship_name) From e03f53935eae2793dd8013458b42129d1b360cb5 Mon Sep 17 00:00:00 2001 From: Alex Gittings Date: Mon, 3 Nov 2025 11:52:02 +0000 Subject: [PATCH 4/8] Test templates for nodeapplier --- backend/tests/unit/conftest.py | 1 + .../unit/core/profiles/test_node_applier.py | 57 ++++++++++++++++++- 2 files changed, 55 insertions(+), 3 deletions(-) diff --git a/backend/tests/unit/conftest.py b/backend/tests/unit/conftest.py index d23ded3009..2078de34fe 100644 --- a/backend/tests/unit/conftest.py +++ b/backend/tests/unit/conftest.py @@ -1805,6 +1805,7 @@ async def criticality_schema_root(register_core_models_schema: None) -> SchemaRo "display_labels": ["label__value"], "inherit_from": ["TestGenericCriticality"], "branch": BranchSupportType.AWARE.value, + "generate_template": True, "attributes": [ {"name": "name", "kind": "Text", "unique": True}, {"name": "label", "kind": "Text", "optional": True}, diff --git a/backend/tests/unit/core/profiles/test_node_applier.py b/backend/tests/unit/core/profiles/test_node_applier.py index 3c2c347e48..36c030bbc0 100644 --- a/backend/tests/unit/core/profiles/test_node_applier.py +++ b/backend/tests/unit/core/profiles/test_node_applier.py @@ -26,16 +26,20 @@ async def _validate_node_profile_attrs( ): expected_profile_attrs_by_name = {attr.name: attr for attr in expected_profile_attrs} for attr_name in schema.attribute_names: + # Skip if the attribute is not present on the node (e.g., not set on template) + if not hasattr(updated_node, attr_name): + continue updated_node_attr = getattr(updated_node, attr_name) updated_source = await updated_node_attr.get_source(db=db) - original_node_attr = getattr(original_node, attr_name) + original_node_attr = getattr(original_node, attr_name) if hasattr(original_node, attr_name) else None expected_profile_attr = expected_profile_attrs_by_name.get(attr_name) if expected_profile_attr: assert updated_node_attr.value == expected_profile_attr.value assert updated_source.id == expected_profile_attr.source_uuid else: - assert updated_node_attr.value == original_node_attr.value - assert updated_source is None + if original_node_attr is not None: + assert updated_node_attr.value == original_node_attr.value + assert updated_source is None async def test_get_many_with_profile( @@ -214,3 +218,50 @@ async def test_get_many_with_multiple_profiles_same_priority( assert updated_field_names == [] updated_field_names = await node_applier.apply_profiles(node=updated_crit_low) assert updated_field_names == [] + + +async def test_template_profile_application( + db: InfrahubDatabase, + criticality_schema: NodeSchema, + criticality_low: Node, + branch: Branch, +): + profile_schema = registry.schema.get("ProfileTestCriticality", branch=branch) + template_schema = registry.schema.get("TemplateTestCriticality", branch=branch) + + crit_profile_1 = await Node.init(db=db, branch=branch, schema=profile_schema) + await crit_profile_1.new(db=db, profile_name="crit_profile_1", color="green", profile_priority=1001) + await crit_profile_1.save(db=db) + + crit_template = await Node.init(db=db, branch=branch, schema=template_schema) + await crit_template.new(db=db, template_name="crit_template", name="crit_template") + await crit_template.save(db=db) + + # TODO: Fix profile assignment to template + await crit_template.profiles.update(db=db, data=[crit_profile_1]) + + node_applier = NodeProfilesApplier(db=db, branch=branch) + + updated_template_field_names = await node_applier.apply_profiles(node=crit_template) + assert updated_template_field_names == ["color"] + await crit_template.save(db=db) + + + node = await NodeManager.get_one( + db=db, branch=branch, id=crit_template.id, include_source=True + ) + assert node.id == crit_template.id + expected_profile_attrs = [ + ExpectedProfileAttr(name="color", value="green", source_uuid=crit_profile_1.id), + ] + await _validate_node_profile_attrs( + db=db, + schema=criticality_schema, + original_node=crit_template, + updated_node=node, + expected_profile_attrs=expected_profile_attrs, + ) + + # make sure field names returned by apply_profiles is idempotent for templates + updated_field_names = await node_applier.apply_profiles(node=crit_template) + assert updated_field_names == [] \ No newline at end of file From c408ac2d4f74060fbe19ed2c2d695067f9df0f7c Mon Sep 17 00:00:00 2001 From: Alex Gittings Date: Mon, 3 Nov 2025 11:52:56 +0000 Subject: [PATCH 5/8] Test adding a node from a template that is using profiles. --- backend/tests/unit/core/test_node.py | 122 +++++++++++++++++++++++++++ 1 file changed, 122 insertions(+) diff --git a/backend/tests/unit/core/test_node.py b/backend/tests/unit/core/test_node.py index 6e09bb6e87..7d31f803f2 100644 --- a/backend/tests/unit/core/test_node.py +++ b/backend/tests/unit/core/test_node.py @@ -816,6 +816,128 @@ async def test_node_create_with_object_template( == template.id ) +async def test_node_create_with_object_template_with_profile( + db: InfrahubDatabase, default_branch: Branch, register_core_models_schema: SchemaBranch +): + DUMMY = NodeSchema( + name="Dummy", + namespace="Testing", + generate_template=True, + attributes=[ + AttributeSchema(name="name", kind="Text", unique=True), + ], + ) + + SIMPLE_DEVICE = NodeSchema( + name="Device", + namespace="Testing", + generate_template=True, + generate_profile=True, + attributes=[ + AttributeSchema(name="name", kind="Text", unique=True, order_weight=500), + AttributeSchema(name="manufacturer", kind="Text", order_weight=500), + AttributeSchema(name="height", kind="Number", order_weight=300), + AttributeSchema(name="weight", kind="Number", order_weight=1000), + AttributeSchema( + name="airflow", + kind="Text", + enum=["Front to rear", "Rear to front"], + optional=True, + ), + ], + relationships=[ + RelationshipSchema( + name="dummy", + peer="TestingDummy", + cardinality=RelationshipCardinality.ONE, + order_weight=5000, + optional=True, + ) + ], + ) + registry.schema.set(name=DUMMY.kind, schema=DUMMY, branch=default_branch.name) + registry.schema.set(name=SIMPLE_DEVICE.kind, schema=SIMPLE_DEVICE, branch=default_branch.name) + registry.schema.process_schema_branch(name=default_branch.name) + + template_schema = registry.schema.get(name=f"Template{SIMPLE_DEVICE.kind}", branch=default_branch.name) + node_schema = registry.schema.get(name=SIMPLE_DEVICE.kind, branch=default_branch.name) + + # Validate that the attributes respect the order_weight defined on the original schema + template_weights = { + attr.name: attr.order_weight for attr in template_schema.attributes + template_schema.relationships + } + + assert "name" not in template_weights + assert template_weights["manufacturer"] == 10500 + assert template_weights["dummy"] == 15000 + + profile_schema = registry.schema.get(name=f"Profile{SIMPLE_DEVICE.kind}", branch=default_branch.name) + + profile = await Node.init(db=db, branch=default_branch.name, schema=profile_schema) + await profile.new( + db=db, + profile_name="Airflow Rear to Front", + airflow="Rear to front", + + ) + await profile.save(db=db) + + template = await Node.init(db=db, schema=template_schema) + await template.new( + db=db, + template_name="Juniper MX204", + manufacturer="Juniper", + height=1, + weight=8, + ) + await template.save(db=db) + # TODO: Fix profile assignment + await template.profiles.update(db=db, data=[profile]) + template_profiles = await template.profiles.get_peers(db=db) + assert len(template_profiles) == 1 + + from infrahub.profiles.node_applier import NodeProfilesApplier + + node_applier = NodeProfilesApplier(db=db, branch=default_branch) + test = await node_applier.apply_profiles(node=template) + await template.save(db=db) + assert template.airflow.value == "Rear to front" + assert template.airflow.source_id == profile.id + + device: Node = await Node.init(db=db, schema=node_schema) + await device.new(db=db, name="par-th2-br01", object_template={"id": template.id}) + await device.save(db=db) + + assert device.id + assert device.db_id + assert device.name.value == device.node_changelog.attributes["name"].value == "par-th2-br01" + assert device.node_changelog.attributes["name"].value_update_status == DiffAction.ADDED + assert "source" not in device.node_changelog.attributes["name"].properties + assert device.manufacturer.value == device.node_changelog.attributes["manufacturer"].value == "Juniper" + assert device.node_changelog.attributes["manufacturer"].value_update_status == DiffAction.ADDED + assert ( + device.manufacturer.source_id + == device.node_changelog.attributes["manufacturer"].properties["source"].value + == template.id + ) + assert device.height.value == device.node_changelog.attributes["height"].value == 1 + assert device.node_changelog.attributes["height"].value_update_status == DiffAction.ADDED + assert ( + device.height.source_id == device.node_changelog.attributes["height"].properties["source"].value == template.id + ) + assert device.weight.value == device.node_changelog.attributes["weight"].value == 8 + assert device.node_changelog.attributes["weight"].value_update_status == DiffAction.ADDED + assert ( + device.weight.source_id == device.node_changelog.attributes["weight"].properties["source"].value == template.id + ) + assert device.airflow.value.value == device.node_changelog.attributes["airflow"].value.value == "Rear to front" + assert device.node_changelog.attributes["airflow"].value_update_status == DiffAction.ADDED + assert ( + device.airflow.source_id + == device.node_changelog.attributes["airflow"].properties["source"].value + == profile.id + ) + # -------------------------------------------------------------------------- # Update From 4793759e699bc886feeffca748b00b785b0d259f Mon Sep 17 00:00:00 2001 From: Alex Gittings Date: Mon, 3 Nov 2025 11:53:35 +0000 Subject: [PATCH 6/8] Simplify source ID assignment in node template processing --- backend/infrahub/core/node/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/infrahub/core/node/__init__.py b/backend/infrahub/core/node/__init__.py index 10cf2d77c9..1e43d03324 100644 --- a/backend/infrahub/core/node/__init__.py +++ b/backend/infrahub/core/node/__init__.py @@ -432,7 +432,7 @@ async def handle_object_template(self, fields: dict, db: InfrahubDatabase, error attr = getattr(template, attribute_name) attr_value = attr.value if attr_value is not None: - fields[attribute_name] = {"value": attr_value, "source": attr.source_id if attr.source_id else template.id} + fields[attribute_name] = {"value": attr_value, "source": attr.source_id or template.id} for relationship_name in template._relationships: relationship_schema = template._schema.get_relationship(name=relationship_name) From a302af39fda0eec486ba9e51dafb9081e7275181 Mon Sep 17 00:00:00 2001 From: Alex Gittings Date: Mon, 3 Nov 2025 11:54:16 +0000 Subject: [PATCH 7/8] Reformat --- .../unit/core/profiles/test_node_applier.py | 16 ++++++---------- backend/tests/unit/core/test_node.py | 8 +++----- 2 files changed, 9 insertions(+), 15 deletions(-) diff --git a/backend/tests/unit/core/profiles/test_node_applier.py b/backend/tests/unit/core/profiles/test_node_applier.py index 36c030bbc0..a89fdaf193 100644 --- a/backend/tests/unit/core/profiles/test_node_applier.py +++ b/backend/tests/unit/core/profiles/test_node_applier.py @@ -36,10 +36,9 @@ async def _validate_node_profile_attrs( if expected_profile_attr: assert updated_node_attr.value == expected_profile_attr.value assert updated_source.id == expected_profile_attr.source_uuid - else: - if original_node_attr is not None: - assert updated_node_attr.value == original_node_attr.value - assert updated_source is None + elif original_node_attr is not None: + assert updated_node_attr.value == original_node_attr.value + assert updated_source is None async def test_get_many_with_profile( @@ -236,7 +235,7 @@ async def test_template_profile_application( crit_template = await Node.init(db=db, branch=branch, schema=template_schema) await crit_template.new(db=db, template_name="crit_template", name="crit_template") await crit_template.save(db=db) - + # TODO: Fix profile assignment to template await crit_template.profiles.update(db=db, data=[crit_profile_1]) @@ -245,11 +244,8 @@ async def test_template_profile_application( updated_template_field_names = await node_applier.apply_profiles(node=crit_template) assert updated_template_field_names == ["color"] await crit_template.save(db=db) - - node = await NodeManager.get_one( - db=db, branch=branch, id=crit_template.id, include_source=True - ) + node = await NodeManager.get_one(db=db, branch=branch, id=crit_template.id, include_source=True) assert node.id == crit_template.id expected_profile_attrs = [ ExpectedProfileAttr(name="color", value="green", source_uuid=crit_profile_1.id), @@ -264,4 +260,4 @@ async def test_template_profile_application( # make sure field names returned by apply_profiles is idempotent for templates updated_field_names = await node_applier.apply_profiles(node=crit_template) - assert updated_field_names == [] \ No newline at end of file + assert updated_field_names == [] diff --git a/backend/tests/unit/core/test_node.py b/backend/tests/unit/core/test_node.py index 7d31f803f2..c219fb1845 100644 --- a/backend/tests/unit/core/test_node.py +++ b/backend/tests/unit/core/test_node.py @@ -816,6 +816,7 @@ async def test_node_create_with_object_template( == template.id ) + async def test_node_create_with_object_template_with_profile( db: InfrahubDatabase, default_branch: Branch, register_core_models_schema: SchemaBranch ): @@ -878,7 +879,6 @@ async def test_node_create_with_object_template_with_profile( db=db, profile_name="Airflow Rear to Front", airflow="Rear to front", - ) await profile.save(db=db) @@ -899,7 +899,7 @@ async def test_node_create_with_object_template_with_profile( from infrahub.profiles.node_applier import NodeProfilesApplier node_applier = NodeProfilesApplier(db=db, branch=default_branch) - test = await node_applier.apply_profiles(node=template) + await node_applier.apply_profiles(node=template) await template.save(db=db) assert template.airflow.value == "Rear to front" assert template.airflow.source_id == profile.id @@ -933,9 +933,7 @@ async def test_node_create_with_object_template_with_profile( assert device.airflow.value.value == device.node_changelog.attributes["airflow"].value.value == "Rear to front" assert device.node_changelog.attributes["airflow"].value_update_status == DiffAction.ADDED assert ( - device.airflow.source_id - == device.node_changelog.attributes["airflow"].properties["source"].value - == profile.id + device.airflow.source_id == device.node_changelog.attributes["airflow"].properties["source"].value == profile.id ) From 9b258998bcc0540edd6a5cf2c02ecf6d66ab488b Mon Sep 17 00:00:00 2001 From: Alex Gittings Date: Mon, 3 Nov 2025 23:42:24 +0000 Subject: [PATCH 8/8] Add PROFILE_TEMPLATE_RELATIONSHIP_IDENTIFIER and enhance profile handling in templates --- backend/infrahub/core/constants/__init__.py | 1 + backend/infrahub/core/node/create.py | 13 ++++++++++++- backend/infrahub/core/schema/schema_branch.py | 17 ++++++++++++++++- backend/infrahub/graphql/mutations/profile.py | 9 ++++++++- 4 files changed, 37 insertions(+), 3 deletions(-) diff --git a/backend/infrahub/core/constants/__init__.py b/backend/infrahub/core/constants/__init__.py index 02b39677c3..ca3f6fc158 100644 --- a/backend/infrahub/core/constants/__init__.py +++ b/backend/infrahub/core/constants/__init__.py @@ -388,3 +388,4 @@ class AttributeDBNodeType(Flag): OBJECT_TEMPLATE_RELATIONSHIP_NAME = "object_template" OBJECT_TEMPLATE_NAME_ATTR = "template_name" PROFILE_NODE_RELATIONSHIP_IDENTIFIER = "node__profile" +PROFILE_TEMPLATE_RELATIONSHIP_IDENTIFIER = "template__profile" diff --git a/backend/infrahub/core/node/create.py b/backend/infrahub/core/node/create.py index 103a43b49b..30d2204977 100644 --- a/backend/infrahub/core/node/create.py +++ b/backend/infrahub/core/node/create.py @@ -63,7 +63,8 @@ async def extract_peer_data( for rel in template_peer.get_schema().relationship_names: rel_manager: RelationshipManager = getattr(template_peer, rel) if ( - rel_manager.schema.kind not in [RelationshipKind.COMPONENT, RelationshipKind.PARENT] + rel_manager.schema.kind + not in [RelationshipKind.COMPONENT, RelationshipKind.PARENT, RelationshipKind.PROFILE] or rel_manager.schema.name not in obj_peer_schema.relationship_names ): continue @@ -71,6 +72,10 @@ async def extract_peer_data( if list(await rel_manager.get_peers(db=db)) == [current_template.id]: obj_peer_data[rel] = {"id": parent_obj.id} + if rel_manager.schema.kind == RelationshipKind.PROFILE: + profiles = list(await rel_manager.get_peers(db=db)) + obj_peer_data[rel] = profiles + return obj_peer_data @@ -114,6 +119,12 @@ async def handle_template_relationships( await constraint_runner.check(node=obj_peer, field_filters=list(obj_peer_data)) await obj_peer.save(db=db) + template_profile_ids = await get_profile_ids(db=db, obj=template_relationship_peer) + if template_profile_ids: + node_profiles_applier = NodeProfilesApplier(db=db, branch=branch) + await node_profiles_applier.apply_profiles(node=obj_peer) + await obj_peer.save(db=db) + await handle_template_relationships( db=db, branch=branch, diff --git a/backend/infrahub/core/schema/schema_branch.py b/backend/infrahub/core/schema/schema_branch.py index b3e1bb1933..addaeaaebe 100644 --- a/backend/infrahub/core/schema/schema_branch.py +++ b/backend/infrahub/core/schema/schema_branch.py @@ -19,6 +19,7 @@ OBJECT_TEMPLATE_NAME_ATTR, OBJECT_TEMPLATE_RELATIONSHIP_NAME, PROFILE_NODE_RELATIONSHIP_IDENTIFIER, + PROFILE_TEMPLATE_RELATIONSHIP_IDENTIFIER, RESERVED_ATTR_GEN_NAMES, RESERVED_ATTR_REL_NAMES, RESTRICTED_NAMESPACES, @@ -2006,6 +2007,18 @@ def generate_profile_from_node(self, node: NodeSchema) -> ProfileSchema: ) ], ) + if f"Template{node.kind}" in self.all_names: + template = self.get(name=f"Template{node.kind}", duplicate=False) + profile.relationships.append( + RelationshipSchema( + name="related_templates", + identifier=PROFILE_TEMPLATE_RELATIONSHIP_IDENTIFIER, + peer=template.kind, + kind=RelationshipKind.PROFILE, + cardinality=RelationshipCardinality.MANY, + branch=BranchSupportType.AWARE, + ) + ) for node_attr in node.attributes: if node_attr.read_only or node_attr.optional is False: @@ -2138,7 +2151,9 @@ def add_relationships_to_template(self, node: NodeSchema | GenericSchema) -> Non if getattr(node, "generate_profile", False) and getattr(node, "generate_template", False): if "profiles" not in [r.name for r in template_schema.relationships]: - template_schema.relationships.append(RelationshipSchema(**profiles_rel_settings)) + settings = dict(profiles_rel_settings) + settings["identifier"] = PROFILE_TEMPLATE_RELATIONSHIP_IDENTIFIER + template_schema.relationships.append(RelationshipSchema(**settings)) self.set(name=template_schema.kind, schema=template_schema) diff --git a/backend/infrahub/graphql/mutations/profile.py b/backend/infrahub/graphql/mutations/profile.py index 1fa38a0b68..e30e5b25fc 100644 --- a/backend/infrahub/graphql/mutations/profile.py +++ b/backend/infrahub/graphql/mutations/profile.py @@ -57,6 +57,8 @@ async def _send_profile_refresh_workflows( ) -> None: if not node_ids: related_nodes = await obj.related_nodes.get_relationships(db=db) # type: ignore[attr-defined] + if hasattr(obj, "related_templates"): + related_nodes.extend(await obj.related_templates.get_relationships(db=db)) # type: ignore[attr-defined] node_ids = [rel.peer_id for rel in related_nodes] if node_ids: await workflow_service.submit_workflow( @@ -79,7 +81,12 @@ def _get_profile_attr_values_map(cls, obj: Node) -> dict[str, Any]: @classmethod async def _get_profile_related_node_ids(cls, db: InfrahubDatabase, obj: Node) -> set[str]: - related_nodes = await obj.related_nodes.get_relationships(db=db) # type: ignore[attr-defined] + related_nodes = [] + related_nodes.extend(await obj.related_nodes.get_relationships(db=db)) + + if hasattr(obj, "related_templates"): + related_nodes.extend(await obj.related_templates.get_relationships(db=db)) + if related_nodes: related_node_ids = {rel.peer_id for rel in related_nodes} else: