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

249 statements  

« prev     ^ index     » next       coverage.py v7.17.0, created at 2026-10-24 16:36 -0500

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 

38log = logging.getLogger(__name__) 

39 

40 

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

42 """ 

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

44 app registry. 

45 

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

47 class include: 

48 

49 * ``/views/master/`` 

50 """ 

51 

52 model_name = "master_view" 

53 model_title = "Master View" 

54 model_title_plural = "Master Views" 

55 url_prefix = "/views/master" 

56 

57 filterable = False 

58 sortable = True 

59 sort_on_backend = False 

60 paginated = True 

61 paginate_on_backend = False 

62 

63 creatable = True 

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

65 editable = False 

66 deletable = False 

67 configurable = True 

68 

69 labels = { 

70 "model_title_plural": "Title", 

71 "url_prefix": "URL Prefix", 

72 } 

73 

74 grid_columns = [ 

75 "model_title_plural", 

76 "model_name", 

77 "route_prefix", 

78 "url_prefix", 

79 ] 

80 

81 sort_defaults = "model_title_plural" 

82 

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

84 self, columns=None, session=None 

85 ): 

86 """ """ 

87 data = [] 

88 

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

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

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

92 # *possible* within the app etc. 

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

94 for model_views in master_views.values(): 

95 for view in model_views: 

96 data.append( 

97 { 

98 "model_title_plural": view.get_model_title_plural(), 

99 "model_name": view.get_model_name(), 

100 "route_prefix": view.get_route_prefix(), 

101 "url_prefix": view.get_url_prefix(), 

102 } 

103 ) 

104 

105 return data 

106 

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

108 """ """ 

109 g = grid 

110 super().configure_grid(g) 

111 

112 # nb. show more views by default 

113 g.pagesize = 50 

114 

115 # nb. add "pseudo" View action 

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

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

118 

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

120 

121 # model_title_plural 

122 g.set_link("model_title_plural") 

123 g.set_searchable("model_title_plural") 

124 

125 # model_name 

126 g.set_searchable("model_name") 

127 

128 # route_prefix 

129 g.set_searchable("route_prefix") 

130 

131 # url_prefix 

132 g.set_link("url_prefix") 

133 g.set_searchable("url_prefix") 

134 

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

136 """ """ 

137 context = super().get_template_context(context) 

138 

139 if self.creating: 

140 model = self.app.model 

141 session = self.Session() 

142 

143 # app models 

144 app_models = [] 

145 for name in dir(model): 

146 obj = getattr(model, name) 

147 if ( 

148 isinstance(obj, type) 

149 and issubclass(obj, model.Base) 

150 and obj is not model.Base 

151 ): 

152 app_models.append(name) 

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

154 

155 # view module location 

156 view_locations = self.get_view_module_options() 

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

158 if modpath not in view_locations: 

159 modpath = None 

160 if not modpath: 

161 pkgname = self.get_pkgname() 

162 pkgpath = f"{pkgname}.web.views" 

163 if pkgpath in view_locations: 

164 modpath = pkgpath 

165 context["view_module_dirs"] = view_locations 

166 context["view_module_dir"] = modpath 

167 

168 # menu handler path 

169 web = self.app.get_web_handler() 

170 menu = web.get_menu_handler() 

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

172 

173 # roles for access 

174 roles = self.get_roles_for_access(session) 

175 context["roles"] = [ 

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

177 ] 

178 roles = {role.uuid.hex: False for role in roles} 

179 context.update( 

180 { 

181 "listing_roles": roles, 

182 "creating_roles": roles, 

183 "viewing_roles": roles, 

184 "editing_roles": roles, 

185 "deleting_roles": roles, 

186 "history_roles": roles, 

187 } 

188 ) 

189 

190 return context 

191 

192 def get_pkgname(self): # pylint: disable=missing-function-docstring 

193 model = self.app.model 

194 return model.__name__.split(".")[0] 

195 

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

197 self, session 

198 ): 

199 model = self.app.model 

200 auth = self.app.get_auth_handler() 

201 admin = auth.get_role_administrator(session) 

202 return ( 

203 session.query(model.Role) 

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

205 .order_by(model.Role.name) 

206 .all() 

207 ) 

208 

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

210 modules = set() 

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

212 for model_views in master_views.values(): 

213 for view in model_views: 

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

215 modules.add(parent) 

216 return sorted(modules) 

217 

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

219 """ 

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

221 """ 

222 data = self.request.json_body 

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

224 try: 

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

226 # project technically still supports python 3.8 

227 if action == "suggest_details": 

228 return self.suggest_details(data) 

229 if action == "write_view_file": 

230 return self.write_view_file(data) 

231 if action == "check_route": 

232 return self.check_route(data) 

233 if action == "apply_permissions": 

234 return self.apply_permissions(data) 

235 if action == "": 

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

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

238 

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

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

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

242 

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

244 self, data 

245 ): 

246 model = self.app.model 

247 model_name = data["model_name"] 

248 

249 def make_normal(match): 

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

251 

252 # normal is like: poser_widget 

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

254 normal = normal.lstrip("_") 

255 

256 def make_title(match): 

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

258 

259 # title is like: Poser Widget 

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

261 title = title.lstrip(" ") 

262 

263 model_title = title 

264 model_title_plural = title + "s" 

265 

266 def make_camel(match): 

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

268 

269 # camel is like: PoserWidget 

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

271 

272 # fields are unknown without model class 

273 grid_columns = [] 

274 form_fields = [] 

275 

276 # versioning feature requires "configured" model class 

277 versioning = False 

278 

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

280 model_class = getattr(model, model_name) 

281 

282 # get model title from model class, if possible 

283 if hasattr(model_class, "__wutta_hint__"): 

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

285 model_title_plural = model_class.__wutta_hint__.get( 

286 "model_title_plural", model_title + "s" 

287 ) 

288 

289 # get columns/fields from model class 

290 grid_columns = get_model_fields(self.config, model_class) 

291 if "uuid" in grid_columns: 

292 grid_columns.remove("uuid") 

293 form_fields = grid_columns 

294 

295 # check versioning support 

296 try: 

297 import sqlalchemy_continuum as continuum # pylint: disable=import-outside-toplevel 

298 except ImportError: # pragma: no cover 

299 pass 

300 else: 

301 try: 

302 continuum.version_class(model_class) 

303 except continuum.exc.ClassNotVersioned: 

304 pass 

305 else: 

306 versioning = True 

307 

308 # plural is like: poser_widgets 

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

310 plural = plural.lstrip("_") 

311 

312 route_prefix = plural 

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

314 

315 return { 

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

317 "class_name": camel + "View", 

318 "model_name": model_name, 

319 "model_title": model_title, 

320 "model_title_plural": model_title_plural, 

321 "route_prefix": route_prefix, 

322 "permission_prefix": route_prefix, 

323 "url_prefix": url_prefix, 

324 "template_prefix": url_prefix, 

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

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

327 "supports_versioning": versioning, 

328 } 

329 

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

331 model = self.app.model 

332 

333 # sort out the destination file path 

334 modpath = data["view_location"] 

335 if modpath: 

336 mod = importlib.import_module(modpath) 

337 file_path = os.path.join( 

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

339 ) 

340 else: 

341 file_path = data["view_file_path"] 

342 

343 # confirm file is writable 

344 if os.path.exists(file_path): 

345 if data["overwrite"]: 

346 os.remove(file_path) 

347 else: 

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

349 

350 # guess its dotted module path 

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

352 os.path.basename(file_path) 

353 ) 

354 if modpath: 

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

356 else: 

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

358 

359 # inject module for class if needed 

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

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

362 data["model_module"] = model_class.__module__ 

363 

364 # TODO: make templates dir configurable? 

365 view_templates = TemplateLookup( 

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

367 ) 

368 

369 # render template to file 

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

371 content = template.render(**data) 

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

373 f.write(content) 

374 

375 return { 

376 "view_file_path": file_path, 

377 "view_module_path": modpath, 

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

379 } 

380 

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

382 try: 

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

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

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

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

387 

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

389 

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

391 self, data 

392 ): 

393 session = self.Session() 

394 auth = self.app.get_auth_handler() 

395 roles = self.get_roles_for_access(session) 

396 permission_prefix = data["permission_prefix"] 

397 

398 if "listing_roles" in data: 

399 listing = data["listing_roles"] 

400 for role in roles: 

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

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

403 else: 

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

405 

406 if "creating_roles" in data: 

407 creating = data["creating_roles"] 

408 for role in roles: 

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

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

411 else: 

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

413 

414 if "viewing_roles" in data: 

415 viewing = data["viewing_roles"] 

416 for role in roles: 

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

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

419 else: 

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

421 

422 if "history_roles" in data: 

423 history = data["history_roles"] 

424 for role in roles: 

425 if history.get(role.uuid.hex): 

426 auth.grant_permission(role, f"{permission_prefix}.versions") 

427 else: 

428 auth.revoke_permission(role, f"{permission_prefix}.versions") 

429 

430 if "editing_roles" in data: 

431 editing = data["editing_roles"] 

432 for role in roles: 

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

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

435 else: 

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

437 

438 if "deleting_roles" in data: 

439 deleting = data["deleting_roles"] 

440 for role in roles: 

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

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

443 else: 

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

445 

446 return {} 

447 

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

449 """ """ 

450 return [ 

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

452 ] 

453 

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

455 self, **kwargs 

456 ): 

457 """ """ 

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

459 

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

461 

462 return context 

463 

464 @classmethod 

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

466 """ """ 

467 cls._masterview_defaults(config) 

468 cls._defaults(config) 

469 

470 # pylint: disable=duplicate-code 

471 @classmethod 

472 def _masterview_defaults(cls, config): 

473 route_prefix = cls.get_route_prefix() 

474 permission_prefix = cls.get_permission_prefix() 

475 model_title_plural = cls.get_model_title_plural() 

476 url_prefix = cls.get_url_prefix() 

477 

478 # fix permission group 

479 config.add_wutta_permission_group( 

480 permission_prefix, model_title_plural, overwrite=False 

481 ) 

482 

483 # wizard actions 

484 config.add_route( 

485 f"{route_prefix}.wizard_action", 

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

487 request_method="POST", 

488 ) 

489 config.add_view( 

490 cls, 

491 attr="wizard_action", 

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

493 renderer="json", 

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

495 ) 

496 

497 # pylint: enable=duplicate-code 

498 

499 

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

501 base = globals() 

502 

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

504 "MasterViewView", base["MasterViewView"] 

505 ) 

506 MasterViewView.defaults(config) 

507 

508 

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

510 defaults(config)