Coverage for src/lexigram/admin/resources/permissions_renderer.py: 100%

23 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-21 15:04 +0800

1"""Renderer for the per-user permission editing page. 

2 

3Serves the direct-permission editor for admin users (the page the legacy 

4RbacController served at ``/admin/users/{id}/permissions``) through the 

5Resource handler path. 

6""" 

7 

8from __future__ import annotations 

9 

10from typing import TYPE_CHECKING, Any 

11 

12from starlette.responses import HTMLResponse 

13 

14from lexigram.ui import el 

15 

16if TYPE_CHECKING: 

17 from lexigram.admin.rbac.inventory import PermissionInventoryService 

18 

19 

20def _checkbox(perm: str, checked: bool) -> Any: 

21 """Build a single permission checkbox input.""" 

22 return el( 

23 "input", 

24 type="checkbox", 

25 name="permissions", 

26 value=perm, 

27 checked=checked or None, 

28 class_="h-4 w-4 rounded border-input text-primary focus:ring-ring bg-background", 

29 ) 

30 

31 

32class UserPermissionsRenderer: 

33 """Render the grouped user permission checkboxes inside the admin shell. 

34 

35 Attributes: 

36 resource_name: The admin resource name (``users``). 

37 renderer: The engine renderer used to wrap content in the admin 

38 shell; overridable for tests. 

39 """ 

40 

41 def __init__(self, resource_name: str, engine_renderer: Any | None = None) -> None: 

42 """Initialize the renderer. 

43 

44 Args: 

45 resource_name: The admin resource name (``users``). 

46 engine_renderer: Optional engine renderer; a fresh 

47 :class:`~lexigram.admin.engine.renderer.AdminRenderer` is 

48 built when omitted. 

49 """ 

50 self.resource_name = resource_name 

51 if engine_renderer is None: 

52 from lexigram.admin.engine.renderer import ( 

53 AdminRenderer as EngineAdminRenderer, 

54 ) 

55 

56 engine_renderer = EngineAdminRenderer() 

57 self._renderer = engine_renderer 

58 

59 def render_form( 

60 self, 

61 request: Any, 

62 user: Any, 

63 inventory: PermissionInventoryService, 

64 item_id: str, 

65 prefix: str, 

66 ) -> HTMLResponse: 

67 """Render the permissions form as a full admin page. 

68 

69 Args: 

70 request: The current request (carries the CSRF token in 

71 ``request.state``). 

72 user: The admin user being edited (must not be ``None``). 

73 inventory: Grouped permission inventory ``{resource: [perm]}``. 

74 item_id: The user id from the URL path. 

75 prefix: Admin resource prefix (``admin/users``). 

76 

77 Returns: 

78 A full-page HTMLResponse wrapped in the admin shell. 

79 """ 

80 options = inventory.options() or {} 

81 selected = set(getattr(user, "permissions", None) or []) 

82 all_options = {p for perms in options.values() for p in perms} 

83 preserved = [ 

84 el("input", type="hidden", name="permissions", value=perm) 

85 for perm in sorted(selected - all_options) 

86 ] 

87 

88 groups = [ 

89 el( 

90 "fieldset", 

91 el("legend", perm_name, class_="font-semibold text-foreground"), 

92 *[_checkbox(perm, perm in selected) for perm in perms], 

93 class_="mb-5 border rounded-lg p-4", 

94 ) 

95 for perm_name, perms in options.items() 

96 ] 

97 

98 user_label = ( 

99 f"{getattr(user, 'name', '')} <{getattr(user, 'email', '')}>" 

100 if user is not None 

101 else "unknown user" 

102 ) 

103 

104 form = el( 

105 "form", 

106 el( 

107 "input", 

108 type="hidden", 

109 name="csrf_token", 

110 value=getattr(request.state, "csrf_token", ""), 

111 ), 

112 *preserved, 

113 el("p", user_label, class_="mb-4 text-sm text-foreground"), 

114 *groups, 

115 el( 

116 "button", 

117 "Save Permissions", 

118 type="submit", 

119 class_="mt-4 inline-flex items-center rounded bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90", 

120 ), 

121 method="post", 

122 action=f"/admin/{prefix}/{item_id}/permissions", 

123 ) 

124 

125 content = el( 

126 "div", 

127 el( 

128 "h1", 

129 "Edit Permissions", 

130 class_="text-2xl font-bold text-foreground mb-4", 

131 ), 

132 form, 

133 class_="resource-content bg-card shadow rounded-lg p-6", 

134 ) 

135 

136 return self._renderer.render_page( 

137 content, 

138 request=request, 

139 title="User Permissions", 

140 breadcrumbs=[ 

141 {"label": "Dashboard", "url": f"/admin/{prefix}"}, 

142 {"label": "Users", "url": f"/admin/{prefix}"}, 

143 { 

144 "label": user_label, 

145 "url": f"/admin/{prefix}/{item_id}/permissions", 

146 }, 

147 ], 

148 )