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