view.py 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675
  1. import logging
  2. from flask import request, flash, abort, Response
  3. from flask_admin import expose
  4. from flask_admin.babel import gettext, ngettext, lazy_gettext
  5. from flask_admin.model import BaseModelView
  6. from flask_admin.model.form import create_editable_list_form
  7. from flask_admin._compat import iteritems, string_types
  8. import mongoengine
  9. import gridfs
  10. from mongoengine.connection import get_db
  11. from bson.objectid import ObjectId
  12. from flask_admin.actions import action
  13. from .filters import FilterConverter, BaseMongoEngineFilter
  14. from .form import get_form, CustomModelConverter
  15. from .typefmt import DEFAULT_FORMATTERS
  16. from .tools import parse_like_term
  17. from .helpers import format_error
  18. from .ajax import process_ajax_references, create_ajax_loader
  19. from .subdoc import convert_subdocuments
  20. # Set up logger
  21. log = logging.getLogger("flask-admin.mongo")
  22. SORTABLE_FIELDS = set((
  23. mongoengine.StringField,
  24. mongoengine.IntField,
  25. mongoengine.FloatField,
  26. mongoengine.BooleanField,
  27. mongoengine.DateTimeField,
  28. mongoengine.ComplexDateTimeField,
  29. mongoengine.ObjectIdField,
  30. mongoengine.DecimalField,
  31. mongoengine.ReferenceField,
  32. mongoengine.EmailField,
  33. mongoengine.UUIDField,
  34. mongoengine.URLField
  35. ))
  36. class ModelView(BaseModelView):
  37. """
  38. MongoEngine model scaffolding.
  39. """
  40. column_filters = None
  41. """
  42. Collection of the column filters.
  43. Can contain either field names or instances of
  44. :class:`flask_admin.contrib.mongoengine.filters.BaseMongoEngineFilter`
  45. classes.
  46. Filters will be grouped by name when displayed in the drop-down.
  47. For example::
  48. class MyModelView(BaseModelView):
  49. column_filters = ('user', 'email')
  50. or::
  51. from flask_admin.contrib.mongoengine.filters import BooleanEqualFilter
  52. class MyModelView(BaseModelView):
  53. column_filters = (BooleanEqualFilter(column=User.name, name='Name'),)
  54. or::
  55. from flask_admin.contrib.mongoengine.filters import BaseMongoEngineFilter
  56. class FilterLastNameBrown(BaseMongoEngineFilter):
  57. def apply(self, query, value):
  58. if value == '1':
  59. return query.filter(self.column == "Brown")
  60. else:
  61. return query.filter(self.column != "Brown")
  62. def operation(self):
  63. return 'is Brown'
  64. class MyModelView(BaseModelView):
  65. column_filters = [
  66. FilterLastNameBrown(
  67. column=User.last_name, name='Last Name',
  68. options=(('1', 'Yes'), ('0', 'No'))
  69. )
  70. ]
  71. """
  72. model_form_converter = CustomModelConverter
  73. """
  74. Model form conversion class. Use this to implement custom
  75. field conversion logic.
  76. Custom class should be derived from the
  77. `flask_admin.contrib.mongoengine.form.CustomModelConverter`.
  78. For example::
  79. class MyModelConverter(AdminModelConverter):
  80. pass
  81. class MyAdminView(ModelView):
  82. model_form_converter = MyModelConverter
  83. """
  84. object_id_converter = ObjectId
  85. """
  86. Mongodb ``_id`` value conversion function. Default is `bson.ObjectId`.
  87. Use this if you are using String, Binary and etc.
  88. For example::
  89. class MyModelView(BaseModelView):
  90. object_id_converter = int
  91. or::
  92. class MyModelView(BaseModelView):
  93. object_id_converter = str
  94. """
  95. filter_converter = FilterConverter()
  96. """
  97. Field to filter converter.
  98. Override this attribute to use a non-default converter.
  99. """
  100. column_type_formatters = DEFAULT_FORMATTERS
  101. """
  102. Customized type formatters for MongoEngine backend
  103. """
  104. allowed_search_types = (mongoengine.StringField,
  105. mongoengine.URLField,
  106. mongoengine.EmailField)
  107. """
  108. List of allowed search field types.
  109. """
  110. form_subdocuments = None
  111. """
  112. Subdocument configuration options.
  113. This field accepts dictionary, where key is field name and value is either dictionary or instance of the
  114. `flask_admin.contrib.mongoengine.EmbeddedForm`.
  115. Consider following example::
  116. class Comment(db.EmbeddedDocument):
  117. name = db.StringField(max_length=20, required=True)
  118. value = db.StringField(max_length=20)
  119. class Post(db.Document):
  120. text = db.StringField(max_length=30)
  121. data = db.EmbeddedDocumentField(Comment)
  122. class MyAdmin(ModelView):
  123. form_subdocuments = {
  124. 'data': {
  125. 'form_columns': ('name',)
  126. }
  127. }
  128. In this example, `Post` model has child `Comment` subdocument. When generating form for `Comment` embedded
  129. document, Flask-Admin will only create `name` field.
  130. It is also possible to use class-based embedded document configuration::
  131. class CommentEmbed(EmbeddedForm):
  132. form_columns = ('name',)
  133. class MyAdmin(ModelView):
  134. form_subdocuments = {
  135. 'data': CommentEmbed()
  136. }
  137. Arbitrary depth nesting is supported::
  138. class SomeEmbed(EmbeddedForm):
  139. form_excluded_columns = ('test',)
  140. class CommentEmbed(EmbeddedForm):
  141. form_columns = ('name',)
  142. form_subdocuments = {
  143. 'inner': SomeEmbed()
  144. }
  145. class MyAdmin(ModelView):
  146. form_subdocuments = {
  147. 'data': CommentEmbed()
  148. }
  149. There's also support for forms embedded into `ListField`. All you have
  150. to do is to create nested rule with `None` as a name. Even though it
  151. is slightly confusing, but that's how Flask-MongoEngine creates
  152. form fields embedded into ListField::
  153. class Comment(db.EmbeddedDocument):
  154. name = db.StringField(max_length=20, required=True)
  155. value = db.StringField(max_length=20)
  156. class Post(db.Document):
  157. text = db.StringField(max_length=30)
  158. data = db.ListField(db.EmbeddedDocumentField(Comment))
  159. class MyAdmin(ModelView):
  160. form_subdocuments = {
  161. 'data': {
  162. 'form_subdocuments': {
  163. None: {
  164. 'form_columns': ('name',)
  165. }
  166. }
  167. }
  168. }
  169. """
  170. def __init__(self, model, name=None,
  171. category=None, endpoint=None, url=None, static_folder=None,
  172. menu_class_name=None, menu_icon_type=None, menu_icon_value=None):
  173. """
  174. Constructor
  175. :param model:
  176. Model class
  177. :param name:
  178. Display name
  179. :param category:
  180. Display category
  181. :param endpoint:
  182. Endpoint
  183. :param url:
  184. Custom URL
  185. :param menu_class_name:
  186. Optional class name for the menu item.
  187. :param menu_icon_type:
  188. Optional icon. Possible icon types:
  189. - `flask_admin.consts.ICON_TYPE_GLYPH` - Bootstrap glyph icon
  190. - `flask_admin.consts.ICON_TYPE_FONT_AWESOME` - Font Awesome icon
  191. - `flask_admin.consts.ICON_TYPE_IMAGE` - Image relative to Flask static directory
  192. - `flask_admin.consts.ICON_TYPE_IMAGE_URL` - Image with full URL
  193. :param menu_icon_value:
  194. Icon glyph name or URL, depending on `menu_icon_type` setting
  195. """
  196. self._search_fields = []
  197. super(ModelView, self).__init__(model, name, category, endpoint, url, static_folder,
  198. menu_class_name=menu_class_name,
  199. menu_icon_type=menu_icon_type,
  200. menu_icon_value=menu_icon_value)
  201. self._primary_key = self.scaffold_pk()
  202. def _refresh_cache(self):
  203. """
  204. Refresh cache.
  205. """
  206. # Process subdocuments
  207. if self.form_subdocuments is None:
  208. self.form_subdocuments = {}
  209. self._form_subdocuments = convert_subdocuments(self.form_subdocuments)
  210. # Cache other properties
  211. super(ModelView, self)._refresh_cache()
  212. def _process_ajax_references(self):
  213. """
  214. AJAX endpoint is exposed by top-level admin view class, but
  215. subdocuments might have AJAX references too.
  216. This method will recursively go over subdocument configuration
  217. and will precompute AJAX references for them ensuring that
  218. subdocuments can also use AJAX to populate their ReferenceFields.
  219. """
  220. references = super(ModelView, self)._process_ajax_references()
  221. return process_ajax_references(references, self)
  222. def _get_model_fields(self, model=None):
  223. """
  224. Inspect model and return list of model fields
  225. :param model:
  226. Model to inspect
  227. """
  228. if model is None:
  229. model = self.model
  230. return sorted(iteritems(model._fields), key=lambda n: n[1].creation_counter)
  231. def scaffold_pk(self):
  232. # MongoEngine models have predefined 'id' as a key
  233. return 'id'
  234. def get_pk_value(self, model):
  235. """
  236. Return the primary key value from the model instance
  237. :param model:
  238. Model instance
  239. """
  240. return model.pk
  241. def scaffold_list_columns(self):
  242. """
  243. Scaffold list columns
  244. """
  245. columns = []
  246. for n, f in self._get_model_fields():
  247. # Verify type
  248. field_class = type(f)
  249. if (field_class == mongoengine.ListField and
  250. isinstance(f.field, mongoengine.EmbeddedDocumentField)):
  251. continue
  252. if field_class == mongoengine.EmbeddedDocumentField:
  253. continue
  254. if self.column_display_pk or field_class != mongoengine.ObjectIdField:
  255. columns.append(n)
  256. return columns
  257. def scaffold_sortable_columns(self):
  258. """
  259. Return a dictionary of sortable columns (name, field)
  260. """
  261. columns = {}
  262. for n, f in self._get_model_fields():
  263. if type(f) in SORTABLE_FIELDS:
  264. if self.column_display_pk or type(f) != mongoengine.ObjectIdField:
  265. columns[n] = f
  266. return columns
  267. def init_search(self):
  268. """
  269. Init search
  270. """
  271. if self.column_searchable_list:
  272. for p in self.column_searchable_list:
  273. if isinstance(p, string_types):
  274. p = self.model._fields.get(p)
  275. if p is None:
  276. raise Exception('Invalid search field')
  277. field_type = type(p)
  278. # Check type
  279. if (field_type not in self.allowed_search_types):
  280. raise Exception('Can only search on text columns. ' +
  281. 'Failed to setup search for "%s"' % p)
  282. self._search_fields.append(p)
  283. return bool(self._search_fields)
  284. def scaffold_filters(self, name):
  285. """
  286. Return filter object(s) for the field
  287. :param name:
  288. Either field name or field instance
  289. """
  290. if isinstance(name, string_types):
  291. attr = self.model._fields.get(name)
  292. else:
  293. attr = name
  294. if attr is None:
  295. raise Exception('Failed to find field for filter: %s' % name)
  296. # Find name
  297. visible_name = None
  298. if not isinstance(name, string_types):
  299. visible_name = self.get_column_name(attr.name)
  300. if not visible_name:
  301. visible_name = self.get_column_name(name)
  302. # Convert filter
  303. type_name = type(attr).__name__
  304. flt = self.filter_converter.convert(type_name,
  305. attr,
  306. visible_name)
  307. return flt
  308. def is_valid_filter(self, filter):
  309. """
  310. Validate if the provided filter is a valid MongoEngine filter
  311. :param filter:
  312. Filter object
  313. """
  314. return isinstance(filter, BaseMongoEngineFilter)
  315. def scaffold_form(self):
  316. """
  317. Create form from the model.
  318. """
  319. form_class = get_form(self.model,
  320. self.model_form_converter(self),
  321. base_class=self.form_base_class,
  322. only=self.form_columns,
  323. exclude=self.form_excluded_columns,
  324. field_args=self.form_args,
  325. extra_fields=self.form_extra_fields)
  326. return form_class
  327. def scaffold_list_form(self, widget=None, validators=None):
  328. """
  329. Create form for the `index_view` using only the columns from
  330. `self.column_editable_list`.
  331. :param widget:
  332. WTForms widget class. Defaults to `XEditableWidget`.
  333. :param validators:
  334. `form_args` dict with only validators
  335. {'name': {'validators': [required()]}}
  336. """
  337. form_class = get_form(self.model,
  338. self.model_form_converter(self),
  339. base_class=self.form_base_class,
  340. only=self.column_editable_list,
  341. field_args=validators)
  342. return create_editable_list_form(self.form_base_class, form_class,
  343. widget)
  344. # AJAX foreignkey support
  345. def _create_ajax_loader(self, name, opts):
  346. return create_ajax_loader(self.model, name, name, opts)
  347. def get_query(self):
  348. """
  349. Returns the QuerySet for this view. By default, it returns all the
  350. objects for the current model.
  351. """
  352. return self.model.objects
  353. def _search(self, query, search_term):
  354. # TODO: Unfortunately, MongoEngine contains bug which
  355. # prevents running complex Q queries and, as a result,
  356. # Flask-Admin does not support per-word searching like
  357. # in other backends
  358. op, term = parse_like_term(search_term)
  359. criteria = None
  360. for field in self._search_fields:
  361. flt = {'%s__%s' % (field.name, op): term}
  362. q = mongoengine.Q(**flt)
  363. if criteria is None:
  364. criteria = q
  365. else:
  366. criteria |= q
  367. return query.filter(criteria)
  368. def get_list(self, page, sort_column, sort_desc, search, filters,
  369. execute=True, page_size=None):
  370. """
  371. Get list of objects from MongoEngine
  372. :param page:
  373. Page number
  374. :param sort_column:
  375. Sort column
  376. :param sort_desc:
  377. Sort descending
  378. :param search:
  379. Search criteria
  380. :param filters:
  381. List of applied filters
  382. :param execute:
  383. Run query immediately or not
  384. :param page_size:
  385. Number of results. Defaults to ModelView's page_size. Can be
  386. overriden to change the page_size limit. Removing the page_size
  387. limit requires setting page_size to 0 or False.
  388. """
  389. query = self.get_query()
  390. # Filters
  391. if self._filters:
  392. for flt, flt_name, value in filters:
  393. f = self._filters[flt]
  394. query = f.apply(query, f.clean(value))
  395. # Search
  396. if self._search_supported and search:
  397. query = self._search(query, search)
  398. # Get count
  399. count = query.count() if not self.simple_list_pager else None
  400. # Sorting
  401. if sort_column:
  402. query = query.order_by('%s%s' % ('-' if sort_desc else '', sort_column))
  403. else:
  404. order = self._get_default_order()
  405. if order:
  406. query = query.order_by('%s%s' % ('-' if order[1] else '', order[0]))
  407. # Pagination
  408. if page_size is None:
  409. page_size = self.page_size
  410. if page_size:
  411. query = query.limit(page_size)
  412. if page and page_size:
  413. query = query.skip(page * page_size)
  414. if execute:
  415. query = query.all()
  416. return count, query
  417. def get_one(self, id):
  418. """
  419. Return a single model instance by its ID
  420. :param id:
  421. Model ID
  422. """
  423. try:
  424. return self.get_query().filter(pk=id).first()
  425. except mongoengine.ValidationError as ex:
  426. flash(gettext('Failed to get model. %(error)s',
  427. error=format_error(ex)),
  428. 'error')
  429. return None
  430. def create_model(self, form):
  431. """
  432. Create model helper
  433. :param form:
  434. Form instance
  435. """
  436. try:
  437. model = self.model()
  438. form.populate_obj(model)
  439. self._on_model_change(form, model, True)
  440. model.save()
  441. except Exception as ex:
  442. if not self.handle_view_exception(ex):
  443. flash(gettext('Failed to create record. %(error)s',
  444. error=format_error(ex)),
  445. 'error')
  446. log.exception('Failed to create record.')
  447. return False
  448. else:
  449. self.after_model_change(form, model, True)
  450. return model
  451. def update_model(self, form, model):
  452. """
  453. Update model helper
  454. :param form:
  455. Form instance
  456. :param model:
  457. Model instance to update
  458. """
  459. try:
  460. form.populate_obj(model)
  461. self._on_model_change(form, model, False)
  462. model.save()
  463. except Exception as ex:
  464. if not self.handle_view_exception(ex):
  465. flash(gettext('Failed to update record. %(error)s',
  466. error=format_error(ex)),
  467. 'error')
  468. log.exception('Failed to update record.')
  469. return False
  470. else:
  471. self.after_model_change(form, model, False)
  472. return True
  473. def delete_model(self, model):
  474. """
  475. Delete model helper
  476. :param model:
  477. Model instance
  478. """
  479. try:
  480. self.on_model_delete(model)
  481. model.delete()
  482. except Exception as ex:
  483. if not self.handle_view_exception(ex):
  484. flash(gettext('Failed to delete record. %(error)s',
  485. error=format_error(ex)),
  486. 'error')
  487. log.exception('Failed to delete record.')
  488. return False
  489. else:
  490. self.after_model_delete(model)
  491. return True
  492. # FileField access API
  493. @expose('/api/file/')
  494. def api_file_view(self):
  495. pk = request.args.get('id')
  496. coll = request.args.get('coll')
  497. db = request.args.get('db', 'default')
  498. if not pk or not coll or not db:
  499. abort(404)
  500. fs = gridfs.GridFS(get_db(db), coll)
  501. data = fs.get(self.object_id_converter(pk))
  502. if not data:
  503. abort(404)
  504. return Response(data.read(),
  505. content_type=data.content_type,
  506. headers={'Content-Length': data.length})
  507. # Default model actions
  508. def is_action_allowed(self, name):
  509. # Check delete action permission
  510. if name == 'delete' and not self.can_delete:
  511. return False
  512. return super(ModelView, self).is_action_allowed(name)
  513. @action('delete',
  514. lazy_gettext('Delete'),
  515. lazy_gettext('Are you sure you want to delete selected records?'))
  516. def action_delete(self, ids):
  517. try:
  518. count = 0
  519. all_ids = [self.object_id_converter(pk) for pk in ids]
  520. for obj in self.get_query().in_bulk(all_ids).values():
  521. count += self.delete_model(obj)
  522. flash(ngettext('Record was successfully deleted.',
  523. '%(count)s records were successfully deleted.',
  524. count,
  525. count=count), 'success')
  526. except Exception as ex:
  527. if not self.handle_view_exception(ex):
  528. flash(gettext('Failed to delete records. %(error)s', error=str(ex)),
  529. 'error')