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