views.py 31 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915
  1. import json
  2. import logging
  3. import os.path as op
  4. from typing import Set
  5. from flask import (
  6. abort,
  7. flash,
  8. jsonify,
  9. make_response,
  10. redirect,
  11. request,
  12. send_file,
  13. session,
  14. url_for,
  15. )
  16. from flask_appbuilder.exceptions import FABException
  17. from ._compat import as_unicode, string_types
  18. from .baseviews import BaseCRUDView, BaseFormView, BaseView, expose, expose_api
  19. from .const import FLAMSG_ERR_SEC_ACCESS_DENIED, PERMISSION_PREFIX
  20. from .filemanager import uuid_originalname
  21. from .security.decorators import has_access, has_access_api, permission_name
  22. from .urltools import get_filter_args, get_order_args, get_page_args, get_page_size_args
  23. from .widgets import GroupFormListWidget, ListMasterWidget
  24. log = logging.getLogger(__name__)
  25. class IndexView(BaseView):
  26. """
  27. A simple view that implements the index for the site
  28. """
  29. route_base = ""
  30. default_view = "index"
  31. index_template = "appbuilder/index.html"
  32. @expose("/")
  33. def index(self):
  34. self.update_redirect()
  35. return self.render_template(self.index_template, appbuilder=self.appbuilder)
  36. class UtilView(BaseView):
  37. """
  38. A simple view that implements special util routes.
  39. At the moment it only supports the back special endpoint.
  40. """
  41. route_base = ""
  42. default_view = "back"
  43. @expose("/back")
  44. def back(self):
  45. return redirect(self.get_redirect())
  46. class SimpleFormView(BaseFormView):
  47. """
  48. View for presenting your own forms
  49. Inherit from this view to provide some base processing
  50. for your customized form views.
  51. Notice that this class inherits from BaseView so all properties
  52. from the parent class can be overridden also.
  53. Implement form_get and form_post to implement your
  54. form pre-processing and post-processing
  55. """
  56. @expose("/form", methods=["GET"])
  57. @has_access
  58. def this_form_get(self):
  59. self._init_vars()
  60. form = self.form.refresh()
  61. self.form_get(form)
  62. widgets = self._get_edit_widget(form=form)
  63. self.update_redirect()
  64. return self.render_template(
  65. self.form_template,
  66. title=self.form_title,
  67. widgets=widgets,
  68. appbuilder=self.appbuilder,
  69. )
  70. @expose("/form", methods=["POST"])
  71. @has_access
  72. def this_form_post(self):
  73. self._init_vars()
  74. form = self.form.refresh()
  75. if form.validate_on_submit():
  76. response = self.form_post(form)
  77. if not response:
  78. return redirect(self.get_redirect())
  79. return response
  80. else:
  81. widgets = self._get_edit_widget(form=form)
  82. return self.render_template(
  83. self.form_template,
  84. title=self.form_title,
  85. widgets=widgets,
  86. appbuilder=self.appbuilder,
  87. )
  88. class PublicFormView(BaseFormView):
  89. """
  90. View for presenting your own forms
  91. Inherit from this view to provide some base
  92. processing for your customized form views.
  93. Notice that this class inherits from BaseView
  94. so all properties from the parent class can be overridden also.
  95. Implement form_get and form_post to implement
  96. your form pre-processing and post-processing
  97. """
  98. @expose("/form", methods=["GET"])
  99. def this_form_get(self):
  100. self._init_vars()
  101. form = self.form.refresh()
  102. self.form_get(form)
  103. widgets = self._get_edit_widget(form=form)
  104. self.update_redirect()
  105. return self.render_template(
  106. self.form_template,
  107. title=self.form_title,
  108. widgets=widgets,
  109. appbuilder=self.appbuilder,
  110. )
  111. @expose("/form", methods=["POST"])
  112. def this_form_post(self):
  113. self._init_vars()
  114. form = self.form.refresh()
  115. if form.validate_on_submit():
  116. response = self.form_post(form)
  117. if not response:
  118. return redirect(self.get_redirect())
  119. return response
  120. else:
  121. widgets = self._get_edit_widget(form=form)
  122. return self.render_template(
  123. self.form_template,
  124. title=self.form_title,
  125. widgets=widgets,
  126. appbuilder=self.appbuilder,
  127. )
  128. class RestCRUDView(BaseCRUDView):
  129. """
  130. This class view exposes REST method for CRUD operations on you models
  131. """
  132. disable_api_route_methods: bool = False
  133. """ Flag to disable this class exposed methods, note that this class
  134. will eventually get deprecated """
  135. def __init__(self, **kwargs):
  136. if self.disable_api_route_methods:
  137. api_route_methods: Set = {
  138. "api",
  139. "api_read",
  140. "api_get",
  141. "api_create",
  142. "api_update",
  143. "api_delete",
  144. "api_column_add",
  145. "api_column_edit",
  146. "api_readvalues",
  147. }
  148. self.exclude_route_methods = self.exclude_route_methods | api_route_methods
  149. super().__init__(**kwargs)
  150. def _search_form_json(self):
  151. pass
  152. def _get_api_urls(self, api_urls=None):
  153. """
  154. Completes a dict with the CRUD urls of the API.
  155. :param api_urls: A dict with the urls {'<FUNCTION>':'<URL>',...}
  156. :return: A dict with the CRUD urls of the base API.
  157. """
  158. view_name = self.__class__.__name__
  159. api_urls = api_urls or {}
  160. api_urls["read"] = url_for(view_name + ".api_read")
  161. api_urls["delete"] = url_for(view_name + ".api_delete", pk="")
  162. api_urls["create"] = url_for(view_name + ".api_create")
  163. api_urls["update"] = url_for(view_name + ".api_update", pk="")
  164. return api_urls
  165. def _get_modelview_urls(self, modelview_urls=None):
  166. view_name = self.__class__.__name__
  167. modelview_urls = modelview_urls or {}
  168. modelview_urls["show"] = url_for(view_name + ".show", pk="")
  169. modelview_urls["add"] = url_for(view_name + ".add")
  170. modelview_urls["edit"] = url_for(view_name + ".edit", pk="")
  171. return modelview_urls
  172. @expose("/api", methods=["GET"])
  173. @has_access_api
  174. @permission_name("list")
  175. def api(self):
  176. log.warning("This API is deprecated and will be removed on 1.15.X")
  177. view_name = self.__class__.__name__
  178. api_urls = self._get_api_urls()
  179. modelview_urls = self._get_modelview_urls()
  180. #
  181. # Collects the CRUD permissions
  182. can_show = self.appbuilder.sm.has_access("can_show", view_name)
  183. can_edit = self.appbuilder.sm.has_access("can_edit", view_name)
  184. can_add = self.appbuilder.sm.has_access("can_add", view_name)
  185. can_delete = self.appbuilder.sm.has_access("can_delete", view_name)
  186. #
  187. # Prepares the form with the search fields make it JSON serializable
  188. form_fields = {}
  189. search_filters = {}
  190. dict_filters = self._filters.get_search_filters()
  191. form = self.search_form.refresh()
  192. for col in self.search_columns:
  193. form_fields[col] = form[col]()
  194. search_filters[col] = [as_unicode(flt.name) for flt in dict_filters[col]]
  195. ret_json = jsonify(
  196. can_show=can_show,
  197. can_add=can_add,
  198. can_edit=can_edit,
  199. can_delete=can_delete,
  200. label_columns=self._label_columns_json(),
  201. list_columns=self.list_columns,
  202. order_columns=self.order_columns,
  203. page_size=self.page_size,
  204. modelview_name=view_name,
  205. api_urls=api_urls,
  206. search_filters=search_filters,
  207. search_fields=form_fields,
  208. modelview_urls=modelview_urls,
  209. )
  210. response = make_response(ret_json, 200)
  211. response.headers["Content-Type"] = "application/json"
  212. return response
  213. @expose_api(name="read", url="/api/read", methods=["GET"])
  214. @has_access_api
  215. @permission_name("list")
  216. def api_read(self):
  217. """ """
  218. log.warning("This API is deprecated and will be removed on 2.3.X")
  219. # Get arguments for ordering
  220. if get_order_args().get(self.__class__.__name__):
  221. order_column, order_direction = get_order_args().get(
  222. self.__class__.__name__
  223. )
  224. else:
  225. order_column, order_direction = "", ""
  226. page = get_page_args().get(self.__class__.__name__)
  227. page_size = get_page_size_args().get(self.__class__.__name__)
  228. get_filter_args(self._filters)
  229. joined_filters = self._filters.get_joined_filters(self._base_filters)
  230. count, lst = self.datamodel.query(
  231. joined_filters,
  232. order_column,
  233. order_direction,
  234. page=page,
  235. page_size=page_size,
  236. )
  237. result = self.datamodel.get_values_json(lst, self.list_columns)
  238. pks = self.datamodel.get_keys(lst)
  239. ret_json = jsonify(
  240. label_columns=self._label_columns_json(),
  241. list_columns=self.list_columns,
  242. order_columns=self.order_columns,
  243. page=page,
  244. page_size=page_size,
  245. count=count,
  246. modelview_name=self.__class__.__name__,
  247. pks=pks,
  248. result=result,
  249. )
  250. response = make_response(ret_json, 200)
  251. response.headers["Content-Type"] = "application/json"
  252. return response
  253. def show_item_dict(self, item):
  254. """Returns a json-able dict for show"""
  255. d = {}
  256. for col in self.show_columns:
  257. v = getattr(item, col)
  258. if not isinstance(v, (int, float, string_types)):
  259. v = str(v)
  260. d[col] = v
  261. return d
  262. @expose_api(name="get", url="/api/get/<pk>", methods=["GET"])
  263. @has_access_api
  264. @permission_name("show")
  265. def api_get(self, pk):
  266. """ """
  267. log.warning("This API is deprecated and will be removed on 2.3.X")
  268. # Get arguments for ordering
  269. item = self.datamodel.get(pk, self._base_filters)
  270. if not item:
  271. abort(404)
  272. ret_json = jsonify(
  273. pk=pk,
  274. label_columns=self._label_columns_json(),
  275. include_columns=self.show_columns,
  276. modelview_name=self.__class__.__name__,
  277. result=self.show_item_dict(item),
  278. )
  279. response = make_response(ret_json, 200)
  280. response.headers["Content-Type"] = "application/json"
  281. return response
  282. @expose_api(name="create", url="/api/create", methods=["POST"])
  283. @has_access_api
  284. @permission_name("add")
  285. def api_create(self):
  286. log.warning("This API is deprecated and will be removed on 2.3.X")
  287. get_filter_args(self._filters, disallow_if_not_in_search=False)
  288. exclude_cols = self._filters.get_relation_cols()
  289. form = self.add_form.refresh()
  290. self._fill_form_exclude_cols(exclude_cols, form)
  291. if form.validate():
  292. item = self.datamodel.obj()
  293. form.populate_obj(item)
  294. self.pre_add(item)
  295. if self.datamodel.add(item):
  296. self.post_add(item)
  297. http_return_code = 200
  298. else:
  299. http_return_code = 500
  300. payload = {
  301. "message": self.datamodel.message[0],
  302. "item": self.show_item_dict(item),
  303. "severity": self.datamodel.message[1],
  304. }
  305. else:
  306. payload = {"message": "Validation error", "error_details": form.errors}
  307. http_return_code = 500
  308. return make_response(jsonify(payload), http_return_code)
  309. @expose_api(name="update", url="/api/update/<pk>", methods=["PUT"])
  310. @has_access_api
  311. @permission_name("edit")
  312. def api_update(self, pk):
  313. log.warning("This API is deprecated and will be removed on 2.3.X")
  314. get_filter_args(self._filters, disallow_if_not_in_search=False)
  315. exclude_cols = self._filters.get_relation_cols()
  316. item = self.datamodel.get(pk, self._base_filters)
  317. if not item:
  318. abort(404)
  319. # convert pk to correct type, if pk is non string type.
  320. pk = self.datamodel.get_pk_value(item)
  321. form = self.edit_form.refresh(request.form)
  322. # fill the form with the suppressed cols, generated from exclude_cols
  323. self._fill_form_exclude_cols(exclude_cols, form)
  324. # trick to pass unique validation
  325. form._id = pk
  326. http_return_code = 500
  327. if form.validate():
  328. # Deleting form fields not specified as keys in POST data
  329. # this allows for other Model columns to be left untouched when
  330. # unspecified.
  331. form_fields = set([t for t in form._fields.keys()])
  332. for field in form_fields - set(request.form.keys()):
  333. delattr(form, field)
  334. form.populate_obj(item)
  335. self.pre_update(item)
  336. if self.datamodel.edit(item):
  337. self.post_update(item)
  338. http_return_code = 200
  339. payload = {
  340. "message": self.datamodel.message[0],
  341. "severity": self.datamodel.message[1],
  342. "item": self.show_item_dict(item),
  343. }
  344. else:
  345. payload = {
  346. "message": "Validation error",
  347. "error_details": form.errors,
  348. "severity": "warning",
  349. }
  350. return make_response(jsonify(payload), http_return_code)
  351. @expose_api(name="delete", url="/api/delete/<pk>", methods=["DELETE"])
  352. @has_access_api
  353. @permission_name("delete")
  354. def api_delete(self, pk):
  355. log.warning("This API is deprecated and will be removed on 2.3.X")
  356. item = self.datamodel.get(pk, self._base_filters)
  357. if not item:
  358. abort(404)
  359. self.pre_delete(item)
  360. if self.datamodel.delete(item):
  361. self.post_delete(item)
  362. http_return_code = 200
  363. else:
  364. http_return_code = 500
  365. response = make_response(
  366. jsonify(
  367. {
  368. "message": self.datamodel.message[0],
  369. "severity": self.datamodel.message[1],
  370. }
  371. ),
  372. http_return_code,
  373. )
  374. response.headers["Content-Type"] = "application/json"
  375. return response
  376. def _get_related_column_data(self, col_name, filters):
  377. rel_datamodel = self.datamodel.get_related_interface(col_name)
  378. _filters = rel_datamodel.get_filters(rel_datamodel.get_search_columns_list())
  379. get_filter_args(_filters)
  380. if filters:
  381. filters = _filters.add_filter_list(filters)
  382. else:
  383. filters = _filters
  384. result = rel_datamodel.query(filters)[1]
  385. ret_list = list()
  386. for item in result:
  387. pk = rel_datamodel.get_pk_value(item)
  388. ret_list.append({"id": int(pk), "text": str(item)})
  389. ret_json = json.dumps(ret_list)
  390. return ret_json
  391. @expose_api(name="column_add", url="/api/column/add/<col_name>", methods=["GET"])
  392. @has_access_api
  393. @permission_name("add")
  394. def api_column_add(self, col_name):
  395. """
  396. Returns list of (pk, object) nice to use on select2.
  397. Use only for related columns.
  398. Always filters with add_form_query_rel_fields, and accepts extra filters
  399. on endpoint arguments.
  400. :param col_name: The related column name
  401. :return: JSON response
  402. """
  403. log.warning("This API is deprecated and will be removed on 2.3.X")
  404. filter_rel_fields = None
  405. if self.add_form_query_rel_fields:
  406. filter_rel_fields = self.add_form_query_rel_fields.get(col_name)
  407. ret_json = self._get_related_column_data(col_name, filter_rel_fields)
  408. response = make_response(ret_json, 200)
  409. response.headers["Content-Type"] = "application/json"
  410. return response
  411. @expose_api(name="column_edit", url="/api/column/edit/<col_name>", methods=["GET"])
  412. @has_access_api
  413. @permission_name("edit")
  414. def api_column_edit(self, col_name):
  415. """
  416. Returns list of (pk, object) nice to use on select2.
  417. Use only for related columns.
  418. Always filters with edit_form_query_rel_fields, and accepts extra filters
  419. on endpoint arguments.
  420. :param col_name: The related column name
  421. :return: JSON response
  422. """
  423. log.warning("This API is deprecated and will be removed on 2.3.X")
  424. filter_rel_fields = None
  425. if self.edit_form_query_rel_fields:
  426. filter_rel_fields = self.edit_form_query_rel_fields.get(col_name)
  427. ret_json = self._get_related_column_data(col_name, filter_rel_fields)
  428. response = make_response(ret_json, 200)
  429. response.headers["Content-Type"] = "application/json"
  430. return response
  431. @expose_api(name="readvalues", url="/api/readvalues", methods=["GET"])
  432. @has_access_api
  433. @permission_name("list")
  434. def api_readvalues(self):
  435. """ """
  436. log.warning("This API is deprecated and will be removed on 2.3.X")
  437. # Get arguments for ordering
  438. if get_order_args().get(self.__class__.__name__):
  439. order_column, order_direction = get_order_args().get(
  440. self.__class__.__name__
  441. )
  442. else:
  443. order_column, order_direction = "", ""
  444. get_filter_args(self._filters)
  445. joined_filters = self._filters.get_joined_filters(self._base_filters)
  446. count, result = self.datamodel.query(
  447. joined_filters, order_column, order_direction
  448. )
  449. ret_list = list()
  450. for item in result:
  451. pk = self.datamodel.get_pk_value(item)
  452. ret_list.append({"id": int(pk), "text": str(item)})
  453. ret_json = json.dumps(ret_list)
  454. response = make_response(ret_json, 200)
  455. response.headers["Content-Type"] = "application/json"
  456. return response
  457. class ModelView(RestCRUDView):
  458. """
  459. This is the CRUD generic view.
  460. If you want to automatically implement create, edit,
  461. delete, show, and list from your database tables,
  462. inherit your views from this class.
  463. Notice that this class inherits from BaseCRUDView and BaseModelView
  464. so all properties from the parent class can be overridden.
  465. """
  466. def __init__(self, **kwargs):
  467. super(ModelView, self).__init__(**kwargs)
  468. def post_add_redirect(self):
  469. """Override this function to control the
  470. redirect after add endpoint is called."""
  471. return redirect(self.get_redirect())
  472. def post_edit_redirect(self):
  473. """Override this function to control the
  474. redirect after edit endpoint is called."""
  475. return redirect(self.get_redirect())
  476. def post_delete_redirect(self):
  477. """Override this function to control the
  478. redirect after edit endpoint is called."""
  479. return redirect(self.get_redirect())
  480. """
  481. --------------------------------
  482. LIST
  483. --------------------------------
  484. """
  485. @expose("/list/")
  486. @has_access
  487. def list(self):
  488. self.update_redirect()
  489. try:
  490. widgets = self._list()
  491. except FABException as exc:
  492. flash(f"An error occurred: {exc}", "warning")
  493. return redirect(self.get_redirect())
  494. return self.render_template(
  495. self.list_template, title=self.list_title, widgets=widgets
  496. )
  497. """
  498. --------------------------------
  499. SHOW
  500. --------------------------------
  501. """
  502. @expose("/show/<pk>", methods=["GET"])
  503. @has_access
  504. def show(self, pk):
  505. pk = self._deserialize_pk_if_composite(pk)
  506. widgets = self._show(pk)
  507. return self.render_template(
  508. self.show_template,
  509. pk=pk,
  510. title=self.show_title,
  511. widgets=widgets,
  512. related_views=self._related_views,
  513. )
  514. """
  515. ---------------------------
  516. ADD
  517. ---------------------------
  518. """
  519. @expose("/add", methods=["GET", "POST"])
  520. @has_access
  521. def add(self):
  522. widget = self._add()
  523. if not widget:
  524. return self.post_add_redirect()
  525. else:
  526. return self.render_template(
  527. self.add_template, title=self.add_title, widgets=widget
  528. )
  529. """
  530. ---------------------------
  531. EDIT
  532. ---------------------------
  533. """
  534. @expose("/edit/<pk>", methods=["GET", "POST"])
  535. @has_access
  536. def edit(self, pk):
  537. pk = self._deserialize_pk_if_composite(pk)
  538. widgets = self._edit(pk)
  539. if not widgets:
  540. return self.post_edit_redirect()
  541. else:
  542. return self.render_template(
  543. self.edit_template,
  544. title=self.edit_title,
  545. widgets=widgets,
  546. related_views=self._related_views,
  547. )
  548. """
  549. ---------------------------
  550. DELETE
  551. ---------------------------
  552. """
  553. @expose("/delete/<pk>", methods=["GET", "POST"])
  554. @has_access
  555. def delete(self, pk):
  556. # Maintains compatibility but refuses to delete on GET methods if CSRF is enabled
  557. if not self.is_get_mutation_allowed():
  558. self.update_redirect()
  559. log.warning("CSRF is enabled and a delete using GET was invoked")
  560. flash(as_unicode(FLAMSG_ERR_SEC_ACCESS_DENIED), "danger")
  561. return self.post_delete_redirect()
  562. pk = self._deserialize_pk_if_composite(pk)
  563. self._delete(pk)
  564. return self.post_delete_redirect()
  565. @expose("/download/<string:filename>")
  566. @has_access
  567. def download(self, filename):
  568. return send_file(
  569. op.join(self.appbuilder.app.config["UPLOAD_FOLDER"], filename),
  570. download_name=uuid_originalname(filename),
  571. as_attachment=True,
  572. )
  573. def get_action_permission_name(self, name: str) -> str:
  574. """
  575. Get the permission name of an action name
  576. """
  577. _permission_name = self.method_permission_name.get(
  578. self.actions.get(name).func.__name__
  579. )
  580. if _permission_name:
  581. return PERMISSION_PREFIX + _permission_name
  582. else:
  583. return name
  584. @expose("/action/<string:name>/<pk>", methods=["GET", "POST"])
  585. def action(self, name, pk):
  586. """
  587. Action method to handle actions from a show view
  588. """
  589. # Maintains compatibility but refuses to proceed if CSRF is enabled
  590. if not self.is_get_mutation_allowed():
  591. self.update_redirect()
  592. log.warning("CSRF is enabled and a action using GET was invoked")
  593. flash(as_unicode(FLAMSG_ERR_SEC_ACCESS_DENIED), "danger")
  594. return redirect(self.get_redirect())
  595. pk = self._deserialize_pk_if_composite(pk)
  596. permission_name = self.get_action_permission_name(name)
  597. if self.appbuilder.sm.has_access(permission_name, self.class_permission_name):
  598. action = self.actions.get(name)
  599. return action.func(self.datamodel.get(pk))
  600. else:
  601. flash(as_unicode(FLAMSG_ERR_SEC_ACCESS_DENIED), "danger")
  602. return redirect(".")
  603. @expose("/action_post", methods=["POST"])
  604. def action_post(self):
  605. """
  606. Action method to handle multiple records selected from a list view
  607. """
  608. name = request.form["action"]
  609. pks = request.form.getlist("rowid")
  610. permission_name = self.get_action_permission_name(name)
  611. if self.appbuilder.sm.has_access(permission_name, self.class_permission_name):
  612. action = self.actions.get(name)
  613. items = [
  614. self.datamodel.get(self._deserialize_pk_if_composite(pk)) for pk in pks
  615. ]
  616. return action.func(items)
  617. else:
  618. flash(as_unicode(FLAMSG_ERR_SEC_ACCESS_DENIED), "danger")
  619. return redirect(".")
  620. class MasterDetailView(BaseCRUDView):
  621. """
  622. Implements behaviour for controlling two CRUD views
  623. linked by PK and FK, in a master/detail type with
  624. two lists.
  625. Master view will behave like a left menu::
  626. class DetailView(ModelView):
  627. datamodel = SQLAInterface(DetailTable, db.session)
  628. class MasterView(MasterDetailView):
  629. datamodel = SQLAInterface(MasterTable, db.session)
  630. related_views = [DetailView]
  631. """
  632. list_template = "appbuilder/general/model/left_master_detail.html"
  633. list_widget = ListMasterWidget
  634. master_div_width = 2
  635. """
  636. Set to configure bootstrap class for master grid size
  637. """
  638. @expose("/list/")
  639. @expose("/list/<pk>")
  640. @has_access
  641. def list(self, pk=None):
  642. pages = get_page_args()
  643. page_sizes = get_page_size_args()
  644. orders = get_order_args()
  645. widgets = self._list()
  646. if pk:
  647. item = self.datamodel.get(pk)
  648. widgets = self._get_related_views_widgets(
  649. item, orders=orders, pages=pages, page_sizes=page_sizes, widgets=widgets
  650. )
  651. related_views = self._related_views
  652. else:
  653. related_views = []
  654. return self.render_template(
  655. self.list_template,
  656. title=self.list_title,
  657. widgets=widgets,
  658. related_views=related_views,
  659. master_div_width=self.master_div_width,
  660. )
  661. class MultipleView(BaseView):
  662. """
  663. Use this view to render multiple views on the same page,
  664. exposed on the list endpoint.
  665. example (after defining GroupModelView and ContactModelView)::
  666. class MultipleViewsExp(MultipleView):
  667. views = [GroupModelView, ContactModelView]
  668. """
  669. list_template = "appbuilder/general/model/multiple_views.html"
  670. """ Override this to implement your own template for the list endpoint """
  671. views = None
  672. " A list of ModelView's to render on the same page "
  673. _views = None
  674. def __init__(self, **kwargs):
  675. super(MultipleView, self).__init__(**kwargs)
  676. self.views = self.views or list()
  677. self._views = self._views or list()
  678. def get_uninit_inner_views(self):
  679. return self.views
  680. def get_init_inner_views(self):
  681. return self._views
  682. @expose("/list/")
  683. @has_access
  684. def list(self):
  685. pages = get_page_args()
  686. page_sizes = get_page_size_args()
  687. orders = get_order_args()
  688. views_widgets = list()
  689. for view in self._views:
  690. if orders.get(view.__class__.__name__):
  691. order_column, order_direction = orders.get(view.__class__.__name__)
  692. else:
  693. order_column, order_direction = "", ""
  694. page = pages.get(view.__class__.__name__)
  695. page_size = page_sizes.get(view.__class__.__name__)
  696. views_widgets.append(
  697. view._get_view_widget(
  698. filters=view._base_filters,
  699. order_column=order_column,
  700. order_direction=order_direction,
  701. page=page,
  702. page_size=page_size,
  703. )
  704. )
  705. self.update_redirect()
  706. return self.render_template(
  707. self.list_template, views=self._views, views_widgets=views_widgets
  708. )
  709. class CompactCRUDMixin(BaseCRUDView):
  710. """
  711. Mix with ModelView to implement a list with add and edit on the same page.
  712. """
  713. @classmethod
  714. def set_key(cls, k, v):
  715. """Allows attaching stateless information to the class using the
  716. flask session dict
  717. """
  718. k = cls.__name__ + "__" + k
  719. session[k] = v
  720. @classmethod
  721. def get_key(cls, k, default=None):
  722. """Matching get method for ``set_key``"""
  723. k = cls.__name__ + "__" + k
  724. if k in session:
  725. return session[k]
  726. else:
  727. return default
  728. @classmethod
  729. def del_key(cls, k):
  730. """Matching get method for ``set_key``"""
  731. k = cls.__name__ + "__" + k
  732. session.pop(k)
  733. def _get_list_widget(self, **kwargs):
  734. """get joined base filter and current active filter for query"""
  735. widgets = super(CompactCRUDMixin, self)._get_list_widget(**kwargs)
  736. session_form_widget = self.get_key("session_form_widget", None)
  737. form_widget = None
  738. if session_form_widget == "add":
  739. form_widget = self._add().get("add")
  740. elif session_form_widget == "edit":
  741. pk = self.get_key("session_form_edit_pk")
  742. if pk and self.datamodel.get(int(pk)):
  743. form_widget = self._edit(int(pk)).get("edit")
  744. return {
  745. "list": GroupFormListWidget(
  746. list_widget=widgets.get("list"),
  747. form_widget=form_widget,
  748. form_action=self.get_key("session_form_action", ""),
  749. form_title=self.get_key("session_form_title", ""),
  750. )
  751. }
  752. @expose("/list/", methods=["GET", "POST"])
  753. @has_access
  754. def list(self):
  755. list_widgets = self._list()
  756. return self.render_template(
  757. self.list_template, title=self.list_title, widgets=list_widgets
  758. )
  759. @expose("/add/", methods=["GET", "POST"])
  760. @has_access
  761. def add(self):
  762. widgets = self._add()
  763. if not widgets:
  764. self.set_key("session_form_action", "")
  765. self.set_key("session_form_widget", None)
  766. return redirect(request.referrer)
  767. else:
  768. self.set_key("session_form_widget", "add")
  769. self.set_key("session_form_action", request.script_root + request.full_path)
  770. self.set_key("session_form_title", self.add_title)
  771. return redirect(self.get_redirect())
  772. @expose("/edit/<pk>", methods=["GET", "POST"])
  773. @has_access
  774. def edit(self, pk):
  775. pk = self._deserialize_pk_if_composite(pk)
  776. widgets = self._edit(pk)
  777. self.update_redirect()
  778. if not widgets:
  779. self.set_key("session_form_action", "")
  780. self.set_key("session_form_widget", None)
  781. return redirect(self.get_redirect())
  782. else:
  783. self.set_key("session_form_widget", "edit")
  784. self.set_key("session_form_action", request.script_root + request.full_path)
  785. self.set_key("session_form_title", self.add_title)
  786. self.set_key("session_form_edit_pk", pk)
  787. return redirect(self.get_redirect())
  788. @expose("/delete/<pk>", methods=["GET", "POST"])
  789. @has_access
  790. def delete(self, pk):
  791. # Maintains compatibility but refuses to delete on GET methods if CSRF is enabled
  792. if not self.is_get_mutation_allowed():
  793. self.update_redirect()
  794. log.warning("CSRF is enabled and a delete using GET was invoked")
  795. flash(as_unicode(FLAMSG_ERR_SEC_ACCESS_DENIED), "danger")
  796. return redirect(self.get_redirect())
  797. pk = self._deserialize_pk_if_composite(pk)
  798. self._delete(pk)
  799. edit_pk = self.get_key("session_form_edit_pk")
  800. if pk == edit_pk:
  801. self.del_key("session_form_edit_pk")
  802. return redirect(self.get_redirect())
  803. """
  804. This is for retro compatibility
  805. """
  806. GeneralView = ModelView