Coverage for .tox / coverage / lib / python3.11 / site-packages / wuttaweb / views / views.py: 100%

229 statements  

« prev     ^ index     » next       coverage.py v7.13.1, created at 2025-12-31 19:25 -0600

1# -*- coding: utf-8; -*- 

2################################################################################ 

3# 

4# wuttaweb -- Web App for Wutta Framework 

5# Copyright © 2024-2025 Lance Edgar 

6# 

7# This file is part of Wutta Framework. 

8# 

9# Wutta Framework is free software: you can redistribute it and/or modify it 

10# under the terms of the GNU General Public License as published by the Free 

11# Software Foundation, either version 3 of the License, or (at your option) any 

12# later version. 

13# 

14# Wutta Framework is distributed in the hope that it will be useful, but 

15# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or 

16# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for 

17# more details. 

18# 

19# You should have received a copy of the GNU General Public License along with 

20# Wutta Framework. If not, see <http://www.gnu.org/licenses/>. 

21# 

22################################################################################ 

23""" 

24Views of Views 

25""" 

26 

27import importlib 

28import logging 

29import os 

30import re 

31import sys 

32 

33from mako.lookup import TemplateLookup 

34 

35from wuttaweb.views import MasterView 

36from wuttaweb.util import get_model_fields 

37 

38 

39log = logging.getLogger(__name__) 

40 

41 

42class MasterViewView(MasterView): # pylint: disable=abstract-method 

43 """ 

44 Master view which shows a list of all master views found in the 

45 app registry. 

46 

47 Route prefix is ``master_views``; notable URLs provided by this 

48 class include: 

49 

50 * ``/views/master/`` 

51 """ 

52 

53 model_name = "master_view" 

54 model_title = "Master View" 

55 model_title_plural = "Master Views" 

56 url_prefix = "/views/master" 

57 

58 filterable = False 

59 sortable = True 

60 sort_on_backend = False 

61 paginated = True 

62 paginate_on_backend = False 

63 

64 creatable = True 

65 viewable = False # nb. it has a pseudo-view action instead 

66 editable = False 

67 deletable = False 

68 configurable = True 

69 

70 labels = { 

71 "model_title_plural": "Title", 

72 "url_prefix": "URL Prefix", 

73 } 

74 

75 grid_columns = [ 

76 "model_title_plural", 

77 "model_name", 

78 "route_prefix", 

79 "url_prefix", 

80 ] 

81 

82 sort_defaults = "model_title_plural" 

83 

84 def get_grid_data( # pylint: disable=empty-docstring 

85 self, columns=None, session=None 

86 ): 

87 """ """ 

88 data = [] 

89 

90 # nb. we do not omit any views due to lack of permission here. 

91 # all views are shown for anyone seeing this page. this is 

92 # for sake of clarity so admin users are aware of what is 

93 # *possible* within the app etc. 

94 master_views = self.request.registry.settings.get("wuttaweb_master_views", {}) 

95 for model_views in master_views.values(): 

96 for view in model_views: 

97 data.append( 

98 { 

99 "model_title_plural": view.get_model_title_plural(), 

100 "model_name": view.get_model_name(), 

101 "route_prefix": view.get_route_prefix(), 

102 "url_prefix": view.get_url_prefix(), 

103 } 

104 ) 

105 

106 return data 

107 

108 def configure_grid(self, grid): # pylint: disable=empty-docstring 

109 """ """ 

110 g = grid 

111 super().configure_grid(g) 

112 

113 # nb. show more views by default 

114 g.pagesize = 50 

115 

116 # nb. add "pseudo" View action 

117 def viewurl(view, i): # pylint: disable=unused-argument 

118 return self.request.route_url(view["route_prefix"]) 

119 

120 g.add_action("view", icon="eye", url=viewurl) 

121 

122 # model_title_plural 

123 g.set_link("model_title_plural") 

124 g.set_searchable("model_title_plural") 

125 

126 # model_name 

127 g.set_searchable("model_name") 

128 

129 # route_prefix 

130 g.set_searchable("route_prefix") 

131 

132 # url_prefix 

133 g.set_link("url_prefix") 

134 g.set_searchable("url_prefix") 

135 

136 def get_template_context(self, context): # pylint: disable=empty-docstring 

137 """ """ 

138 if self.creating: 

139 model = self.app.model 

140 session = self.Session() 

141 

142 # app models 

143 app_models = [] 

144 for name in dir(model): 

145 obj = getattr(model, name) 

146 if ( 

147 isinstance(obj, type) 

148 and issubclass(obj, model.Base) 

149 and obj is not model.Base 

150 ): 

151 app_models.append(name) 

152 context["app_models"] = sorted(app_models) 

153 

154 # view module location 

155 view_locations = self.get_view_module_options() 

156 modpath = self.config.get("wuttaweb.master_views.default_module_dir") 

157 if modpath not in view_locations: 

158 modpath = None 

159 if not modpath and len(view_locations) == 1: 

160 modpath = view_locations[0] 

161 context["view_module_dirs"] = view_locations 

162 context["view_module_dir"] = modpath 

163 

164 # menu handler path 

165 web = self.app.get_web_handler() 

166 menu = web.get_menu_handler() 

167 context["menu_path"] = sys.modules[menu.__class__.__module__].__file__ 

168 

169 # roles for access 

170 roles = self.get_roles_for_access(session) 

171 context["roles"] = [ 

172 {"uuid": role.uuid.hex, "name": role.name} for role in roles 

173 ] 

174 context["listing_roles"] = {role.uuid.hex: False for role in roles} 

175 context["creating_roles"] = {role.uuid.hex: False for role in roles} 

176 context["viewing_roles"] = {role.uuid.hex: False for role in roles} 

177 context["editing_roles"] = {role.uuid.hex: False for role in roles} 

178 context["deleting_roles"] = {role.uuid.hex: False for role in roles} 

179 

180 return context 

181 

182 def get_roles_for_access( # pylint: disable=missing-function-docstring 

183 self, session 

184 ): 

185 model = self.app.model 

186 auth = self.app.get_auth_handler() 

187 admin = auth.get_role_administrator(session) 

188 return ( 

189 session.query(model.Role) 

190 .filter(model.Role.uuid != admin.uuid) 

191 .order_by(model.Role.name) 

192 .all() 

193 ) 

194 

195 def get_view_module_options(self): # pylint: disable=missing-function-docstring 

196 modules = set() 

197 master_views = self.request.registry.settings.get("wuttaweb_master_views", {}) 

198 for model_views in master_views.values(): 

199 for view in model_views: 

200 parent = ".".join(view.__module__.split(".")[:-1]) 

201 modules.add(parent) 

202 return sorted(modules) 

203 

204 def wizard_action(self): # pylint: disable=too-many-return-statements 

205 """ 

206 AJAX view to handle various actions for the "new master view" wizard. 

207 """ 

208 data = self.request.json_body 

209 action = data.get("action", "").strip() 

210 try: 

211 # nb. cannot use match/case statement until python 3.10, but this 

212 # project technically still supports python 3.8 

213 if action == "suggest_details": 

214 return self.suggest_details(data) 

215 if action == "write_view_file": 

216 return self.write_view_file(data) 

217 if action == "check_route": 

218 return self.check_route(data) 

219 if action == "apply_permissions": 

220 return self.apply_permissions(data) 

221 if action == "": 

222 return {"error": "Must specify the action to perform."} 

223 return {"error": f"Unknown action requested: {action}"} 

224 

225 except Exception as err: # pylint: disable=broad-exception-caught 

226 log.exception("new master view wizard action failed: %s", action) 

227 return {"error": f"Unexpected error occurred: {err}"} 

228 

229 def suggest_details( # pylint: disable=missing-function-docstring,too-many-locals 

230 self, data 

231 ): 

232 model = self.app.model 

233 model_name = data["model_name"] 

234 

235 def make_normal(match): 

236 return "_" + match.group(1).lower() 

237 

238 # normal is like: poser_widget 

239 normal = re.sub(r"([A-Z])", make_normal, model_name) 

240 normal = normal.lstrip("_") 

241 

242 def make_title(match): 

243 return " " + match.group(1).upper() 

244 

245 # title is like: Poser Widget 

246 title = re.sub(r"(?:^|_)([a-z])", make_title, normal) 

247 title = title.lstrip(" ") 

248 

249 model_title = title 

250 model_title_plural = title + "s" 

251 

252 def make_camel(match): 

253 return match.group(1).upper() 

254 

255 # camel is like: PoserWidget 

256 camel = re.sub(r"(?:^|_)([a-z])", make_camel, normal) 

257 

258 # fields are unknown without model class 

259 grid_columns = [] 

260 form_fields = [] 

261 

262 if data["model_option"] == "model_class": 

263 model_class = getattr(model, model_name) 

264 

265 # get model title from model class, if possible 

266 if hasattr(model_class, "__wutta_hint__"): 

267 model_title = model_class.__wutta_hint__.get("model_title", model_title) 

268 model_title_plural = model_class.__wutta_hint__.get( 

269 "model_title_plural", model_title + "s" 

270 ) 

271 

272 # get columns/fields from model class 

273 grid_columns = get_model_fields(self.config, model_class) 

274 form_fields = grid_columns 

275 

276 # plural is like: poser_widgets 

277 plural = re.sub(r"(?:^| )([A-Z])", make_normal, model_title_plural) 

278 plural = plural.lstrip("_") 

279 

280 route_prefix = plural 

281 url_prefix = "/" + (plural).replace("_", "-") 

282 

283 return { 

284 "class_file_name": plural + ".py", 

285 "class_name": camel + "View", 

286 "model_name": model_name, 

287 "model_title": model_title, 

288 "model_title_plural": model_title_plural, 

289 "route_prefix": route_prefix, 

290 "permission_prefix": route_prefix, 

291 "url_prefix": url_prefix, 

292 "template_prefix": url_prefix, 

293 "grid_columns": "\n".join(grid_columns), 

294 "form_fields": "\n".join(form_fields), 

295 } 

296 

297 def write_view_file(self, data): # pylint: disable=missing-function-docstring 

298 model = self.app.model 

299 

300 # sort out the destination file path 

301 modpath = data["view_location"] 

302 if modpath: 

303 mod = importlib.import_module(modpath) 

304 file_path = os.path.join( 

305 os.path.dirname(mod.__file__), data["view_file_name"] 

306 ) 

307 else: 

308 file_path = data["view_file_path"] 

309 

310 # confirm file is writable 

311 if os.path.exists(file_path): 

312 if data["overwrite"]: 

313 os.remove(file_path) 

314 else: 

315 return {"error": "File already exists"} 

316 

317 # guess its dotted module path 

318 modname, ext = os.path.splitext( # pylint: disable=unused-variable 

319 os.path.basename(file_path) 

320 ) 

321 if modpath: 

322 modpath = f"{modpath}.{modname}" 

323 else: 

324 modpath = f"poser.web.views.{modname}" 

325 

326 # inject module for class if needed 

327 if data["model_option"] == "model_class": 

328 model_class = getattr(model, data["model_name"]) 

329 data["model_module"] = model_class.__module__ 

330 

331 # TODO: make templates dir configurable? 

332 view_templates = TemplateLookup( 

333 directories=[self.app.resource_path("wuttaweb:code-templates")] 

334 ) 

335 

336 # render template to file 

337 template = view_templates.get_template("/new-master-view.mako") 

338 content = template.render(**data) 

339 with open(file_path, "wt", encoding="utf_8") as f: 

340 f.write(content) 

341 

342 return { 

343 "view_file_path": file_path, 

344 "view_module_path": modpath, 

345 "view_config_path": os.path.join(os.path.dirname(file_path), "__init__.py"), 

346 } 

347 

348 def check_route(self, data): # pylint: disable=missing-function-docstring 

349 try: 

350 url = self.request.route_url(data["route"]) 

351 path = self.request.route_path(data["route"]) 

352 except Exception as err: # pylint: disable=broad-exception-caught 

353 return {"problem": self.app.render_error(err)} 

354 

355 return {"url": url, "path": path} 

356 

357 def apply_permissions( # pylint: disable=missing-function-docstring,too-many-branches 

358 self, data 

359 ): 

360 session = self.Session() 

361 auth = self.app.get_auth_handler() 

362 roles = self.get_roles_for_access(session) 

363 permission_prefix = data["permission_prefix"] 

364 

365 if "listing_roles" in data: 

366 listing = data["listing_roles"] 

367 for role in roles: 

368 if listing.get(role.uuid.hex): 

369 auth.grant_permission(role, f"{permission_prefix}.list") 

370 else: 

371 auth.revoke_permission(role, f"{permission_prefix}.list") 

372 

373 if "creating_roles" in data: 

374 creating = data["creating_roles"] 

375 for role in roles: 

376 if creating.get(role.uuid.hex): 

377 auth.grant_permission(role, f"{permission_prefix}.create") 

378 else: 

379 auth.revoke_permission(role, f"{permission_prefix}.create") 

380 

381 if "viewing_roles" in data: 

382 viewing = data["viewing_roles"] 

383 for role in roles: 

384 if viewing.get(role.uuid.hex): 

385 auth.grant_permission(role, f"{permission_prefix}.view") 

386 else: 

387 auth.revoke_permission(role, f"{permission_prefix}.view") 

388 

389 if "editing_roles" in data: 

390 editing = data["editing_roles"] 

391 for role in roles: 

392 if editing.get(role.uuid.hex): 

393 auth.grant_permission(role, f"{permission_prefix}.edit") 

394 else: 

395 auth.revoke_permission(role, f"{permission_prefix}.edit") 

396 

397 if "deleting_roles" in data: 

398 deleting = data["deleting_roles"] 

399 for role in roles: 

400 if deleting.get(role.uuid.hex): 

401 auth.grant_permission(role, f"{permission_prefix}.delete") 

402 else: 

403 auth.revoke_permission(role, f"{permission_prefix}.delete") 

404 

405 return {} 

406 

407 def configure_get_simple_settings(self): # pylint: disable=empty-docstring 

408 """ """ 

409 return [ 

410 {"name": "wuttaweb.master_views.default_module_dir"}, 

411 ] 

412 

413 def configure_get_context( # pylint: disable=empty-docstring,arguments-differ 

414 self, **kwargs 

415 ): 

416 """ """ 

417 context = super().configure_get_context(**kwargs) 

418 

419 context["view_module_locations"] = self.get_view_module_options() 

420 

421 return context 

422 

423 @classmethod 

424 def defaults(cls, config): # pylint: disable=empty-docstring 

425 """ """ 

426 cls._masterview_defaults(config) 

427 cls._defaults(config) 

428 

429 # pylint: disable=duplicate-code 

430 @classmethod 

431 def _masterview_defaults(cls, config): 

432 route_prefix = cls.get_route_prefix() 

433 permission_prefix = cls.get_permission_prefix() 

434 model_title_plural = cls.get_model_title_plural() 

435 url_prefix = cls.get_url_prefix() 

436 

437 # fix permission group 

438 config.add_wutta_permission_group( 

439 permission_prefix, model_title_plural, overwrite=False 

440 ) 

441 

442 # wizard actions 

443 config.add_route( 

444 f"{route_prefix}.wizard_action", 

445 f"{url_prefix}/new/wizard-action", 

446 request_method="POST", 

447 ) 

448 config.add_view( 

449 cls, 

450 attr="wizard_action", 

451 route_name=f"{route_prefix}.wizard_action", 

452 renderer="json", 

453 permission=f"{permission_prefix}.create", 

454 ) 

455 

456 # pylint: enable=duplicate-code 

457 

458 

459def defaults(config, **kwargs): # pylint: disable=missing-function-docstring 

460 base = globals() 

461 

462 MasterViewView = kwargs.get( # pylint: disable=invalid-name,redefined-outer-name 

463 "MasterViewView", base["MasterViewView"] 

464 ) 

465 MasterViewView.defaults(config) 

466 

467 

468def includeme(config): # pylint: disable=missing-function-docstring 

469 defaults(config)