diff --git a/backend/infrahub/api/menu.py b/backend/infrahub/api/menu.py index ea64d1cd30..e622c61114 100644 --- a/backend/infrahub/api/menu.py +++ b/backend/infrahub/api/menu.py @@ -5,15 +5,21 @@ from fastapi import APIRouter, Depends from pydantic import BaseModel, Field -from infrahub.api.dependencies import get_branch_dep +from infrahub.api.dependencies import get_branch_dep, get_current_user, get_db from infrahub.core import registry from infrahub.core.branch import Branch # noqa: TCH001 from infrahub.core.constants import InfrahubKind +from infrahub.core.protocols import CoreMenuItem from infrahub.core.schema import NodeSchema from infrahub.log import get_logger +from infrahub.menu.generator import generate_menu +from infrahub.menu.models import Menu # noqa: TCH001 if TYPE_CHECKING: + from infrahub.auth import AccountSession from infrahub.core.schema import MainSchemaTypes + from infrahub.database import InfrahubDatabase + log = get_logger() router = APIRouter(prefix="/menu") @@ -231,3 +237,17 @@ async def get_menu(branch: Branch = Depends(get_branch_dep)) -> list[InterfaceMe menu_items.extend([groups, unified_storage, change_control, deployment, admin]) return menu_items + + +@router.get("/new") +async def get_new_menu( + db: InfrahubDatabase = Depends(get_db), + branch: Branch = Depends(get_branch_dep), + account_session: AccountSession = Depends(get_current_user), +) -> Menu: + log.info("new_menu_request", branch=branch.name) + + menu_items = await registry.manager.query(db=db, schema=CoreMenuItem, branch=branch, prefetch_relationships=True) + menu = await generate_menu(db=db, branch=branch, account=account_session, menu_items=menu_items) + + return menu.to_rest() diff --git a/backend/infrahub/core/constants/infrahubkind.py b/backend/infrahub/core/constants/infrahubkind.py index 0f0a41077a..bc2f8229f6 100644 --- a/backend/infrahub/core/constants/infrahubkind.py +++ b/backend/infrahub/core/constants/infrahubkind.py @@ -34,6 +34,7 @@ IPADDRESSPOOL = "CoreIPAddressPool" IPPREFIX = "BuiltinIPPrefix" IPPREFIXPOOL = "CoreIPPrefixPool" +MENUITEM = "CoreMenuItem" NAMESPACE = "IpamNamespace" NODE = "CoreNode" NUMBERPOOL = "CoreNumberPool" diff --git a/backend/infrahub/core/initialization.py b/backend/infrahub/core/initialization.py index 37538c8aa3..eef11ef412 100644 --- a/backend/infrahub/core/initialization.py +++ b/backend/infrahub/core/initialization.py @@ -20,13 +20,15 @@ from infrahub.core.node.resource_manager.ip_address_pool import CoreIPAddressPool from infrahub.core.node.resource_manager.ip_prefix_pool import CoreIPPrefixPool from infrahub.core.node.resource_manager.number_pool import CoreNumberPool -from infrahub.core.protocols import CoreAccount +from infrahub.core.protocols import CoreAccount, CoreMenuItem from infrahub.core.root import Root from infrahub.core.schema import SchemaRoot, core_models, internal_schema from infrahub.core.schema.manager import SchemaManager from infrahub.database import InfrahubDatabase from infrahub.exceptions import DatabaseError from infrahub.log import get_logger +from infrahub.menu.menu import default_menu +from infrahub.menu.models import MenuItemDefinition from infrahub.permissions import PermissionBackend from infrahub.storage import InfrahubObjectStorage from infrahub.utils import format_label @@ -305,6 +307,22 @@ async def create_initial_permission(db: InfrahubDatabase) -> Node: return permission +async def create_menu_children(db: InfrahubDatabase, parent: CoreMenuItem, children: list[MenuItemDefinition]) -> None: + for child in children: + obj = await child.to_node(db=db, parent=parent) + await obj.save(db=db) + if child.children: + await create_menu_children(db=db, parent=obj, children=child.children) + + +async def create_default_menu(db: InfrahubDatabase) -> None: + for item in default_menu: + obj = await item.to_node(db=db) + await obj.save(db=db) + if item.children: + await create_menu_children(db=db, parent=obj, children=item.children) + + async def create_super_administrator_role(db: InfrahubDatabase) -> Node: permission = await Node.init(db=db, schema=InfrahubKind.GLOBALPERMISSION) await permission.new( @@ -364,6 +382,11 @@ async def first_time_initialization(db: InfrahubDatabase) -> None: await default_branch.save(db=db) log.info("Created the Schema in the database", hash=default_branch.active_schema_hash.main) + # -------------------------------------------------- + # Create Default Menu + # -------------------------------------------------- + await create_default_menu(db=db) + # -------------------------------------------------- # Create Default Users and Groups # -------------------------------------------------- diff --git a/backend/infrahub/core/manager.py b/backend/infrahub/core/manager.py index 2d6af6fcf1..495b2994d6 100644 --- a/backend/infrahub/core/manager.py +++ b/backend/infrahub/core/manager.py @@ -1043,21 +1043,28 @@ async def get_one( node = result[id] node_schema = node.get_schema() + kind_validation = None + if kind: + node_schema_validation = get_schema(db=db, branch=branch, node_schema=kind) + kind_validation = node_schema_validation.kind + # Temporary list of exception to the validation of the kind kind_validation_exceptions = [ ("CoreChangeThread", "CoreObjectThread"), # issue/3318 ] - if kind and (node_schema.kind != kind and kind not in node_schema.inherit_from): + if kind_validation and ( + node_schema.kind != kind_validation and kind_validation not in node_schema.inherit_from + ): for item in kind_validation_exceptions: - if item[0] == kind and item[1] == node.get_kind(): + if item[0] == kind_validation and item[1] == node.get_kind(): return node raise NodeNotFoundError( branch_name=branch.name, - node_type=kind, + node_type=kind_validation, identifier=id, - message=f"Node with id {id} exists, but it is a {node.get_kind()}, not {kind}", + message=f"Node with id {id} exists, but it is a {node.get_kind()}, not {kind_validation}", ) return node diff --git a/backend/infrahub/core/protocols.py b/backend/infrahub/core/protocols.py index b2ff5afae2..a49de3ff52 100644 --- a/backend/infrahub/core/protocols.py +++ b/backend/infrahub/core/protocols.py @@ -133,6 +133,20 @@ class CoreGroup(CoreNode): children: RelationshipManager +class CoreMenu(CoreNode): + namespace: String + name: String + label: StringOptional + path: StringOptional + description: StringOptional + icon: StringOptional + protected: Boolean + order_weight: Integer + section: Enum + parent: RelationshipManager + children: RelationshipManager + + class CoreProfile(CoreNode): profile_name: String profile_priority: IntegerOptional @@ -365,6 +379,10 @@ class CoreIPPrefixPool(CoreResourcePool, LineageSource): ip_namespace: RelationshipManager +class CoreMenuItem(CoreMenu): + pass + + class CoreNumberPool(CoreResourcePool, LineageSource): node: String node_attribute: String diff --git a/backend/infrahub/core/schema/__init__.py b/backend/infrahub/core/schema/__init__.py index ba92170527..ee64f90510 100644 --- a/backend/infrahub/core/schema/__init__.py +++ b/backend/infrahub/core/schema/__init__.py @@ -7,6 +7,7 @@ from infrahub.core.constants import RESTRICTED_NAMESPACES from infrahub.core.models import HashableModel +from infrahub.exceptions import SchemaNotFoundError from .attribute_schema import AttributeSchema from .basenode_schema import AttributePathParsingError, BaseNodeSchema, SchemaAttributePath, SchemaAttributePathValue @@ -57,6 +58,15 @@ def has_schema(cls, values: dict[str, Any], name: str) -> bool: return True + def get(self, name: str) -> Union[NodeSchema, GenericSchema]: + """Check if a schema exist locally as a node or as a generic.""" + + for item in self.nodes + self.generics: + if item.kind == name: + return item + + raise SchemaNotFoundError(branch_name="undefined", identifier=name) + def validate_namespaces(self) -> list[str]: models = self.nodes + self.generics errors: list[str] = [] diff --git a/backend/infrahub/core/schema/definitions/core.py b/backend/infrahub/core/schema/definitions/core.py index e5a65f3e57..c839c1ccf6 100644 --- a/backend/infrahub/core/schema/definitions/core.py +++ b/backend/infrahub/core/schema/definitions/core.py @@ -3,6 +3,7 @@ from infrahub.core.constants import ( DEFAULT_KIND_MAX_LENGTH, DEFAULT_KIND_MIN_LENGTH, + NAMESPACE_REGEX, AccountRole, AccountStatus, AccountType, @@ -56,6 +57,44 @@ ], } +# ----------------------------------------------- +# Menu Items +# ----------------------------------------------- +generic_menu_item: dict[str, Any] = { + "name": "Menu", + "namespace": "Core", + "include_in_menu": False, + "description": "Base node for the menu", + "label": "Menu Item", + "hierarchical": True, + "uniqueness_constraints": [["namespace__value", "name__value"]], + "attributes": [ + {"name": "namespace", "kind": "Text", "regex": NAMESPACE_REGEX, "order_weight": 1000}, + {"name": "name", "kind": "Text", "order_weight": 1000}, + {"name": "label", "kind": "Text", "optional": True, "order_weight": 2000}, + {"name": "path", "kind": "Text", "optional": True, "order_weight": 2500}, + {"name": "description", "kind": "Text", "optional": True, "order_weight": 3000}, + {"name": "icon", "kind": "Text", "optional": True, "order_weight": 4000}, + {"name": "protected", "kind": "Boolean", "default_value": False, "read_only": True, "order_weight": 5000}, + {"name": "order_weight", "kind": "Number", "default_value": 2000, "order_weight": 6000}, + { + "name": "section", + "kind": "Text", + "enum": ["object", "internal"], + "default_value": "object", + "order_weight": 7000, + }, + ], +} + +menu_item: dict[str, Any] = { + "name": "MenuItem", + "namespace": "Core", + "include_in_menu": False, + "description": "Menu Item", + "label": "Menu Item", + "inherit_from": ["CoreMenu"], +} core_models: dict[str, Any] = { "generics": [ @@ -938,8 +977,10 @@ {"name": "description", "kind": "Text", "optional": True, "order_weight": 3000}, ], }, + generic_menu_item, ], "nodes": [ + menu_item, { "name": "StandardGroup", "namespace": "Core", diff --git a/backend/infrahub/graphql/manager.py b/backend/infrahub/graphql/manager.py index e0f47438d6..805575aa1c 100644 --- a/backend/infrahub/graphql/manager.py +++ b/backend/infrahub/graphql/manager.py @@ -30,6 +30,7 @@ InfrahubIPPrefixMutation, ) from .mutations.main import InfrahubMutation +from .mutations.menu import InfrahubCoreMenuMutation from .mutations.proposed_change import InfrahubProposedChangeMutation from .mutations.repository import InfrahubRepositoryMutation from .mutations.resource_manager import ( @@ -415,6 +416,7 @@ def generate_mutation_mixin(self) -> type[object]: InfrahubKind.GRAPHQLQUERY: InfrahubGraphQLQueryMutation, InfrahubKind.NAMESPACE: InfrahubIPNamespaceMutation, InfrahubKind.NUMBERPOOL: InfrahubNumberPoolMutation, + InfrahubKind.MENUITEM: InfrahubCoreMenuMutation, } if isinstance(node_schema, NodeSchema) and node_schema.is_ip_prefix(): diff --git a/backend/infrahub/graphql/mutations/main.py b/backend/infrahub/graphql/mutations/main.py index a49eff2fcd..2f8a3de052 100644 --- a/backend/infrahub/graphql/mutations/main.py +++ b/backend/infrahub/graphql/mutations/main.py @@ -317,7 +317,7 @@ async def mutate_delete( data: InputObjectType, branch: Branch, at: str, - ): + ) -> tuple[Node, Self]: context: GraphqlContext = info.context obj = await NodeManager.find_object( diff --git a/backend/infrahub/graphql/mutations/menu.py b/backend/infrahub/graphql/mutations/menu.py new file mode 100644 index 0000000000..158f753798 --- /dev/null +++ b/backend/infrahub/graphql/mutations/menu.py @@ -0,0 +1,103 @@ +from typing import TYPE_CHECKING, Any, Optional + +from graphene import InputObjectType, Mutation +from graphql import GraphQLResolveInfo +from typing_extensions import Self + +from infrahub.core.branch import Branch +from infrahub.core.constants import RESTRICTED_NAMESPACES +from infrahub.core.manager import NodeManager +from infrahub.core.node import Node +from infrahub.core.protocols import CoreMenuItem +from infrahub.core.schema import NodeSchema +from infrahub.database import InfrahubDatabase +from infrahub.exceptions import ValidationError +from infrahub.graphql.mutations.main import InfrahubMutationMixin + +from .main import InfrahubMutationOptions + +if TYPE_CHECKING: + from infrahub.graphql.initialization import GraphqlContext + +EXTENDED_RESTRICTED_NAMESPACES = RESTRICTED_NAMESPACES + ["Builtin"] + + +def validate_namespace(data: InputObjectType) -> None: + namespace = data.get("namespace") + if isinstance(namespace, dict) and "value" in namespace: + namespace_value = str(namespace.get("value")) + if namespace_value in EXTENDED_RESTRICTED_NAMESPACES: + raise ValidationError( + input_value={"namespace": f"{namespace_value} is not valid, it's a restricted namespace"} + ) + + +class InfrahubCoreMenuMutation(InfrahubMutationMixin, Mutation): + @classmethod + def __init_subclass_with_meta__( # pylint: disable=arguments-differ + cls, schema: NodeSchema, _meta: Optional[Any] = None, **options: dict[str, Any] + ) -> None: + # Make sure schema is a valid NodeSchema Node Class + if not isinstance(schema, NodeSchema): + raise ValueError(f"You need to pass a valid NodeSchema in '{cls.__name__}.Meta', received '{schema}'") + + if not _meta: + _meta = InfrahubMutationOptions(cls) + _meta.schema = schema + + super().__init_subclass_with_meta__(_meta=_meta, **options) + + @classmethod + async def mutate_create( + cls, + info: GraphQLResolveInfo, + data: InputObjectType, + branch: Branch, + at: str, + database: Optional[InfrahubDatabase] = None, + ) -> tuple[Node, Self]: + validate_namespace(data=data) + + obj, result = await super().mutate_create(info=info, data=data, branch=branch, at=at) + + return obj, result + + @classmethod + async def mutate_update( + cls, + info: GraphQLResolveInfo, + data: InputObjectType, + branch: Branch, + at: str, + database: Optional[InfrahubDatabase] = None, + node: Optional[Node] = None, + ) -> tuple[Node, Self]: + context: GraphqlContext = info.context + + obj = await NodeManager.find_object( + db=context.db, kind=CoreMenuItem, id=data.get("id"), hfid=data.get("hfid"), branch=branch, at=at + ) + validate_namespace(data=data) + + if obj.protected.value: + raise ValidationError(input_value="This object is protected, it can't be modified.") + + obj, result = await super().mutate_update(info=info, data=data, branch=branch, at=at, node=obj) # type: ignore[assignment] + return obj, result # type: ignore[return-value] + + @classmethod + async def mutate_delete( + cls, + info: GraphQLResolveInfo, + data: InputObjectType, + branch: Branch, + at: str, + ) -> tuple[Node, Self]: + context: GraphqlContext = info.context + obj = await NodeManager.find_object( + db=context.db, kind=CoreMenuItem, id=data.get("id"), hfid=data.get("hfid"), branch=branch, at=at + ) + if obj.protected.value: + raise ValidationError(input_value="This object is protected, it can't be deleted.") + + return await super().mutate_delete(info=info, data=data, branch=branch, at=at) diff --git a/backend/infrahub/menu/__init__.py b/backend/infrahub/menu/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/infrahub/menu/constants.py b/backend/infrahub/menu/constants.py new file mode 100644 index 0000000000..e0040eb425 --- /dev/null +++ b/backend/infrahub/menu/constants.py @@ -0,0 +1,10 @@ +from infrahub.utils import InfrahubStringEnum + + +class MenuSection(InfrahubStringEnum): + OBJECT = "object" + INTERNAL = "internal" + + +DEFAULT_MENU = "Other" +FULL_DEFAULT_MENU = "Builtin:Other" diff --git a/backend/infrahub/menu/generator.py b/backend/infrahub/menu/generator.py new file mode 100644 index 0000000000..485d3dbf52 --- /dev/null +++ b/backend/infrahub/menu/generator.py @@ -0,0 +1,101 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from infrahub.core import registry +from infrahub.core.branch import Branch # noqa: TCH001 +from infrahub.core.protocols import CoreMenuItem +from infrahub.log import get_logger + +from .constants import FULL_DEFAULT_MENU +from .models import MenuDict, MenuItemDict + +if TYPE_CHECKING: + from infrahub.auth import AccountSession + from infrahub.database import InfrahubDatabase + +log = get_logger() + + +def get_full_name(obj: CoreMenuItem) -> str: + return f"{obj.namespace.value}:{obj.name.value}" + + +# pylint: disable=too-many-branches +async def generate_menu( + db: InfrahubDatabase, branch: Branch, menu_items: list[CoreMenuItem], account: AccountSession | None = None +) -> MenuDict: + # FIXME temp hack to avoid pylint to complain + account = account # noqa: PLW0127 + + structure = MenuDict() + full_schema = registry.schema.get_full(branch=branch, duplicate=False) + + already_processed = [] + havent_been_processed = [] + + # Process the parent first + for item in menu_items: + full_name = get_full_name(item) + parent = await item.parent.get_peer(db=db, peer_type=CoreMenuItem) + if parent: + havent_been_processed.append(full_name) + continue + structure.data[full_name] = MenuItemDict.from_node(obj=item) + already_processed.append(full_name) + + # Process the children + havent_been_processed = [] + for item in menu_items: + full_name = get_full_name(item) + if full_name in already_processed: + continue + + parent = await item.parent.get_peer(db=db, peer_type=CoreMenuItem) + if not parent: + havent_been_processed.append(full_name) + continue + + parent_full_name = get_full_name(parent) + menu_item = structure.find_item(name=parent_full_name) + if menu_item: + child_item = MenuItemDict.from_node(obj=item) + menu_item.children[child_item.identifier] = child_item + else: + log.warning( + "new_menu_request: unable to find the parent menu item", + branch=branch.name, + menu_item=item.name.value, + parent_item=parent.name.value, + ) + + default_menu = structure.find_item(name=FULL_DEFAULT_MENU) + if not default_menu: + raise ValueError("Unable to locate the default menu item") + + for schema in full_schema.values(): + if schema.include_in_menu is False: + continue + + menu_item = MenuItemDict.from_schema(model=schema) + already_in_schema = bool(structure.find_item(name=menu_item.identifier)) + if already_in_schema: + continue + + if schema.menu_placement: + menu_placement = structure.find_item(name=schema.menu_placement) + + if menu_placement: + menu_placement.children[menu_item.identifier] = menu_item + continue + + log.warning( + "new_menu_request: unable to find the menu_placement defined in the schema", + branch=branch.name, + item=schema.kind, + menu_placement=schema.menu_placement, + ) + + default_menu.children[menu_item.identifier] = menu_item + + return structure diff --git a/backend/infrahub/menu/menu.py b/backend/infrahub/menu/menu.py new file mode 100644 index 0000000000..ba7d554aa8 --- /dev/null +++ b/backend/infrahub/menu/menu.py @@ -0,0 +1,255 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from infrahub.core.constants import infrahubkind as InfrahubKind +from infrahub.core.schema import SchemaRoot, core_models + +from .constants import DEFAULT_MENU, MenuSection +from .models import MenuItemDefinition + +if TYPE_CHECKING: + from infrahub.core.schema import MainSchemaTypes + + +infrahub_schema = SchemaRoot(**core_models) + + +def _extract_node_icon(model: MainSchemaTypes) -> str: + if not model.icon: + return "" + return model.icon + + +default_menu = [ + MenuItemDefinition( + namespace="Builtin", + name=DEFAULT_MENU, + label=DEFAULT_MENU.title(), + protected=True, + section=MenuSection.OBJECT, + ), + MenuItemDefinition( + namespace="Builtin", + name="ObjectManagement", + label="Object Management", + protected=True, + section=MenuSection.INTERNAL, + order_weight=1000, + children=[ + MenuItemDefinition( + namespace="Builtin", + name="Groups", + label="Groups", + kind=InfrahubKind.GENERICGROUP, + icon=_extract_node_icon(infrahub_schema.get(InfrahubKind.GENERICGROUP)), + protected=True, + section=MenuSection.INTERNAL, + order_weight=1000, + ), + MenuItemDefinition( + namespace="Builtin", + name="Profiles", + label="Profiles", + kind=InfrahubKind.PROFILE, + icon=_extract_node_icon(infrahub_schema.get(InfrahubKind.PROFILE)), + protected=True, + section=MenuSection.INTERNAL, + order_weight=2000, + ), + MenuItemDefinition( + namespace="Builtin", + name="ResourceManager", + label="Resource Manager", + path="/resource-manager", + icon=_extract_node_icon(infrahub_schema.get(InfrahubKind.RESOURCEPOOL)), + protected=True, + section=MenuSection.INTERNAL, + order_weight=3000, + ), + ], + ), + MenuItemDefinition( + namespace="Builtin", + name="ChangeControl", + label="Change Control", + protected=True, + section=MenuSection.INTERNAL, + order_weight=2000, + children=[ + MenuItemDefinition( + namespace="Builtin", + name="Branches", + label="Branches", + path="/branche", + icon="mdi:layers-triple", + protected=True, + section=MenuSection.INTERNAL, + order_weight=1000, + ), + MenuItemDefinition( + namespace="Builtin", + name="ProposedChanges", + label="Proposed Changes", + path="/proposed-changes", + icon=_extract_node_icon(infrahub_schema.get(InfrahubKind.PROPOSEDCHANGE)), + protected=True, + section=MenuSection.INTERNAL, + order_weight=2000, + ), + MenuItemDefinition( + namespace="Builtin", + name="CheckDefinition", + label="Check Definition", + kind=InfrahubKind.CHECKDEFINITION, + icon=_extract_node_icon(infrahub_schema.get(InfrahubKind.CHECKDEFINITION)), + protected=True, + section=MenuSection.INTERNAL, + order_weight=3000, + ), + MenuItemDefinition( + namespace="Builtin", + name="Tasks", + label="Tasks", + path="/tasks", + icon="mdi:shield-check", + protected=True, + section=MenuSection.INTERNAL, + order_weight=3000, + ), + ], + ), + MenuItemDefinition( + namespace="Builtin", + name="UnifiedStorage", + label="Unified Storage", + protected=True, + section=MenuSection.INTERNAL, + order_weight=3000, + children=[ + MenuItemDefinition( + namespace="Builtin", + name="Schema", + label="Schema", + path="/schema", + icon="mdi:file-code", + protected=True, + section=MenuSection.INTERNAL, + order_weight=1000, + ), + MenuItemDefinition( + namespace="Builtin", + name="Repository", + label="Repository", + kind=InfrahubKind.GENERICREPOSITORY, + icon=_extract_node_icon(infrahub_schema.get(InfrahubKind.GENERICREPOSITORY)), + protected=True, + section=MenuSection.INTERNAL, + order_weight=2000, + ), + MenuItemDefinition( + namespace="Builtin", + name="GraphqlQuery", + label="GraphQL Query", + kind=InfrahubKind.GRAPHQLQUERY, + icon=_extract_node_icon(infrahub_schema.get(InfrahubKind.GRAPHQLQUERY)), + protected=True, + section=MenuSection.INTERNAL, + order_weight=3000, + ), + ], + ), + MenuItemDefinition( + namespace="Builtin", + name="Admin", + label="Admin", + protected=True, + section=MenuSection.INTERNAL, + order_weight=3000, + children=[ + MenuItemDefinition( + namespace="Builtin", + name="RoleManagement", + label="Role Management", + path="/role-management", + icon=_extract_node_icon(infrahub_schema.get(InfrahubKind.BASEPERMISSION)), + protected=True, + section=MenuSection.INTERNAL, + order_weight=1000, + ), + MenuItemDefinition( + namespace="Builtin", + name="Credentials", + label="Credentials", + kind=InfrahubKind.CREDENTIAL, + icon=_extract_node_icon(infrahub_schema.get(InfrahubKind.CREDENTIAL)), + protected=True, + section=MenuSection.INTERNAL, + order_weight=2000, + ), + MenuItemDefinition( + namespace="Builtin", + name="Webhooks", + label="Webhooks", + icon=_extract_node_icon(infrahub_schema.get(InfrahubKind.CUSTOMWEBHOOK)), + protected=True, + section=MenuSection.INTERNAL, + order_weight=3000, + children=[ + MenuItemDefinition( + namespace="Builtin", + name="WebhookStandard", + label="Webhook", + kind=InfrahubKind.STANDARDWEBHOOK, + icon=_extract_node_icon(infrahub_schema.get(InfrahubKind.STANDARDWEBHOOK)), + protected=True, + section=MenuSection.INTERNAL, + order_weight=1000, + ), + MenuItemDefinition( + namespace="Builtin", + name="WebhookCustom", + label="Custom Webhook", + kind=InfrahubKind.CUSTOMWEBHOOK, + icon=_extract_node_icon(infrahub_schema.get(InfrahubKind.CUSTOMWEBHOOK)), + protected=True, + section=MenuSection.INTERNAL, + order_weight=2000, + ), + ], + ), + ], + ), +] + + +# deployment = InterfaceMenu( +# title="Deployment", +# children=[ +# InterfaceMenu( +# title="Artifact", +# kind=InfrahubKind.ARTIFACT}", +# icon=_extract_node_icon(full_schema[InfrahubKind.ARTIFACT]), +# ), +# InterfaceMenu( +# title="Artifact Definition", +# kind=InfrahubKind.ARTIFACTDEFINITION}", +# icon=_extract_node_icon(full_schema[InfrahubKind.ARTIFACTDEFINITION]), +# ), +# InterfaceMenu( +# title="Generator Definition", +# kind=InfrahubKind.GENERATORDEFINITION}", +# icon=_extract_node_icon(full_schema[InfrahubKind.GENERATORDEFINITION]), +# ), +# InterfaceMenu( +# title="Generator Instance", +# kind=InfrahubKind.GENERATORINSTANCE}", +# icon=_extract_node_icon(full_schema[InfrahubKind.GENERATORINSTANCE]), +# ), +# InterfaceMenu( +# title="Transformation", +# kind=InfrahubKind.TRANSFORM}", +# icon=_extract_node_icon(full_schema[InfrahubKind.TRANSFORM]), +# ), +# ], +# ) diff --git a/backend/infrahub/menu/models.py b/backend/infrahub/menu/models.py new file mode 100644 index 0000000000..9d68cd859b --- /dev/null +++ b/backend/infrahub/menu/models.py @@ -0,0 +1,159 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Self + +from pydantic import BaseModel, Field + +from infrahub.core.node import Node +from infrahub.core.protocols import CoreMenuItem +from infrahub.core.schema import GenericSchema, MainSchemaTypes, NodeSchema, ProfileSchema + +from .constants import MenuSection + +if TYPE_CHECKING: + from infrahub.database import InfrahubDatabase + + +def get_full_name(obj: CoreMenuItem | NodeSchema | GenericSchema | ProfileSchema) -> str: + if isinstance(obj, (NodeSchema, GenericSchema, ProfileSchema)): + return _get_full_name_schema(obj) + return _get_full_name_node(obj) + + +def _get_full_name_node(obj: CoreMenuItem) -> str: + return f"{obj.namespace.value}:{obj.name.value}" + + +def _get_full_name_schema(node: MainSchemaTypes) -> str: + return f"{node.namespace}:{node.name}" + + +@dataclass +class MenuDict: + data: dict[str, MenuItemDict] = field(default_factory=dict) + + def find_item(self, name: str) -> MenuItemDict | None: + return self._find_child_item(name=name, children=self.data) + + @classmethod + def _find_child_item(cls, name: str, children: dict[str, MenuItemDict]) -> MenuItemDict | None: + if name in children.keys(): + return children[name] + + for child in children.values(): + if not child.children: + continue + found = cls._find_child_item(name=name, children=child.children) + if found: + return found + return None + + def to_rest(self) -> Menu: + data: dict[str, list[MenuItemList]] = {} + + for section in [MenuSection.INTERNAL, MenuSection.OBJECT]: + item_per_section = [value.to_list() for key, value in self.data.items() if value.section == section] + data[section.value] = sorted(item_per_section, key=lambda d: d.order_weight) + + return Menu(sections=data) + + # @staticmethod + # def _sort_menu_items(items: dict[str, MenuItem]) -> dict[str, MenuItem]: + # sorted_dict = dict(sorted(items.items(), key=lambda x: (x[1].order_weight, x[0]), reverse=False)) + # return sorted_dict + + +@dataclass +class Menu: + sections: dict[str, list[MenuItemList]] = field(default_factory=dict) + + +class MenuItem(BaseModel): + identifier: str = Field(..., description="Unique identifier for this menu item") + title: str = Field(..., description="Title of the menu item") + path: str = Field(default="", description="URL endpoint if applicable") + icon: str = Field(default="", description="The icon to show for the current view") + kind: str = Field(default="", description="Kind of the model associated with this menuitem if applicable") + order_weight: int = 5000 + section: MenuSection = MenuSection.OBJECT + + @classmethod + def from_node(cls, obj: CoreMenuItem) -> Self: + return cls( + identifier=get_full_name(obj), + title=obj.label.value or "", + icon=obj.icon.value or "", + order_weight=obj.order_weight.value, + path=obj.path.value or "", + kind=obj.get_kind(), + section=obj.section.value, + ) + + @classmethod + def from_schema(cls, model: NodeSchema | GenericSchema | ProfileSchema) -> Self: + return cls( + identifier=get_full_name(model), + title=model.label or model.kind, + path=f"/objects/{model.kind}", + icon=model.icon or "", + kind=model.kind, + ) + + +class MenuItemDict(MenuItem): + children: dict[str, MenuItemDict] = Field(default_factory=dict, description="Child objects") + + def to_list(self) -> MenuItemList: + data = self.model_dump(exclude={"children"}) + unsorted_children = [child.to_list() for child in self.children.values()] + data["children"] = sorted(unsorted_children, key=lambda d: d.order_weight) + return MenuItemList(**data) + + +class MenuItemList(MenuItem): + children: list[MenuItemList] = Field(default_factory=list, description="Child objects") + + +class MenuItemDefinition(BaseModel): + namespace: str + name: str + label: str + description: str = "" + icon: str = "" + protected: bool = False + path: str = "" + kind: str = "" + section: MenuSection = MenuSection.OBJECT + order_weight: int = 2000 + children: list[MenuItemDefinition] = Field(default_factory=list) + + async def to_node(self, db: InfrahubDatabase, parent: CoreMenuItem | None = None) -> CoreMenuItem: + obj = await Node.init(db=db, schema=CoreMenuItem) + await obj.new( + db=db, + namespace=self.namespace, + name=self.name, + label=self.label, + path=self.get_path(), + description=self.description or None, + icon=self.icon or None, + protected=self.protected, + section=self.section.value, + order_weight=self.order_weight, + parent=parent.id if parent else None, + ) + return obj + + def get_path(self) -> str | None: + if self.path: + return self.path + + if self.kind: + return f"/objects/{self.kind}" + + return None + + @property + def full_name(self) -> str: + return f"{self.namespace}:{self.name}" diff --git a/backend/tests/unit/api/test_menu.py b/backend/tests/unit/api/test_menu.py index d727792c1c..4d37efaf70 100644 --- a/backend/tests/unit/api/test_menu.py +++ b/backend/tests/unit/api/test_menu.py @@ -1,5 +1,6 @@ from infrahub.api.menu import InterfaceMenu from infrahub.core.branch import Branch +from infrahub.core.initialization import create_default_menu from infrahub.core.schema import SchemaRoot from infrahub.database import InfrahubDatabase @@ -24,3 +25,23 @@ async def test_get_menu( menu = [InterfaceMenu(**menu_item) for menu_item in response.json()] assert menu[0].title == "Objects" assert menu[0].children[0].title == "Car" + + +async def test_get_new_menu( + db: InfrahubDatabase, + client, + client_headers, + default_branch: Branch, + car_person_schema_generics: SchemaRoot, + car_person_data_generic, +): + await create_default_menu(db=db) + + with client: + response = client.get( + "/api/menu/new", + headers=client_headers, + ) + + assert response.status_code == 200 + assert response.json() is not None diff --git a/backend/tests/unit/graphql/mutations/test_menu.py b/backend/tests/unit/graphql/mutations/test_menu.py new file mode 100644 index 0000000000..857d8f6072 --- /dev/null +++ b/backend/tests/unit/graphql/mutations/test_menu.py @@ -0,0 +1,115 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from infrahub.menu.constants import MenuSection +from infrahub.menu.models import MenuItemDefinition +from infrahub.services import InfrahubServices +from tests.helpers.graphql import graphql_mutation + +if TYPE_CHECKING: + from infrahub.core.branch import Branch + from infrahub.database import InfrahubDatabase + + +async def test_menu_create(db: InfrahubDatabase, register_core_models_schema: None, default_branch: Branch): + service = InfrahubServices(database=db) + + CREATE_MENU = """ + mutation CoreMenuItemCreate { + CoreMenuItemCreate( + data: { + namespace: { value: "Builtin" } + name: { value: "TestCar" } + } + ) { + ok + } + } + """ + + result = await graphql_mutation( + query=CREATE_MENU, + db=db, + service=service, + ) + assert "Builtin is not valid" in result.errors[0].args[0] + + +async def test_menu_update_protected(db: InfrahubDatabase, register_core_models_schema: None, default_branch: Branch): + service = InfrahubServices(database=db) + + menu_item = MenuItemDefinition( + namespace="Builtin", + name="Branches", + label="Branches", + path="/branche", + icon="mdi:layers-triple", + protected=True, + section=MenuSection.INTERNAL, + order_weight=1000, + ) + obj = await menu_item.to_node(db=db) + await obj.save(db=db) + + UPDATE_MENU = """ + mutation CoreMenuItemUpdate($id: String!) { + CoreMenuItemUpdate( + data: { + id: $id + name: { value: "TestCar" } + } + ) { + ok + } + } + """ + + result = await graphql_mutation( + query=UPDATE_MENU, + db=db, + variables={"id": obj.id}, + service=service, + ) + + assert result.errors + assert "This object is protected" in result.errors[0].args[0] + + +async def test_menu_delete_protected(db: InfrahubDatabase, register_core_models_schema: None, default_branch: Branch): + service = InfrahubServices(database=db) + + menu_item = MenuItemDefinition( + namespace="Builtin", + name="Branches", + label="Branches", + path="/branche", + icon="mdi:layers-triple", + protected=True, + section=MenuSection.INTERNAL, + order_weight=1000, + ) + obj = await menu_item.to_node(db=db) + await obj.save(db=db) + + DELETE_MENU = """ + mutation CoreMenuItemDelete($id: String!) { + CoreMenuItemDelete( + data: { + id: $id + } + ) { + ok + } + } + """ + + result = await graphql_mutation( + query=DELETE_MENU, + db=db, + variables={"id": obj.id}, + service=service, + ) + + assert result.errors + assert "This object is protected" in result.errors[0].args[0] diff --git a/backend/tests/unit/menu/__init__.py b/backend/tests/unit/menu/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/tests/unit/menu/test_generator.py b/backend/tests/unit/menu/test_generator.py new file mode 100644 index 0000000000..1781ae4c02 --- /dev/null +++ b/backend/tests/unit/menu/test_generator.py @@ -0,0 +1,46 @@ +from infrahub.core import registry +from infrahub.core.branch import Branch +from infrahub.core.initialization import create_default_menu +from infrahub.core.protocols import CoreMenuItem +from infrahub.core.schema import SchemaRoot +from infrahub.database import InfrahubDatabase +from infrahub.menu.constants import MenuSection +from infrahub.menu.generator import generate_menu +from infrahub.menu.models import MenuItemDefinition + + +async def test_generate_menu( + db: InfrahubDatabase, + default_branch: Branch, + car_person_schema_generics: SchemaRoot, +): + schema_branch = registry.schema.get_schema_branch(name=default_branch.name) + + schema_electriccar = schema_branch.get(name="TestElectricCar") + schema_electriccar.menu_placement = "Builtin:ObjectManagement" + schema_branch.set(name="TestElectricCar", schema=schema_electriccar) + + await create_default_menu(db=db) + + new_menu_items = [ + MenuItemDefinition( + namespace="Test", + name="CarGaz", + label="Car Gaz", + kind="TestCarGaz", + section=MenuSection.OBJECT, + order_weight=1500, + ) + ] + + for item in new_menu_items: + obj = await item.to_node(db=db) + await obj.save(db=db) + + menu_items = await registry.manager.query( + db=db, schema=CoreMenuItem, branch=default_branch, prefetch_relationships=True + ) + menu = await generate_menu(db=db, branch=default_branch, menu_items=menu_items) + + assert menu + assert "Test:CarGaz" in menu.data.keys() diff --git a/backend/tests/unit/message_bus/operations/requests/test_proposed_change.py b/backend/tests/unit/message_bus/operations/requests/test_proposed_change.py index 2867db8e1e..a78796c70e 100644 --- a/backend/tests/unit/message_bus/operations/requests/test_proposed_change.py +++ b/backend/tests/unit/message_bus/operations/requests/test_proposed_change.py @@ -128,8 +128,8 @@ async def test_get_proposed_change_schema_integrity_constraints( ) non_generate_profile_constraints = [c for c in constraints if c.constraint_name != "node.generate_profile.update"] # should be updated/removed when ConstraintValidatorDeterminer is updated (#2592) - assert len(constraints) == 174 - assert len(non_generate_profile_constraints) == 104 + assert len(constraints) == 181 + assert len(non_generate_profile_constraints) == 109 dumped_constraints = [c.model_dump() for c in non_generate_profile_constraints] assert { "constraint_name": "relationship.optional.update", diff --git a/pyproject.toml b/pyproject.toml index bb8af90c57..43ed310d9b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -151,6 +151,7 @@ disable = """, too-many-return-statements, unnecessary-comprehension, multiple-statements, + self-assigning-variable, """ [tool.pylint.miscellaneous] diff --git a/python_sdk b/python_sdk index 656b3820fd..e82e31d915 160000 --- a/python_sdk +++ b/python_sdk @@ -1 +1 @@ -Subproject commit 656b3820fdbbfe4e9eb907a32ea10abea9860c35 +Subproject commit e82e31d9150fd797dc1892b8b72035660754a6fe