view.py 42 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174
  1. import logging
  2. import warnings
  3. import inspect
  4. from sqlalchemy.orm.attributes import InstrumentedAttribute
  5. from sqlalchemy.orm import joinedload, aliased
  6. from sqlalchemy.sql.expression import desc
  7. from sqlalchemy import Boolean, Table, func, or_
  8. from sqlalchemy.exc import IntegrityError
  9. from sqlalchemy.sql.expression import cast
  10. from sqlalchemy import Unicode
  11. from flask import current_app, flash
  12. from flask_admin._compat import string_types, text_type
  13. from flask_admin.babel import gettext, ngettext, lazy_gettext
  14. from flask_admin.contrib.sqla.tools import is_relationship
  15. from flask_admin.model import BaseModelView
  16. from flask_admin.model.form import create_editable_list_form
  17. from flask_admin.actions import action
  18. from flask_admin._backwards import ObsoleteAttr
  19. from flask_admin.contrib.sqla import form, filters as sqla_filters, tools
  20. from .typefmt import DEFAULT_FORMATTERS
  21. from .ajax import create_ajax_loader
  22. # Set up logger
  23. log = logging.getLogger("flask-admin.sqla")
  24. class ModelView(BaseModelView):
  25. """
  26. SQLAlchemy model view
  27. Usage sample::
  28. admin = Admin()
  29. admin.add_view(ModelView(User, db.session))
  30. """
  31. column_auto_select_related = ObsoleteAttr('column_auto_select_related',
  32. 'auto_select_related',
  33. True)
  34. """
  35. Enable automatic detection of displayed foreign keys in this view
  36. and perform automatic joined loading for related models to improve
  37. query performance.
  38. Please note that detection is not recursive: if `__unicode__` method
  39. of related model uses another model to generate string representation, it
  40. will still make separate database call.
  41. """
  42. column_select_related_list = ObsoleteAttr('column_select_related',
  43. 'list_select_related',
  44. None)
  45. """
  46. List of parameters for SQLAlchemy `subqueryload`. Overrides `column_auto_select_related`
  47. property.
  48. For example::
  49. class PostAdmin(ModelView):
  50. column_select_related_list = ('user', 'city')
  51. You can also use properties::
  52. class PostAdmin(ModelView):
  53. column_select_related_list = (Post.user, Post.city)
  54. Please refer to the `subqueryload` on list of possible values.
  55. """
  56. column_display_all_relations = ObsoleteAttr('column_display_all_relations',
  57. 'list_display_all_relations',
  58. False)
  59. """
  60. Controls if list view should display all relations, not only many-to-one.
  61. """
  62. column_searchable_list = ObsoleteAttr('column_searchable_list',
  63. 'searchable_columns',
  64. None)
  65. """
  66. Collection of the searchable columns.
  67. Example::
  68. class MyModelView(ModelView):
  69. column_searchable_list = ('name', 'email')
  70. You can also pass columns::
  71. class MyModelView(ModelView):
  72. column_searchable_list = (User.name, User.email)
  73. The following search rules apply:
  74. - If you enter ``ZZZ`` in the UI search field, it will generate ``ILIKE '%ZZZ%'``
  75. statement against searchable columns.
  76. - If you enter multiple words, each word will be searched separately, but
  77. only rows that contain all words will be displayed. For example, searching
  78. for ``abc def`` will find all rows that contain ``abc`` and ``def`` in one or
  79. more columns.
  80. - If you prefix your search term with ``^``, it will find all rows
  81. that start with ``^``. So, if you entered ``^ZZZ`` then ``ILIKE 'ZZZ%'`` will be used.
  82. - If you prefix your search term with ``=``, it will perform an exact match.
  83. For example, if you entered ``=ZZZ``, the statement ``ILIKE 'ZZZ'`` will be used.
  84. """
  85. column_filters = None
  86. """
  87. Collection of the column filters.
  88. Can contain either field names or instances of
  89. :class:`flask_admin.contrib.sqla.filters.BaseSQLAFilter` classes.
  90. Filters will be grouped by name when displayed in the drop-down.
  91. For example::
  92. class MyModelView(BaseModelView):
  93. column_filters = ('user', 'email')
  94. or::
  95. from flask_admin.contrib.sqla.filters import BooleanEqualFilter
  96. class MyModelView(BaseModelView):
  97. column_filters = (BooleanEqualFilter(column=User.name, name='Name'),)
  98. or::
  99. from flask_admin.contrib.sqla.filters import BaseSQLAFilter
  100. class FilterLastNameBrown(BaseSQLAFilter):
  101. def apply(self, query, value, alias=None):
  102. if value == '1':
  103. return query.filter(self.column == "Brown")
  104. else:
  105. return query.filter(self.column != "Brown")
  106. def operation(self):
  107. return 'is Brown'
  108. class MyModelView(BaseModelView):
  109. column_filters = [
  110. FilterLastNameBrown(
  111. User.last_name, 'Last Name', options=(('1', 'Yes'), ('0', 'No'))
  112. )
  113. ]
  114. """
  115. model_form_converter = form.AdminModelConverter
  116. """
  117. Model form conversion class. Use this to implement custom field conversion logic.
  118. For example::
  119. class MyModelConverter(AdminModelConverter):
  120. pass
  121. class MyAdminView(ModelView):
  122. model_form_converter = MyModelConverter
  123. """
  124. inline_model_form_converter = form.InlineModelConverter
  125. """
  126. Inline model conversion class. If you need some kind of post-processing for inline
  127. forms, you can customize behavior by doing something like this::
  128. class MyInlineModelConverter(InlineModelConverter):
  129. def post_process(self, form_class, info):
  130. form_class.value = wtf.StringField('value')
  131. return form_class
  132. class MyAdminView(ModelView):
  133. inline_model_form_converter = MyInlineModelConverter
  134. """
  135. filter_converter = sqla_filters.FilterConverter()
  136. """
  137. Field to filter converter.
  138. Override this attribute to use non-default converter.
  139. """
  140. fast_mass_delete = False
  141. """
  142. If set to `False` and user deletes more than one model using built in action,
  143. all models will be read from the database and then deleted one by one
  144. giving SQLAlchemy a chance to manually cleanup any dependencies (many-to-many
  145. relationships, etc).
  146. If set to `True`, will run a ``DELETE`` statement which is somewhat faster,
  147. but may leave corrupted data if you forget to configure ``DELETE
  148. CASCADE`` for your model.
  149. """
  150. inline_models = None
  151. """
  152. Inline related-model editing for models with parent-child relations.
  153. Accepts enumerable with one of the following possible values:
  154. 1. Child model class::
  155. class MyModelView(ModelView):
  156. inline_models = (Post,)
  157. 2. Child model class and additional options::
  158. class MyModelView(ModelView):
  159. inline_models = [(Post, dict(form_columns=['title']))]
  160. 3. Django-like ``InlineFormAdmin`` class instance::
  161. from flask_admin.model.form import InlineFormAdmin
  162. class MyInlineModelForm(InlineFormAdmin):
  163. form_columns = ('title', 'date')
  164. class MyModelView(ModelView):
  165. inline_models = (MyInlineModelForm(MyInlineModel),)
  166. You can customize the generated field name by:
  167. 1. Using the `form_name` property as a key to the options dictionary::
  168. class MyModelView(ModelView):
  169. inline_models = ((Post, dict(form_label='Hello')))
  170. 2. Using forward relation name and `column_labels` property::
  171. class Model1(Base):
  172. pass
  173. class Model2(Base):
  174. # ...
  175. model1 = relation(Model1, backref='models')
  176. class MyModel1View(Base):
  177. inline_models = (Model2,)
  178. column_labels = {'models': 'Hello'}
  179. """
  180. column_type_formatters = DEFAULT_FORMATTERS
  181. form_choices = None
  182. """
  183. Map choices to form fields
  184. Example::
  185. class MyModelView(BaseModelView):
  186. form_choices = {'my_form_field': [
  187. ('db_value', 'display_value'),
  188. ]}
  189. """
  190. form_optional_types = (Boolean,)
  191. """
  192. List of field types that should be optional if column is not nullable.
  193. Example::
  194. class MyModelView(BaseModelView):
  195. form_optional_types = (Boolean, Unicode)
  196. """
  197. ignore_hidden = True
  198. """
  199. Ignore field that starts with "_"
  200. Example::
  201. class MyModelView(BaseModelView):
  202. ignore_hidden = False
  203. """
  204. def __init__(self, model, session,
  205. name=None, category=None, endpoint=None, url=None, static_folder=None,
  206. menu_class_name=None, menu_icon_type=None, menu_icon_value=None):
  207. """
  208. Constructor.
  209. :param model:
  210. Model class
  211. :param session:
  212. SQLAlchemy session
  213. :param name:
  214. View name. If not set, defaults to the model name
  215. :param category:
  216. Category name
  217. :param endpoint:
  218. Endpoint name. If not set, defaults to the model name
  219. :param url:
  220. Base URL. If not set, defaults to '/admin/' + endpoint
  221. :param menu_class_name:
  222. Optional class name for the menu item.
  223. :param menu_icon_type:
  224. Optional icon. Possible icon types:
  225. - `flask_admin.consts.ICON_TYPE_GLYPH` - Bootstrap glyph icon
  226. - `flask_admin.consts.ICON_TYPE_FONT_AWESOME` - Font Awesome icon
  227. - `flask_admin.consts.ICON_TYPE_IMAGE` - Image relative to Flask static directory
  228. - `flask_admin.consts.ICON_TYPE_IMAGE_URL` - Image with full URL
  229. :param menu_icon_value:
  230. Icon glyph name or URL, depending on `menu_icon_type` setting
  231. """
  232. self.session = session
  233. self._search_fields = None
  234. self._filter_joins = dict()
  235. self._sortable_joins = dict()
  236. if self.form_choices is None:
  237. self.form_choices = {}
  238. super(ModelView, self).__init__(model, name, category, endpoint, url, static_folder,
  239. menu_class_name=menu_class_name,
  240. menu_icon_type=menu_icon_type,
  241. menu_icon_value=menu_icon_value)
  242. # Primary key
  243. self._primary_key = self.scaffold_pk()
  244. if self._primary_key is None:
  245. raise Exception('Model %s does not have primary key.' % self.model.__name__)
  246. # Configuration
  247. if not self.column_select_related_list:
  248. self._auto_joins = self.scaffold_auto_joins()
  249. else:
  250. self._auto_joins = self.column_select_related_list
  251. # Internal API
  252. def _get_model_iterator(self, model=None):
  253. """
  254. Return property iterator for the model
  255. """
  256. if model is None:
  257. model = self.model
  258. return model._sa_class_manager.mapper.iterate_properties
  259. def _apply_path_joins(self, query, joins, path, inner_join=True):
  260. """
  261. Apply join path to the query.
  262. :param query:
  263. Query to add joins to
  264. :param joins:
  265. List of current joins. Used to avoid joining on same relationship more than once
  266. :param path:
  267. Path to be joined
  268. :param fn:
  269. Join function
  270. """
  271. last = None
  272. if path:
  273. for item in path:
  274. key = (inner_join, item)
  275. alias = joins.get(key)
  276. if key not in joins:
  277. if not isinstance(item, Table):
  278. alias = aliased(item.property.mapper.class_)
  279. fn = query.join if inner_join else query.outerjoin
  280. if last is None:
  281. query = fn(item) if alias is None else fn(alias, item)
  282. else:
  283. prop = getattr(last, item.key)
  284. query = fn(prop) if alias is None else fn(alias, prop)
  285. joins[key] = alias
  286. last = alias
  287. return query, joins, last
  288. # Scaffolding
  289. def scaffold_pk(self):
  290. """
  291. Return the primary key name(s) from a model
  292. If model has single primary key, will return a string and tuple otherwise
  293. """
  294. return tools.get_primary_key(self.model)
  295. def get_pk_value(self, model):
  296. """
  297. Return the primary key value from a model object.
  298. If there are multiple primary keys, they're encoded into string representation.
  299. """
  300. if isinstance(self._primary_key, tuple):
  301. return tools.iterencode(getattr(model, attr) for attr in self._primary_key)
  302. else:
  303. return tools.escape(getattr(model, self._primary_key))
  304. def scaffold_list_columns(self):
  305. """
  306. Return a list of columns from the model.
  307. """
  308. columns = []
  309. for p in self._get_model_iterator():
  310. if hasattr(p, 'direction'):
  311. if self.column_display_all_relations or p.direction.name == 'MANYTOONE':
  312. columns.append(p.key)
  313. elif hasattr(p, 'columns'):
  314. if len(p.columns) > 1:
  315. filtered = tools.filter_foreign_columns(self.model.__table__, p.columns)
  316. if len(filtered) > 1:
  317. warnings.warn('Can not convert multiple-column properties (%s.%s)' % (self.model, p.key))
  318. continue
  319. column = filtered[0]
  320. else:
  321. column = p.columns[0]
  322. if column.foreign_keys:
  323. continue
  324. if not self.column_display_pk and column.primary_key:
  325. continue
  326. columns.append(p.key)
  327. return columns
  328. def scaffold_sortable_columns(self):
  329. """
  330. Return a dictionary of sortable columns.
  331. Key is column name, value is sort column/field.
  332. """
  333. columns = dict()
  334. for p in self._get_model_iterator():
  335. if hasattr(p, 'columns'):
  336. # Sanity check
  337. if len(p.columns) > 1:
  338. # Multi-column properties are not supported
  339. continue
  340. column = p.columns[0]
  341. # Can't sort on primary or foreign keys by default
  342. if column.foreign_keys:
  343. continue
  344. if not self.column_display_pk and column.primary_key:
  345. continue
  346. columns[p.key] = column
  347. return columns
  348. def get_sortable_columns(self):
  349. """
  350. Returns a dictionary of the sortable columns. Key is a model
  351. field name and value is sort column (for example - attribute).
  352. If `column_sortable_list` is set, will use it. Otherwise, will call
  353. `scaffold_sortable_columns` to get them from the model.
  354. """
  355. self._sortable_joins = dict()
  356. if self.column_sortable_list is None:
  357. return self.scaffold_sortable_columns()
  358. else:
  359. result = dict()
  360. for c in self.column_sortable_list:
  361. if isinstance(c, tuple):
  362. column, path = tools.get_field_with_path(self.model, c[1])
  363. column_name = c[0]
  364. else:
  365. column, path = tools.get_field_with_path(self.model, c)
  366. column_name = text_type(c)
  367. if path and hasattr(path[0], 'property'):
  368. self._sortable_joins[column_name] = path
  369. elif path:
  370. raise Exception("For sorting columns in a related table, "
  371. "column_sortable_list requires a string "
  372. "like '<relation name>.<column name>'. "
  373. "Failed on: {0}".format(c))
  374. else:
  375. # column is in same table, use only model attribute name
  376. if getattr(column, 'key', None) is not None:
  377. column_name = column.key
  378. else:
  379. column_name = text_type(c)
  380. # column_name must match column_name used in `get_list_columns`
  381. result[column_name] = column
  382. return result
  383. def get_column_names(self, only_columns, excluded_columns):
  384. """
  385. Returns a list of tuples with the model field name and formatted
  386. field name.
  387. Overridden to handle special columns like InstrumentedAttribute.
  388. :param only_columns:
  389. List of columns to include in the results. If not set,
  390. `scaffold_list_columns` will generate the list from the model.
  391. :param excluded_columns:
  392. List of columns to exclude from the results.
  393. """
  394. if excluded_columns:
  395. only_columns = [c for c in only_columns if c not in excluded_columns]
  396. formatted_columns = []
  397. for c in only_columns:
  398. try:
  399. column, path = tools.get_field_with_path(self.model, c)
  400. if path:
  401. # column is a relation (InstrumentedAttribute), use full path
  402. column_name = text_type(c)
  403. else:
  404. # column is in same table, use only model attribute name
  405. if getattr(column, 'key', None) is not None:
  406. column_name = column.key
  407. else:
  408. column_name = text_type(c)
  409. except AttributeError:
  410. # TODO: See ticket #1299 - allow virtual columns. Probably figure out
  411. # better way to handle it. For now just assume if column was not found - it
  412. # is virtual and there's column formatter for it.
  413. column_name = text_type(c)
  414. visible_name = self.get_column_name(column_name)
  415. # column_name must match column_name in `get_sortable_columns`
  416. formatted_columns.append((column_name, visible_name))
  417. return formatted_columns
  418. def init_search(self):
  419. """
  420. Initialize search. Returns `True` if search is supported for this
  421. view.
  422. For SQLAlchemy, this will initialize internal fields: list of
  423. column objects used for filtering, etc.
  424. """
  425. if self.column_searchable_list:
  426. self._search_fields = []
  427. for p in self.column_searchable_list:
  428. attr, joins = tools.get_field_with_path(self.model, p)
  429. if not attr:
  430. raise Exception('Failed to find field for search field: %s' % p)
  431. for column in tools.get_columns_for_field(attr):
  432. self._search_fields.append((column, joins))
  433. return bool(self.column_searchable_list)
  434. def scaffold_filters(self, name):
  435. """
  436. Return list of enabled filters
  437. """
  438. attr, joins = tools.get_field_with_path(self.model, name)
  439. if attr is None:
  440. raise Exception('Failed to find field for filter: %s' % name)
  441. # Figure out filters for related column
  442. if is_relationship(attr):
  443. filters = []
  444. for p in self._get_model_iterator(attr.property.mapper.class_):
  445. if hasattr(p, 'columns'):
  446. # TODO: Check for multiple columns
  447. column = p.columns[0]
  448. if column.foreign_keys or column.primary_key:
  449. continue
  450. visible_name = '%s / %s' % (self.get_column_name(attr.prop.table.name),
  451. self.get_column_name(p.key))
  452. type_name = type(column.type).__name__
  453. flt = self.filter_converter.convert(type_name,
  454. column,
  455. visible_name)
  456. if flt:
  457. table = column.table
  458. if joins:
  459. self._filter_joins[column] = joins
  460. elif tools.need_join(self.model, table):
  461. self._filter_joins[column] = [table]
  462. filters.extend(flt)
  463. return filters
  464. else:
  465. is_hybrid_property = tools.is_hybrid_property(self.model, name)
  466. if is_hybrid_property:
  467. column = attr
  468. if isinstance(name, string_types):
  469. column.key = name.split('.')[-1]
  470. else:
  471. columns = tools.get_columns_for_field(attr)
  472. if len(columns) > 1:
  473. raise Exception('Can not filter more than on one column for %s' % name)
  474. column = columns[0]
  475. # If filter related to relation column (represented by
  476. # relation_name.target_column) we collect here relation name
  477. joined_column_name = None
  478. if isinstance(name, string_types) and '.' in name:
  479. joined_column_name = name.split('.')[0]
  480. # Join not needed for hybrid properties
  481. if (not is_hybrid_property and tools.need_join(self.model, column.table) and
  482. name not in self.column_labels):
  483. if joined_column_name:
  484. visible_name = '%s / %s / %s' % (
  485. joined_column_name,
  486. self.get_column_name(column.table.name),
  487. self.get_column_name(column.name)
  488. )
  489. else:
  490. visible_name = '%s / %s' % (
  491. self.get_column_name(column.table.name),
  492. self.get_column_name(column.name)
  493. )
  494. else:
  495. if not isinstance(name, string_types):
  496. visible_name = self.get_column_name(name.property.key)
  497. else:
  498. if self.column_labels and name in self.column_labels:
  499. visible_name = self.column_labels[name]
  500. else:
  501. visible_name = self.get_column_name(name)
  502. visible_name = visible_name.replace('.', ' / ')
  503. type_name = type(column.type).__name__
  504. flt = self.filter_converter.convert(
  505. type_name,
  506. column,
  507. visible_name,
  508. options=self.column_choices.get(name),
  509. )
  510. key_name = column
  511. # In case of filter related to relation column filter key
  512. # must be named with relation name (to prevent following same
  513. # target column to replace previous)
  514. if joined_column_name:
  515. key_name = "{0}.{1}".format(joined_column_name, column)
  516. for f in flt:
  517. f.key_name = key_name
  518. if joins:
  519. self._filter_joins[key_name] = joins
  520. elif not is_hybrid_property and tools.need_join(self.model, column.table):
  521. self._filter_joins[key_name] = [column.table]
  522. return flt
  523. def handle_filter(self, filter):
  524. if isinstance(filter, sqla_filters.BaseSQLAFilter):
  525. column = filter.column
  526. # hybrid_property joins are not supported yet
  527. if (isinstance(column, InstrumentedAttribute) and
  528. tools.need_join(self.model, column.table)):
  529. self._filter_joins[column] = [column.table]
  530. return filter
  531. def scaffold_form(self):
  532. """
  533. Create form from the model.
  534. """
  535. converter = self.model_form_converter(self.session, self)
  536. form_class = form.get_form(self.model, converter,
  537. base_class=self.form_base_class,
  538. only=self.form_columns,
  539. exclude=self.form_excluded_columns,
  540. field_args=self.form_args,
  541. ignore_hidden=self.ignore_hidden,
  542. extra_fields=self.form_extra_fields)
  543. if self.inline_models:
  544. form_class = self.scaffold_inline_form_models(form_class)
  545. return form_class
  546. def scaffold_list_form(self, widget=None, validators=None):
  547. """
  548. Create form for the `index_view` using only the columns from
  549. `self.column_editable_list`.
  550. :param widget:
  551. WTForms widget class. Defaults to `XEditableWidget`.
  552. :param validators:
  553. `form_args` dict with only validators
  554. {'name': {'validators': [required()]}}
  555. """
  556. converter = self.model_form_converter(self.session, self)
  557. form_class = form.get_form(self.model, converter,
  558. base_class=self.form_base_class,
  559. only=self.column_editable_list,
  560. field_args=validators)
  561. return create_editable_list_form(self.form_base_class, form_class,
  562. widget)
  563. def scaffold_inline_form_models(self, form_class):
  564. """
  565. Contribute inline models to the form
  566. :param form_class:
  567. Form class
  568. """
  569. inline_converter = self.inline_model_form_converter(self.session,
  570. self,
  571. self.model_form_converter)
  572. for m in self.inline_models:
  573. form_class = inline_converter.contribute(self.model, form_class, m)
  574. return form_class
  575. def scaffold_auto_joins(self):
  576. """
  577. Return a list of joined tables by going through the
  578. displayed columns.
  579. """
  580. if not self.column_auto_select_related:
  581. return []
  582. relations = set()
  583. for p in self._get_model_iterator():
  584. if hasattr(p, 'direction'):
  585. # Check if it is pointing to same model
  586. if p.mapper.class_ == self.model:
  587. continue
  588. if p.direction.name in ['MANYTOONE', 'MANYTOMANY']:
  589. relations.add(p.key)
  590. joined = []
  591. for prop, name in self._list_columns:
  592. if prop in relations:
  593. joined.append(getattr(self.model, prop))
  594. return joined
  595. # AJAX foreignkey support
  596. def _create_ajax_loader(self, name, options):
  597. return create_ajax_loader(self.model, self.session, name, name, options)
  598. # Database-related API
  599. def get_query(self):
  600. """
  601. Return a query for the model type.
  602. If you override this method, don't forget to override `get_count_query` as well.
  603. This method can be used to set a "persistent filter" on an index_view.
  604. Example::
  605. class MyView(ModelView):
  606. def get_query(self):
  607. return super(MyView, self).get_query().filter(User.username == current_user.username)
  608. """
  609. return self.session.query(self.model)
  610. def get_count_query(self):
  611. """
  612. Return a the count query for the model type
  613. A ``query(self.model).count()`` approach produces an excessive
  614. subquery, so ``query(func.count('*'))`` should be used instead.
  615. See commit ``#45a2723`` for details.
  616. """
  617. return self.session.query(func.count('*')).select_from(self.model)
  618. def _order_by(self, query, joins, sort_joins, sort_field, sort_desc):
  619. """
  620. Apply order_by to the query
  621. :param query:
  622. Query
  623. :pram joins:
  624. Current joins
  625. :param sort_joins:
  626. Sort joins (properties or tables)
  627. :param sort_field:
  628. Sort field
  629. :param sort_desc:
  630. Ascending or descending
  631. """
  632. if sort_field is not None:
  633. # Handle joins
  634. query, joins, alias = self._apply_path_joins(query, joins, sort_joins, inner_join=False)
  635. column = sort_field if alias is None else getattr(alias, sort_field.key)
  636. if sort_desc:
  637. if isinstance(column, tuple):
  638. query = query.order_by(*map(desc, column))
  639. else:
  640. query = query.order_by(desc(column))
  641. else:
  642. if isinstance(column, tuple):
  643. query = query.order_by(*column)
  644. else:
  645. query = query.order_by(column)
  646. return query, joins
  647. def _get_default_order(self):
  648. order = super(ModelView, self)._get_default_order()
  649. if order is not None:
  650. field, direction = order
  651. attr, joins = tools.get_field_with_path(self.model, field)
  652. return attr, joins, direction
  653. return None
  654. def _apply_sorting(self, query, joins, sort_column, sort_desc):
  655. if sort_column is not None:
  656. if sort_column in self._sortable_columns:
  657. sort_field = self._sortable_columns[sort_column]
  658. sort_joins = self._sortable_joins.get(sort_column)
  659. query, joins = self._order_by(query, joins, sort_joins, sort_field, sort_desc)
  660. else:
  661. order = self._get_default_order()
  662. if order:
  663. sort_field, sort_joins, sort_desc = order
  664. query, joins = self._order_by(query, joins, sort_joins, sort_field, sort_desc)
  665. return query, joins
  666. def _apply_search(self, query, count_query, joins, count_joins, search):
  667. """
  668. Apply search to a query.
  669. """
  670. terms = search.split(' ')
  671. for term in terms:
  672. if not term:
  673. continue
  674. stmt = tools.parse_like_term(term)
  675. filter_stmt = []
  676. count_filter_stmt = []
  677. for field, path in self._search_fields:
  678. query, joins, alias = self._apply_path_joins(query, joins, path, inner_join=False)
  679. count_alias = None
  680. if count_query is not None:
  681. count_query, count_joins, count_alias = self._apply_path_joins(count_query,
  682. count_joins,
  683. path,
  684. inner_join=False)
  685. column = field if alias is None else getattr(alias, field.key)
  686. filter_stmt.append(cast(column, Unicode).ilike(stmt))
  687. if count_filter_stmt is not None:
  688. column = field if count_alias is None else getattr(count_alias, field.key)
  689. count_filter_stmt.append(cast(column, Unicode).ilike(stmt))
  690. query = query.filter(or_(*filter_stmt))
  691. if count_query is not None:
  692. count_query = count_query.filter(or_(*count_filter_stmt))
  693. return query, count_query, joins, count_joins
  694. def _apply_filters(self, query, count_query, joins, count_joins, filters):
  695. for idx, flt_name, value in filters:
  696. flt = self._filters[idx]
  697. alias = None
  698. count_alias = None
  699. # Figure out joins
  700. if isinstance(flt, sqla_filters.BaseSQLAFilter):
  701. # If no key_name is specified, use filter column as filter key
  702. filter_key = flt.key_name or flt.column
  703. path = self._filter_joins.get(filter_key, [])
  704. query, joins, alias = self._apply_path_joins(query, joins, path, inner_join=False)
  705. if count_query is not None:
  706. count_query, count_joins, count_alias = self._apply_path_joins(
  707. count_query,
  708. count_joins,
  709. path,
  710. inner_join=False)
  711. # Clean value .clean() and apply the filter
  712. clean_value = flt.clean(value)
  713. try:
  714. query = flt.apply(query, clean_value, alias)
  715. except TypeError:
  716. spec = inspect.getargspec(flt.apply)
  717. if len(spec.args) == 3:
  718. warnings.warn('Please update your custom filter %s to '
  719. 'include additional `alias` parameter.' % repr(flt))
  720. else:
  721. raise
  722. query = flt.apply(query, clean_value)
  723. if count_query is not None:
  724. try:
  725. count_query = flt.apply(count_query, clean_value, count_alias)
  726. except TypeError:
  727. count_query = flt.apply(count_query, clean_value)
  728. return query, count_query, joins, count_joins
  729. def _apply_pagination(self, query, page, page_size):
  730. if page_size is None:
  731. page_size = self.page_size
  732. if page_size:
  733. query = query.limit(page_size)
  734. if page and page_size:
  735. query = query.offset(page * page_size)
  736. return query
  737. def get_list(self, page, sort_column, sort_desc, search, filters,
  738. execute=True, page_size=None):
  739. """
  740. Return records from the database.
  741. :param page:
  742. Page number
  743. :param sort_column:
  744. Sort column name
  745. :param sort_desc:
  746. Descending or ascending sort
  747. :param search:
  748. Search query
  749. :param execute:
  750. Execute query immediately? Default is `True`
  751. :param filters:
  752. List of filter tuples
  753. :param page_size:
  754. Number of results. Defaults to ModelView's page_size. Can be
  755. overriden to change the page_size limit. Removing the page_size
  756. limit requires setting page_size to 0 or False.
  757. """
  758. # Will contain join paths with optional aliased object
  759. joins = {}
  760. count_joins = {}
  761. query = self.get_query()
  762. count_query = self.get_count_query() if not self.simple_list_pager else None
  763. # Ignore eager-loaded relations (prevent unnecessary joins)
  764. # TODO: Separate join detection for query and count query?
  765. if hasattr(query, '_join_entities'):
  766. for entity in query._join_entities:
  767. for table in entity.tables:
  768. joins[table] = None
  769. # Apply search criteria
  770. if self._search_supported and search:
  771. query, count_query, joins, count_joins = self._apply_search(query,
  772. count_query,
  773. joins,
  774. count_joins,
  775. search)
  776. # Apply filters
  777. if filters and self._filters:
  778. query, count_query, joins, count_joins = self._apply_filters(query,
  779. count_query,
  780. joins,
  781. count_joins,
  782. filters)
  783. # Calculate number of rows if necessary
  784. count = count_query.scalar() if count_query else None
  785. # Auto join
  786. for j in self._auto_joins:
  787. query = query.options(joinedload(j))
  788. # Sorting
  789. query, joins = self._apply_sorting(query, joins, sort_column, sort_desc)
  790. # Pagination
  791. query = self._apply_pagination(query, page, page_size)
  792. # Execute if needed
  793. if execute:
  794. query = query.all()
  795. return count, query
  796. def get_one(self, id):
  797. """
  798. Return a single model by its id.
  799. :param id:
  800. Model id
  801. """
  802. return self.session.query(self.model).get(tools.iterdecode(id))
  803. # Error handler
  804. def handle_view_exception(self, exc):
  805. if isinstance(exc, IntegrityError):
  806. if current_app.config.get('ADMIN_RAISE_ON_VIEW_EXCEPTION'):
  807. raise
  808. else:
  809. flash(gettext('Integrity error. %(message)s', message=text_type(exc)), 'error')
  810. return True
  811. return super(ModelView, self).handle_view_exception(exc)
  812. # Model handlers
  813. def create_model(self, form):
  814. """
  815. Create model from form.
  816. :param form:
  817. Form instance
  818. """
  819. try:
  820. model = self.model()
  821. form.populate_obj(model)
  822. self.session.add(model)
  823. self._on_model_change(form, model, True)
  824. self.session.commit()
  825. except Exception as ex:
  826. if not self.handle_view_exception(ex):
  827. flash(gettext('Failed to create record. %(error)s', error=str(ex)), 'error')
  828. log.exception('Failed to create record.')
  829. self.session.rollback()
  830. return False
  831. else:
  832. self.after_model_change(form, model, True)
  833. return model
  834. def update_model(self, form, model):
  835. """
  836. Update model from form.
  837. :param form:
  838. Form instance
  839. :param model:
  840. Model instance
  841. """
  842. try:
  843. form.populate_obj(model)
  844. self._on_model_change(form, model, False)
  845. self.session.commit()
  846. except Exception as ex:
  847. if not self.handle_view_exception(ex):
  848. flash(gettext('Failed to update record. %(error)s', error=str(ex)), 'error')
  849. log.exception('Failed to update record.')
  850. self.session.rollback()
  851. return False
  852. else:
  853. self.after_model_change(form, model, False)
  854. return True
  855. def delete_model(self, model):
  856. """
  857. Delete model.
  858. :param model:
  859. Model to delete
  860. """
  861. try:
  862. self.on_model_delete(model)
  863. self.session.flush()
  864. self.session.delete(model)
  865. self.session.commit()
  866. except Exception as ex:
  867. if not self.handle_view_exception(ex):
  868. flash(gettext('Failed to delete record. %(error)s', error=str(ex)), 'error')
  869. log.exception('Failed to delete record.')
  870. self.session.rollback()
  871. return False
  872. else:
  873. self.after_model_delete(model)
  874. return True
  875. # Default model actions
  876. def is_action_allowed(self, name):
  877. # Check delete action permission
  878. if name == 'delete' and not self.can_delete:
  879. return False
  880. return super(ModelView, self).is_action_allowed(name)
  881. @action('delete',
  882. lazy_gettext('Delete'),
  883. lazy_gettext('Are you sure you want to delete selected records?'))
  884. def action_delete(self, ids):
  885. try:
  886. query = tools.get_query_for_ids(self.get_query(), self.model, ids)
  887. if self.fast_mass_delete:
  888. count = query.delete(synchronize_session=False)
  889. else:
  890. count = 0
  891. for m in query.all():
  892. if self.delete_model(m):
  893. count += 1
  894. self.session.commit()
  895. flash(ngettext('Record was successfully deleted.',
  896. '%(count)s records were successfully deleted.',
  897. count,
  898. count=count), 'success')
  899. except Exception as ex:
  900. if not self.handle_view_exception(ex):
  901. raise
  902. flash(gettext('Failed to delete records. %(error)s', error=str(ex)), 'error')