Skip to content

Commit 7841223

Browse files
authored
Merge pull request #5990 from opsmill/dga-20250311-menu-migration
Add migration to update the default menu
2 parents 7202022 + 4ff4f40 commit 7841223

File tree

9 files changed

+974
-44
lines changed

9 files changed

+974
-44
lines changed

backend/infrahub/cli/db.py

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,6 @@
2323
from infrahub.core.graph.schema import GRAPH_SCHEMA
2424
from infrahub.core.initialization import (
2525
create_anonymous_role,
26-
create_default_menu,
2726
create_default_roles,
2827
create_super_administrator_role,
2928
create_super_administrators_group,
@@ -44,6 +43,9 @@
4443
from infrahub.core.validators.tasks import schema_validate_migrations
4544
from infrahub.database import DatabaseType
4645
from infrahub.log import get_logger
46+
from infrahub.menu.menu import default_menu
47+
from infrahub.menu.models import MenuDict
48+
from infrahub.menu.utils import create_default_menu, get_existing_menu, update_menu
4749
from infrahub.services import InfrahubServices
4850
from infrahub.services.adapters.message_bus.local import BusSimulator
4951
from infrahub.services.adapters.workflow.local import WorkflowLocalExecution
@@ -415,13 +417,14 @@ async def create_defaults(db: InfrahubDatabase) -> None:
415417
if not existing_permissions:
416418
await setup_permissions(db=db)
417419

418-
existing_menu_items = await NodeManager.query(
419-
schema=InfrahubKind.MENUITEM,
420-
db=db,
421-
limit=1,
422-
)
423-
if not existing_menu_items:
420+
menu_nodes = await get_existing_menu(db=db)
421+
menu_items = await MenuDict.from_db(db=db, nodes=list(menu_nodes.values()))
422+
default_menu_dict = MenuDict.from_definition_list(default_menu)
423+
424+
if not menu_nodes:
424425
await create_default_menu(db=db)
426+
else:
427+
await update_menu(db=db, existing_menu=menu_items, new_menu=default_menu_dict, menu_nodes=menu_nodes)
425428

426429

427430
async def setup_permissions(db: InfrahubDatabase) -> None:

backend/infrahub/core/initialization.py

Lines changed: 1 addition & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,7 @@
2828
from infrahub.exceptions import DatabaseError
2929
from infrahub.graphql.manager import GraphQLSchemaManager
3030
from infrahub.log import get_logger
31-
from infrahub.menu.menu import default_menu
32-
from infrahub.menu.utils import create_menu_children
31+
from infrahub.menu.utils import create_default_menu
3332
from infrahub.permissions import PermissionBackend
3433
from infrahub.storage import InfrahubObjectStorage
3534

@@ -305,14 +304,6 @@ async def create_ipam_namespace(
305304
return obj
306305

307306

308-
async def create_default_menu(db: InfrahubDatabase) -> None:
309-
for item in default_menu:
310-
obj = await item.to_node(db=db)
311-
await obj.save(db=db)
312-
if item.children:
313-
await create_menu_children(db=db, parent=obj, children=item.children)
314-
315-
316307
async def create_super_administrator_role(db: InfrahubDatabase) -> Node:
317308
permission = await Node.init(db=db, schema=InfrahubKind.GLOBALPERMISSION)
318309
await permission.new(

backend/infrahub/menu/generator.py

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ async def generate_menu(db: InfrahubDatabase, branch: Branch, menu_items: list[C
7070
menu_item = structure.find_item(name=parent_full_name)
7171
if menu_item:
7272
child_item = MenuItemDict.from_node(obj=item)
73-
menu_item.children[child_item.identifier] = child_item
73+
menu_item.children[str(child_item.identifier)] = child_item
7474
else:
7575
log.warning(
7676
"new_menu_request: unable to find the parent menu item",
@@ -90,20 +90,20 @@ async def generate_menu(db: InfrahubDatabase, branch: Branch, menu_items: list[C
9090

9191
schema = full_schema[item_name]
9292
menu_item = MenuItemDict.from_schema(model=schema)
93-
already_in_schema = bool(structure.find_item(name=menu_item.identifier))
93+
already_in_schema = bool(structure.find_item(name=str(menu_item.identifier)))
9494
if already_in_schema:
9595
items_to_add[item_name] = True
9696
continue
9797

9898
if not schema.menu_placement:
9999
first_element = MenuItemDict.from_schema(model=schema)
100-
first_element.identifier = f"{first_element.identifier}Sub"
100+
first_element.name = f"{first_element.name}Sub"
101101
first_element.order_weight = 1
102-
menu_item.children[first_element.identifier] = first_element
103-
structure.data[menu_item.identifier] = menu_item
102+
menu_item.children[str(first_element.identifier)] = first_element
103+
structure.data[str(menu_item.identifier)] = menu_item
104104
items_to_add[item_name] = True
105105
elif menu_placement := structure.find_item(name=schema.menu_placement):
106-
menu_placement.children[menu_item.identifier] = menu_item
106+
menu_placement.children[str(menu_item.identifier)] = menu_item
107107
items_to_add[item_name] = True
108108
continue
109109

@@ -130,7 +130,7 @@ async def generate_menu(db: InfrahubDatabase, branch: Branch, menu_items: list[C
130130
item=schema.kind,
131131
menu_placement=schema.menu_placement,
132132
)
133-
default_menu.children[menu_item.identifier] = menu_item
133+
default_menu.children[str(menu_item.identifier)] = menu_item
134134
items_to_add[item_name] = True
135135

136136
return structure

backend/infrahub/menu/models.py

Lines changed: 148 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
from __future__ import annotations
22

33
from dataclasses import dataclass, field
4-
from typing import TYPE_CHECKING
4+
from typing import TYPE_CHECKING, Any
55

6-
from pydantic import BaseModel, Field
6+
from pydantic import BaseModel, Field, computed_field
77
from typing_extensions import Self
88

99
from infrahub.core.account import GlobalPermission
@@ -35,21 +35,26 @@ def _get_full_name_schema(node: MainSchemaTypes) -> str:
3535
class MenuDict:
3636
data: dict[str, MenuItemDict] = field(default_factory=dict)
3737

38+
def get_item_location(self, name: str) -> list[str]:
39+
location, _ = self._find_child_item(name=name, children=self.data)
40+
return location
41+
3842
def find_item(self, name: str) -> MenuItemDict | None:
39-
return self._find_child_item(name=name, children=self.data)
43+
_, item = self._find_child_item(name=name, children=self.data)
44+
return item
4045

4146
@classmethod
42-
def _find_child_item(cls, name: str, children: dict[str, MenuItemDict]) -> MenuItemDict | None:
47+
def _find_child_item(cls, name: str, children: dict[str, MenuItemDict]) -> tuple[list[str], MenuItemDict | None]:
4348
if name in children.keys():
44-
return children[name]
49+
return [], children[name]
4550

4651
for child in children.values():
4752
if not child.children:
4853
continue
49-
found = cls._find_child_item(name=name, children=child.children)
54+
position, found = cls._find_child_item(name=name, children=child.children)
5055
if found:
51-
return found
52-
return None
56+
return [str(child.identifier)] + position, found
57+
return [], None
5358

5459
def to_rest(self) -> Menu:
5560
data: dict[str, list[MenuItemList]] = {}
@@ -62,10 +67,44 @@ def to_rest(self) -> Menu:
6267

6368
return Menu(sections=data)
6469

65-
# @staticmethod
66-
# def _sort_menu_items(items: dict[str, MenuItem]) -> dict[str, MenuItem]:
67-
# sorted_dict = dict(sorted(items.items(), key=lambda x: (x[1].order_weight, x[0]), reverse=False))
68-
# return sorted_dict
70+
@classmethod
71+
def from_definition_list(cls, definitions: list[MenuItemDefinition]) -> Self:
72+
menu = cls()
73+
for definition in definitions:
74+
menu.data[definition.full_name] = MenuItemDict.from_definition(definition=definition)
75+
return menu
76+
77+
def get_all_identifiers(self) -> set[str]:
78+
return {identifier for item in self.data.values() for identifier in item.get_all_identifiers()}
79+
80+
@classmethod
81+
async def from_db(cls, db: InfrahubDatabase, nodes: list[CoreMenuItem]) -> Self:
82+
menu = cls()
83+
menu_by_ids = {menu_node.get_id(): MenuItemDict.from_node(menu_node) for menu_node in nodes}
84+
85+
async def add_children(menu_item: MenuItemDict, menu_node: CoreMenuItem) -> MenuItemDict:
86+
children = await menu_node.children.get_peers(db=db, peer_type=CoreMenuItem)
87+
for child_id, child_node in children.items():
88+
child_menu_item = menu_by_ids[child_id]
89+
child = await add_children(child_menu_item, child_node)
90+
menu_item.children[str(child.identifier)] = child
91+
return menu_item
92+
93+
for menu_node in nodes:
94+
menu_item = menu_by_ids[menu_node.get_id()]
95+
parent = await menu_node.parent.get_peer(db=db, peer_type=CoreMenuItem)
96+
if parent:
97+
continue
98+
99+
children = await menu_node.children.get_peers(db=db, peer_type=CoreMenuItem)
100+
for child_id, child_node in children.items():
101+
child_menu_item = menu_by_ids[child_id]
102+
child = await add_children(child_menu_item, child_node)
103+
menu_item.children[str(child.identifier)] = child
104+
105+
menu.data[str(menu_item.identifier)] = menu_item
106+
107+
return menu
69108

70109

71110
@dataclass
@@ -74,7 +113,11 @@ class Menu:
74113

75114

76115
class MenuItem(BaseModel):
77-
identifier: str = Field(..., description="Unique identifier for this menu item")
116+
_id: str | None = None
117+
namespace: str = Field(..., description="Namespace of the menu item")
118+
name: str = Field(..., description="Name of the menu item")
119+
description: str = Field(default="", description="Description of the menu item")
120+
protected: bool = Field(default=False, description="Whether the menu item is protected")
78121
label: str = Field(..., description="Title of the menu item")
79122
path: str = Field(default="", description="URL endpoint if applicable")
80123
icon: str = Field(default="", description="The icon to show for the current view")
@@ -83,10 +126,27 @@ class MenuItem(BaseModel):
83126
section: MenuSection = MenuSection.OBJECT
84127
permissions: list[str] = Field(default_factory=list)
85128

129+
@computed_field
130+
def identifier(self) -> str:
131+
return f"{self.namespace}{self.name}"
132+
133+
def get_path(self) -> str | None:
134+
if self.path:
135+
return self.path
136+
137+
if self.kind:
138+
return f"/objects/{self.kind}"
139+
140+
return None
141+
86142
@classmethod
87143
def from_node(cls, obj: CoreMenuItem) -> Self:
88144
return cls(
89-
identifier=get_full_name(obj),
145+
_id=obj.get_id(),
146+
name=obj.name.value,
147+
namespace=obj.namespace.value,
148+
protected=obj.protected.value,
149+
description=obj.description.value or "",
90150
label=obj.label.value or "",
91151
icon=obj.icon.value or "",
92152
order_weight=obj.order_weight.value,
@@ -96,10 +156,30 @@ def from_node(cls, obj: CoreMenuItem) -> Self:
96156
permissions=obj.required_permissions.value or [],
97157
)
98158

159+
async def to_node(self, db: InfrahubDatabase, parent: CoreMenuItem | None = None) -> CoreMenuItem:
160+
obj = await Node.init(db=db, schema=CoreMenuItem)
161+
await obj.new(
162+
db=db,
163+
namespace=self.namespace,
164+
name=self.name,
165+
label=self.label,
166+
kind=self.kind,
167+
path=self.get_path(),
168+
description=self.description or None,
169+
icon=self.icon or None,
170+
protected=self.protected,
171+
section=self.section.value,
172+
order_weight=self.order_weight,
173+
parent=parent.id if parent else None,
174+
required_permissions=self.permissions,
175+
)
176+
return obj
177+
99178
@classmethod
100179
def from_schema(cls, model: NodeSchema | GenericSchema | ProfileSchema | TemplateSchema) -> Self:
101180
return cls(
102-
identifier=get_full_name(model),
181+
name=model.name,
182+
namespace=model.namespace,
103183
label=model.label or model.kind,
104184
path=f"/objects/{model.kind}",
105185
icon=model.icon or "",
@@ -111,8 +191,14 @@ class MenuItemDict(MenuItem):
111191
hidden: bool = False
112192
children: dict[str, MenuItemDict] = Field(default_factory=dict, description="Child objects")
113193

194+
def get_all_identifiers(self) -> set[str]:
195+
identifiers: set[str] = {str(self.identifier)}
196+
for child in self.children.values():
197+
identifiers.update(child.get_all_identifiers())
198+
return identifiers
199+
114200
def to_list(self) -> MenuItemList:
115-
data = self.model_dump(exclude={"children"})
201+
data = self.model_dump(exclude={"children", "id"})
116202
unsorted_children = [child.to_list() for child in self.children.values() if child.hidden is False]
117203
data["children"] = sorted(unsorted_children, key=lambda d: d.order_weight)
118204
return MenuItemList(**data)
@@ -125,12 +211,42 @@ def get_global_permissions(self) -> list[GlobalPermission]:
125211
permissions.append(GlobalPermission.from_string(input=permission))
126212
return permissions
127213

214+
def diff_attributes(self, other: Self) -> dict[str, Any]:
215+
other_attributes = other.model_dump(exclude={"children"})
216+
self_attributes = self.model_dump(exclude={"children"})
217+
return {
218+
key: value
219+
for key, value in other_attributes.items()
220+
if value != self_attributes[key] and key not in ["id", "children"]
221+
}
222+
223+
@classmethod
224+
def from_definition(cls, definition: MenuItemDefinition) -> Self:
225+
menu_item = cls(
226+
name=definition.name,
227+
namespace=definition.namespace,
228+
label=definition.label,
229+
path=definition.get_path() or "",
230+
icon=definition.icon,
231+
kind=definition.kind,
232+
protected=definition.protected,
233+
section=definition.section,
234+
permissions=definition.permissions,
235+
order_weight=definition.order_weight,
236+
)
237+
238+
for child in definition.children:
239+
menu_item.children[child.full_name] = MenuItemDict.from_definition(definition=child)
240+
241+
return menu_item
242+
128243

129244
class MenuItemList(MenuItem):
130245
children: list[MenuItemList] = Field(default_factory=list, description="Child objects")
131246

132247

133248
class MenuItemDefinition(BaseModel):
249+
_id: str | None = None
134250
namespace: str
135251
name: str
136252
label: str
@@ -162,6 +278,22 @@ async def to_node(self, db: InfrahubDatabase, parent: CoreMenuItem | None = None
162278
)
163279
return obj
164280

281+
@classmethod
282+
async def from_node(cls, node: CoreMenuItem) -> Self:
283+
return cls(
284+
_id=node.get_id(),
285+
namespace=node.namespace.value,
286+
name=node.name.value,
287+
label=node.label.value or "",
288+
description=node.description.value or "",
289+
icon=node.icon.value or "",
290+
protected=node.protected.value,
291+
path=node.path.value or "",
292+
kind=node.kind.value or "",
293+
section=node.section.value,
294+
order_weight=node.order_weight.value,
295+
)
296+
165297
def get_path(self) -> str | None:
166298
if self.path:
167299
return self.path

0 commit comments

Comments
 (0)