class ModelView(BaseModelView): """ SQLAlchemy model view Usage sample:: admin = Admin() admin.add_view(ModelView(User, db.session)) """ column_hide_backrefs = ObsoleteAttr('column_hide_backrefs', 'hide_backrefs', True) """ Set this to False if you want to see multiselect for model backrefs. """ column_auto_select_related = ObsoleteAttr('column_auto_select_related', 'auto_select_related', True) """ Enable automatic detection of displayed foreign keys in this view and perform automatic joined loading for related models to improve query performance. Please note that detection is not recursive: if `__unicode__` method of related model uses another model to generate string representation, it will still make separate database call. """ column_select_related_list = ObsoleteAttr('column_select_related', 'list_select_related', None) """ List of parameters for SQLAlchemy `subqueryload`. Overrides `column_auto_select_related` property. For example:: class PostAdmin(ModelAdmin): column_select_related_list = ('user', 'city') You can also use properties:: class PostAdmin(ModelAdmin): column_select_related_list = (Post.user, Post.city) Please refer to the `subqueryload` on list of possible values. """ column_display_all_relations = ObsoleteAttr('column_display_all_relations', 'list_display_all_relations', False) """ Controls if list view should display all relations, not only many-to-one. """ column_searchable_list = ObsoleteAttr('column_searchable_list', 'searchable_columns', None) """ Collection of the searchable columns. Only text-based columns are searchable (`String`, `Unicode`, `Text`, `UnicodeText`). Example:: class MyModelView(ModelView): column_searchable_list = ('name', 'email') You can also pass columns:: class MyModelView(ModelView): column_searchable_list = (User.name, User.email) The following search rules apply: - If you enter *ZZZ* in the UI search field, it will generate *ILIKE '%ZZZ%'* statement against searchable columns. - If you enter multiple words, each word will be searched separately, but only rows that contain all words will be displayed. For example, searching for 'abc def' will find all rows that contain 'abc' and 'def' in one or more columns. - If you prefix your search term with ^, it will find all rows that start with ^. So, if you entered *^ZZZ*, *ILIKE 'ZZZ%'* will be used. - If you prefix your search term with =, it will perform an exact match. For example, if you entered *=ZZZ*, the statement *ILIKE 'ZZZ'* will be used. """ column_filters = None """ Collection of the column filters. Can contain either field names or instances of :class:`flask.ext.admin.contrib.sqlamodel.filters.BaseFilter` classes. For example:: class MyModelView(BaseModelView): column_filters = ('user', 'email') or:: class MyModelView(BaseModelView): column_filters = (BooleanEqualFilter(User.name, 'Name')) """ model_form_converter = form.AdminModelConverter """ Model form conversion class. Use this to implement custom field conversion logic. For example:: class MyModelConverter(AdminModelConverter): pass class MyAdminView(ModelView): model_form_converter = MyModelConverter """ inline_model_form_converter = form.InlineModelConverter """ Inline model conversion class. If you need some kind of post-processing for inline forms, you can customize behavior by doing something like this:: class MyInlineModelConverter(AdminModelConverter): def post_process(self, form_class, info): form_class.value = wtf.TextField('value') return form_class class MyAdminView(ModelView): inline_model_form_converter = MyInlineModelConverter """ filter_converter = filters.FilterConverter() """ Field to filter converter. Override this attribute to use non-default converter. """ fast_mass_delete = False """ If set to `False` and user deletes more than one model using built in action, all models will be read from the database and then deleted one by one giving SQLAlchemy a chance to manually cleanup any dependencies (many-to-many relationships, etc). If set to `True`, will run a `DELETE` statement which is somewhat faster, but may leave corrupted data if you forget to configure `DELETE CASCADE` for your model. """ inline_models = None """ Inline related-model editing for models with parent-child relations. Accepts enumerable with one of the following possible values: 1. Child model class:: class MyModelView(ModelView): inline_models = (Post,) 2. Child model class and additional options:: class MyModelView(ModelView): inline_models = [(Post, dict(form_columns=['title']))] 3. Django-like ``InlineFormAdmin`` class instance:: class MyInlineModelForm(InlineFormAdmin): form_columns = ('title', 'date') class MyModelView(ModelView): inline_models = (MyInlineModelForm(MyInlineModel),) You can customize the generated field name by: 1. Using the `form_name` property as a key to the options dictionary: class MyModelView(ModelView): inline_models = ((Post, dict(form_label='Hello'))) 2. Using forward relation name and `column_labels` property: class Model1(Base): pass class Model2(Base): # ... model1 = relation(Model1, backref='models') class MyModel1View(Base): inline_models = (Model2,) column_labels = {'models': 'Hello'} """ column_type_formatters = DEFAULT_FORMATTERS form_choices = None """ Map choices to form fields Example:: class MyModelView(BaseModelView): form_choices = {'my_form_field': [ ('db_value', 'display_value'), ] """ def __init__(self, model, session, name=None, category=None, endpoint=None, url=None): """ Constructor. :param model: Model class :param session: SQLAlchemy session :param name: View name. If not set, defaults to the model name :param category: Category name :param endpoint: Endpoint name. If not set, defaults to the model name :param url: Base URL. If not set, defaults to '/admin/' + endpoint """ self.session = session self._search_fields = None self._search_joins = dict() self._filter_joins = dict() if self.form_choices is None: self.form_choices = {} super(ModelView, self).__init__(model, name, category, endpoint, url) # Primary key self._primary_key = self.scaffold_pk() if self._primary_key is None: raise Exception('Model %s does not have primary key.' % self.model.__name__) # Configuration if not self.column_select_related_list: self._auto_joins = self.scaffold_auto_joins() else: self._auto_joins = self.column_select_related_list # Internal API def _get_model_iterator(self, model=None): """ Return property iterator for the model """ if model is None: model = self.model return model._sa_class_manager.mapper.iterate_properties # Scaffolding def scaffold_pk(self): """ Return the primary key name from a model """ return tools.get_primary_key(self.model) def get_pk_value(self, model): """ Return the PK value from a model object. """ return getattr(model, self._primary_key) def scaffold_list_columns(self): """ Return a list of columns from the model. """ columns = [] for p in self._get_model_iterator(): # Verify type if hasattr(p, 'direction'): if self.column_display_all_relations or p.direction.name == 'MANYTOONE': columns.append(p.key) elif hasattr(p, 'columns'): # TODO: Check for multiple columns column = p.columns[0] if column.foreign_keys: continue if not self.column_display_pk and column.primary_key: continue columns.append(p.key) return columns def scaffold_sortable_columns(self): """ Return a dictionary of sortable columns. Key is column name, value is sort column/field. """ columns = dict() for p in self._get_model_iterator(): if hasattr(p, 'columns'): # Sanity check if len(p.columns) > 1: # Multi-column properties are not supported continue column = p.columns[0] # Can't sort on primary or foreign keys by default if column.foreign_keys: continue if not self.column_display_pk and column.primary_key: continue columns[p.key] = column return columns def _get_columns_for_field(self, field): if isinstance(field, basestring): attr = getattr(self.model, field, None) if field is None: raise Exception('Field %s was not found.' % field) else: attr = field if (not attr or not hasattr(attr, 'property') or not hasattr(attr.property, 'columns') or not attr.property.columns): raise Exception('Invalid field %s: does not contains any columns.' % field) return attr.property.columns def _need_join(self, table): return table not in self.model._sa_class_manager.mapper.tables def init_search(self): """ Initialize search. Returns `True` if search is supported for this view. For SQLAlchemy, this will initialize internal fields: list of column objects used for filtering, etc. """ if self.column_searchable_list: self._search_fields = [] self._search_joins = dict() for p in self.column_searchable_list: for column in self._get_columns_for_field(p): column_type = type(column.type).__name__ if not self.is_text_column_type(column_type): raise Exception('Can only search on text columns. ' + 'Failed to setup search for "%s"' % p) self._search_fields.append(column) # If it belongs to different table - add a join if self._need_join(column.table): self._search_joins[column.table.name] = column.table return bool(self.column_searchable_list) def is_text_column_type(self, name): """ Verify if the provided column type is text-based. :returns: ``True`` for ``String``, ``Unicode``, ``Text``, ``UnicodeText`` """ return name in ('String', 'Unicode', 'Text', 'UnicodeText') def scaffold_filters(self, name): """ Return list of enabled filters """ join_tables = [] if isinstance(name, basestring): model = self.model for attribute in name.split('.'): value = getattr(model, attribute) if (hasattr(value, 'property') and hasattr(value.property, 'direction')): model = value.property.mapper.class_ table = model.__table__ if self._need_join(table): join_tables.append(table) attr = value else: attr = name if attr is None: raise Exception('Failed to find field for filter: %s' % name) # Figure out filters for related column if hasattr(attr, 'property') and hasattr(attr.property, 'direction'): filters = [] for p in self._get_model_iterator(attr.property.mapper.class_): if hasattr(p, 'columns'): # TODO: Check for multiple columns column = p.columns[0] if column.foreign_keys or column.primary_key: continue visible_name = '%s / %s' % (self.get_column_name(attr.prop.table.name), self.get_column_name(p.key)) type_name = type(column.type).__name__ flt = self.filter_converter.convert(type_name, column, visible_name) if flt: table = column.table if join_tables: self._filter_joins[table.name] = join_tables elif self._need_join(table.name): self._filter_joins[table.name] = [table.name] filters.extend(flt) return filters else: columns = self._get_columns_for_field(attr) if len(columns) > 1: raise Exception('Can not filter more than on one column for %s' % name) column = columns[0] if self._need_join(column.table) and name not in self.column_labels: visible_name = '%s / %s' % ( self.get_column_name(column.table.name), self.get_column_name(column.name) ) else: if not isinstance(name, basestring): visible_name = self.get_column_name(name.property.key) else: visible_name = self.get_column_name(name) type_name = type(column.type).__name__ if join_tables: self._filter_joins[column.table.name] = join_tables flt = self.filter_converter.convert( type_name, column, visible_name, options=self.column_choices.get(name), ) if flt and not join_tables and self._need_join(column.table): self._filter_joins[column.table.name] = [column.table] return flt def is_valid_filter(self, filter): """ Verify that the provided filter object is derived from the SQLAlchemy-compatible filter class. :param filter: Filter object to verify. """ return isinstance(filter, filters.BaseSQLAFilter) def scaffold_form(self): """ Create form from the model. """ converter = self.model_form_converter(self.session, self) form_class = form.get_form(self.model, converter, only=self.form_columns, exclude=self.form_excluded_columns, field_args=self.form_args) if self.inline_models: form_class = self.scaffold_inline_form_models(form_class) return form_class def scaffold_inline_form_models(self, form_class): """ Contribute inline models to the form :param form_class: Form class """ converter = self.model_form_converter(self.session, self) inline_converter = self.inline_model_form_converter(self.session, self) for m in self.inline_models: form_class = inline_converter.contribute(converter, self.model, form_class, m) return form_class def scaffold_auto_joins(self): """ Return a list of joined tables by going through the displayed columns. """ if not self.column_auto_select_related: return [] relations = set() for p in self._get_model_iterator(): if hasattr(p, 'direction'): # Check if it is pointing to same model if p.mapper.class_ == self.model: continue if p.direction.name == 'MANYTOONE': relations.add(p.key) joined = [] for prop, name in self._list_columns: if prop in relations: joined.append(getattr(self.model, prop)) return joined # Database-related API def get_query(self): """ Return a query for the model type. If you override this method, don't forget to override `get_count_query` as well. """ return self.session.query(self.model) def get_count_query(self): """ Return a the count query for the model type """ return self.session.query(func.count('*')).select_from(self.model) def _order_by(self, query, joins, sort_field, sort_desc): """ Apply order_by to the query :param query: Query :param joins: Joins set :param sort_field: Sort field :param sort_desc: Ascending or descending """ # TODO: Preprocessing for joins # Try to handle it as a string if isinstance(sort_field, basestring): # Create automatic join against a table if column name # contains dot. if '.' in sort_field: parts = sort_field.split('.', 1) if parts[0] not in joins: query = query.join(parts[0]) joins.add(parts[0]) elif isinstance(sort_field, InstrumentedAttribute): # SQLAlchemy 0.8+ uses 'parent' as a name mapper = getattr(sort_field, 'parent', None) if mapper is None: # SQLAlchemy 0.7.x uses parententity mapper = getattr(sort_field, 'parententity', None) if mapper is not None: table = mapper.tables[0] if table.name not in joins: query = query.join(table) joins.add(table.name) elif isinstance(sort_field, Column): pass else: raise TypeError('Wrong argument type') if sort_field is not None: if sort_desc: query = query.order_by(desc(sort_field)) else: query = query.order_by(sort_field) return query, joins def get_list(self, page, sort_column, sort_desc, search, filters, execute=True): """ Return models from the database. :param page: Page number :param sort_column: Sort column name :param sort_desc: Descending or ascending sort :param search: Search query :param execute: Execute query immediately? Default is `True` :param filters: List of filter tuples """ # Will contain names of joined tables to avoid duplicate joins joins = set() query = self.get_query() count_query = self.get_count_query() # Apply search criteria if self._search_supported and search: # Apply search-related joins if self._search_joins: for jn in self._search_joins.values(): query = query.join(jn) count_query = count_query.join(jn) joins = set(self._search_joins.keys()) # Apply terms terms = search.split(' ') for term in terms: if not term: continue stmt = tools.parse_like_term(term) filter_stmt = [c.ilike(stmt) for c in self._search_fields] query = query.filter(or_(*filter_stmt)) count_query = count_query.filter(or_(*filter_stmt)) # Apply filters if filters and self._filters: for idx, value in filters: flt = self._filters[idx] # Figure out joins tbl = flt.column.table.name join_tables = self._filter_joins.get(tbl, []) for table in join_tables: if table.name not in joins: query = query.join(table) count_query = count_query.join(table) joins.add(table.name) # Apply filter query = flt.apply(query, value) count_query = flt.apply(count_query, value) # Calculate number of rows count = count_query.scalar() # Auto join for j in self._auto_joins: query = query.options(joinedload(j)) # Sorting if sort_column is not None: if sort_column in self._sortable_columns: sort_field = self._sortable_columns[sort_column] query, joins = self._order_by(query, joins, sort_field, sort_desc) else: order = get_default_order(self) if order: query, joins = self._order_by(query, joins, order[0], order[1]) # Pagination if page is not None: query = query.offset(page * self.page_size) query = query.limit(self.page_size) # Execute if needed if execute: query = query.all() return count, query def get_one(self, id): """ Return a single model by its id. :param id: Model id """ return self.session.query(self.model).get(id) # Model handlers def create_model(self, form): """ Create model from form. :param form: Form instance """ try: model = self.model() form.populate_obj(model) self.session.add(model) self.on_model_change(form, model) self.session.commit() except Exception, ex: flash(gettext('Failed to create model. %(error)s', error=str(ex)), 'error') logging.exception('Failed to create model') self.session.rollback() return False else:
class BaseModelView(BaseView, ActionsMixin): """ Base model view. View does not make any assumptions on how models are stored or managed, but expects following: 1. Model is an object 2. Model contains properties 3. Each model contains attribute which uniquely identifies it (i.e. primary key for database model) 4. You can get list of sorted models with pagination applied from a data source 5. You can get one model by its identifier from the data source Essentially, if you want to support new data store, all you have to do: 1. Derive from `BaseModelView` class 2. Implement various data-related methods (`get_list`, `get_one`, `create_model`, etc) 3. Implement automatic form generation from the model representation (`scaffold_form`) """ # Permissions can_create = True """Is model creation allowed""" can_edit = True """Is model editing allowed""" can_delete = True """Is model deletion allowed""" # Templates list_template = 'admin/model/list.html' """Default list view template""" edit_template = 'admin/model/edit.html' """Default edit template""" create_template = 'admin/model/create.html' """Default create template""" # Customizations column_list = ObsoleteAttr('column_list', 'list_columns', None) """ Collection of the model field names for the list view. If set to `None`, will get them from the model. For example:: class MyModelView(BaseModelView): column_list = ('name', 'last_name', 'email') """ column_exclude_list = ObsoleteAttr('column_exclude_list', 'excluded_list_columns', None) """ Collection of excluded list column names. For example:: class MyModelView(BaseModelView): column_exclude_list = ('last_name', 'email') """ column_formatters = ObsoleteAttr('column_formatters', 'list_formatters', dict()) """ Dictionary of list view column formatters. For example, if you want to show price multiplied by two, you can do something like this:: class MyModelView(BaseModelView): column_formatters = dict(price=lambda c, m, p: m.price*2) Callback function has following prototype:: def formatter(context, model, name): # context is instance of jinja2.runtime.Context # model is model instance # name is property name pass """ column_type_formatters = ObsoleteAttr('column_type_formatters', 'list_type_formatters', None) """ Dictionary of value type formatters to be used in list view. By default, two types are formatted: 1. ``None`` will be displayed as empty string 2. ``bool`` will be displayed as check if it is ``True`` If you don't like default behavior and don't want any type formatters applied, just override this property with empty dictionary:: class MyModelView(BaseModelView): column_type_formatters = dict() If you want to display `NULL` instead of empty string, you can do something like this:: from flask.ext.admin import typefmt MY_DEFAULT_FORMATTERS = dict(typefmt.BASE_FORMATTERS).extend({ type(None): typefmt.null_formatter }) class MyModelView(BaseModelView): column_type_formatters = MY_DEFAULT_FORMATTERS Type formatters have lower priority than list column formatters. """ column_labels = ObsoleteAttr('column_labels', 'rename_columns', None) """ Dictionary where key is column name and value is string to display. For example:: class MyModelView(BaseModelView): column_labels = dict(name='Name', last_name='Last Name') """ column_descriptions = None """ Dictionary where key is column name and value is description for `list view` column or add/edit form field. For example:: class MyModelView(BaseModelView): column_descriptions = dict( full_name='First and Last name' ) """ column_sortable_list = ObsoleteAttr('column_sortable_list', 'sortable_columns', None) """ Collection of the sortable columns for the list view. If set to `None`, will get them from the model. For example:: class MyModelView(BaseModelView): column_sortable_list = ('name', 'last_name') If you want to explicitly specify field/column to be used while sorting, you can use tuple:: class MyModelView(BaseModelView): column_sortable_list = ('name', ('user', 'user.username')) When using SQLAlchemy models, model attributes can be used instead of the string:: class MyModelView(BaseModelView): column_sortable_list = ('name', ('user', User.username)) """ column_searchable_list = ObsoleteAttr('column_searchable_list', 'searchable_columns', None) """ Collection of the searchable columns. It is assumed that only text-only fields are searchable, but it is up for a model implementation to make decision. Example:: class MyModelView(BaseModelView): column_searchable_list = ('name', 'email') """ column_filters = None """ Collection of the column filters. Can contain either field names or instances of :class:`~flask.ext.admin.model.filters.BaseFilter` classes. Example:: class MyModelView(BaseModelView): column_filters = ('user', 'email') """ column_display_pk = ObsoleteAttr('column_display_pk', 'list_display_pk', False) """ Controls if primary key should be displayed in list view. """ form = None """ Form class. Override if you want to use custom form for your model. For example:: class MyForm(wtf.Form): pass class MyModelView(BaseModelView): form = MyForm """ form_args = None """ Dictionary of form field arguments. Refer to WTForms documentation for list of possible options. Example:: class MyModelView(BaseModelView): form_args = dict( name=dict(label='First Name', validators=[wtf.required()]) ) """ form_columns = None """ Collection of the model field names for the form. If set to `None` will get them from the model. Example:: class MyModelView(BaseModelView): form_columns = ('name', 'email') """ form_excluded_columns = ObsoleteAttr('form_excluded_columns', 'excluded_form_columns', None) """ Collection of excluded form field names. For example:: class MyModelView(BaseModelView): form_excluded_columns = ('last_name', 'email') """ form_overrides = None """ Dictionary of form column overrides. Example:: class MyModelView(BaseModelView): form_overrides = dict(name=wtf.FileField) """ # Actions action_disallowed_list = ObsoleteAttr('action_disallowed_list', 'disallowed_actions', []) """ Set of disallowed action names. For example, if you want to disable mass model deletion, do something like this: class MyModelView(BaseModelView): action_disallowed_list = ['delete'] """ # Various settings page_size = 20 """ Default page size. """ def __init__(self, model, name=None, category=None, endpoint=None, url=None): """ Constructor. :param model: Model class :param name: View name. If not provided, will use model class name :param category: View category :param endpoint: Base endpoint. If not provided, will use model name + 'view'. For example if model name was 'User', endpoint will be 'userview' :param url: Base URL. If not provided, will use endpoint as a URL. """ # If name not provided, it is model name if name is None: name = '%s' % self._prettify_name(model.__name__) # If endpoint not provided, it is model name + 'view' if endpoint is None: endpoint = ('%sview' % model.__name__).lower() super(BaseModelView, self).__init__(name, category, endpoint, url) self.model = model # Actions self.init_actions() # Scaffolding self._refresh_cache() # Caching def _refresh_cache(self): """ Refresh various cached variables. """ # List view self._list_columns = self.get_list_columns() self._sortable_columns = self.get_sortable_columns() # Forms self._create_form_class = self.get_create_form() self._edit_form_class = self.get_edit_form() # Search self._search_supported = self.init_search() # Filters self._filters = self.get_filters() # Type formatters if self.column_type_formatters is None: self.column_type_formatters = dict(typefmt.BASE_FORMATTERS) if self.column_descriptions is None: self.column_descriptions = dict() if self._filters: self._filter_groups = [] self._filter_dict = dict() for i, n in enumerate(self._filters): if n.name not in self._filter_dict: group = [] self._filter_dict[n.name] = group self._filter_groups.append((n.name, group)) else: group = self._filter_dict[n.name] group.append((i, n.operation())) self._filter_types = dict((i, f.data_type) for i, f in enumerate(self._filters) if f.data_type) else: self._filter_groups = None self._filter_types = None # Primary key def get_pk_value(self, model): """ Return PK value from a model object. """ raise NotImplemented() # List view def scaffold_list_columns(self): """ Return list of the model field names. Must be implemented in the child class. Expected return format is list of tuples with field name and display text. For example:: ['name', 'first_name', 'last_name'] """ raise NotImplemented('Please implement scaffold_list_columns method') def get_column_name(self, field): """ Return human-readable column name. :param field: Model field name. """ if self.column_labels and field in self.column_labels: return self.column_labels[field] else: return self.prettify_name(field) def get_list_columns(self): """ Returns list of the model field names. If `column_list` was set, returns it. Otherwise calls `scaffold_list_columns` to generate list from the model. """ columns = self.column_list if columns is None: columns = self.scaffold_list_columns() # Filter excluded columns if self.column_exclude_list: columns = [c for c in columns if c not in self.column_exclude_list] return [(c, self.get_column_name(c)) for c in columns] def scaffold_sortable_columns(self): """ Returns dictionary of sortable columns. Must be implemented in the child class. Expected return format is dictionary, where key is field name and value is property name. """ raise NotImplemented('Please implement scaffold_sortable_columns method') def get_sortable_columns(self): """ Returns dictionary of the sortable columns. Key is a model field name and value is sort column (for example - attribute). If `column_sortable_list` is set, will use it. Otherwise, will call `scaffold_sortable_columns` to get them from the model. """ if self.column_sortable_list is None: return self.scaffold_sortable_columns() or dict() else: result = dict() for c in self.column_sortable_list: if isinstance(c, tuple): result[c[0]] = c[1] else: result[c] = c return result def init_search(self): """ Initialize search. If data provider does not support search, `init_search` will return `False`. """ return False def scaffold_filters(self, name): """ Generate filter object for the given name :param name: Name of the field """ return None def is_valid_filter(self, filter): """ Verify that provided filter object is valid. Override in model backend implementation to verify if provided filter type is allowed. :param filter: Filter object to verify. """ return isinstance(filter, filters.BaseFilter) def get_filters(self): """ Return list of filter objects. If your model backend implementation does not support filters, override this method and return `None`. """ if self.column_filters: collection = [] for n in self.column_filters: if not self.is_valid_filter(n): flt = self.scaffold_filters(n) if flt: collection.extend(flt) else: raise Exception('Unsupported filter type %s' % n) else: collection.append(n) return collection else: return None def scaffold_form(self): """ Create `form.BaseForm` inherited class from the model. Must be implemented in the child class. """ raise NotImplemented('Please implement scaffold_form method') def get_form(self): """ Get form class. If ``self.form`` is set, will return it and will call ``self.scaffold_form`` otherwise. Override to implement customized behavior. """ if self.form is not None: return self.form return self.scaffold_form() def get_create_form(self): """ Create form class for model creation view. Override to implement customized behavior. """ return self.get_form() def get_edit_form(self): """ Create form class for model editing view. Override to implement customized behavior. """ return self.get_form() def create_form(self, obj=None): """ Instantiate model creation form and return it. Override to implement custom behavior. """ return self._create_form_class(obj=obj) def edit_form(self, obj=None): """ Instantiate model editing form and return it. Override to implement custom behavior. """ return self._edit_form_class(obj=obj) # Helpers def is_sortable(self, name): """ Verify if column is sortable. :param name: Column name. """ return name in self._sortable_columns def _get_column_by_idx(self, idx): """ Return column index by """ if idx is None or idx < 0 or idx >= len(self._list_columns): return None return self._list_columns[idx] # Database-related API def get_list(self, page, sort_field, sort_desc, search, filters): """ Return list of models from the data source with applied pagination and sorting. Must be implemented in child class. :param page: Page number, 0 based. Can be set to None if it is first page. :param sort_field: Sort column name or None. :param sort_desc: If set to True, sorting is in descending order. :param search: Search query :param filters: List of filter tuples. First value in a tuple is a search index, second value is a search value. """ raise NotImplemented('Please implement get_list method') def get_one(self, id): """ Return one model by its id. Must be implemented in the child class. :param id: Model id """ raise NotImplemented('Please implement get_one method') # Model handlers def on_model_change(self, form, model): """ Allow to do some actions after a model was created or updated. Called from create_model and update_model in the same transaction (if it has any meaning for a store backend). By default do nothing. """ pass def on_model_delete(self, model): """ Allow to do some actions before a model will be deleted. Called from delete_model in the same transaction (if it has any meaning for a store backend). By default do nothing. """ pass def create_model(self, form): """ Create model from the form. Returns `True` if operation succeeded. Must be implemented in the child class. :param form: Form instance """ raise NotImplemented() def update_model(self, form, model): """ Update model from the form. Returns `True` if operation succeeded. Must be implemented in the child class. :param form: Form instance :param model: Model instance """ raise NotImplemented() def delete_model(self, model): """ Delete model. Returns `True` if operation succeeded. Must be implemented in the child class. :param model: Model instance """ raise NotImplemented() # Various helpers def prettify_name(self, name): """ Prettify pythonic variable name. For example, 'hello_world' will be converted to 'Hello World' :param name: Name to prettify """ return name.replace('_', ' ').title() # URL generation helper def _get_extra_args(self): """ Return arguments from query string. """ page = request.args.get('page', 0, type=int) sort = request.args.get('sort', None, type=int) sort_desc = request.args.get('desc', None, type=int) search = request.args.get('search', None) # Gather filters if self._filters: sfilters = [] for n in request.args: if n.startswith('flt'): ofs = n.find('_') if ofs == -1: continue try: pos = int(n[3:ofs]) idx = int(n[ofs + 1:]) except ValueError: continue if idx >= 0 and idx < len(self._filters): flt = self._filters[idx] value = request.args[n] if flt.validate(value): sfilters.append((pos, (idx, flt.clean(value)))) filters = [v[1] for v in sorted(sfilters, key=lambda n: n[0])] else: filters = None return page, sort, sort_desc, search, filters def _get_url(self, view=None, page=None, sort=None, sort_desc=None, search=None, filters=None): """ Generate page URL with current page, sort column and other parameters. :param view: View name :param page: Page number :param sort: Sort column index :param sort_desc: Use descending sorting order :param search: Search query :param filters: List of active filters """ if not search: search = None if not page: page = None kwargs = dict(page=page, sort=sort, desc=sort_desc, search=search) if filters: for i, flt in enumerate(filters): key = 'flt%d_%d' % (i, flt[0]) kwargs[key] = flt[1] return url_for(view, **kwargs) def is_action_allowed(self, name): """ Override this method to allow or disallow actions based on some condition. Default implementation only checks if particular action is not in `action_disallowed_list`. """ return name not in self.action_disallowed_list @contextfunction def get_list_value(self, context, model, name): """ Returns value to be displayed in list view :param context: :py:class:`jinja2.runtime.Context` :param model: Model instance :param name: Field name """ column_fmt = self.column_formatters.get(name) if column_fmt is not None: return column_fmt(context, model, name) value = rec_getattr(model, name) type_fmt = self.column_type_formatters.get(type(value)) if type_fmt is not None: value = type_fmt(value) return value # Views @expose('/') def index_view(self): """ List view """ # Grab parameters from URL page, sort_idx, sort_desc, search, filters = self._get_extra_args() # Map column index to column name sort_column = self._get_column_by_idx(sort_idx) if sort_column is not None: sort_column = sort_column[0] # Get count and data count, data = self.get_list(page, sort_column, sort_desc, search, filters) # Calculate number of pages num_pages = count / self.page_size if count % self.page_size != 0: num_pages += 1 # Pregenerate filters if self._filters: filters_data = dict() for idx, f in enumerate(self._filters): flt_data = f.get_options(self) if flt_data: filters_data[idx] = flt_data else: filters_data = None # Various URL generation helpers def pager_url(p): # Do not add page number if it is first page if p == 0: p = None return self._get_url('.index_view', p, sort_idx, sort_desc, search, filters) def sort_url(column, invert=False): desc = None if invert and not sort_desc: desc = 1 return self._get_url('.index_view', page, column, desc, search, filters) # Actions actions, actions_confirmation = self.get_actions_list() return self.render(self.list_template, data=data, # List list_columns=self._list_columns, sortable_columns=self._sortable_columns, # Stuff enumerate=enumerate, get_pk_value=self.get_pk_value, get_value=self.get_list_value, return_url=self._get_url('.index_view', page, sort_idx, sort_desc, search, filters), # Pagination count=count, pager_url=pager_url, num_pages=num_pages, page=page, # Sorting sort_column=sort_idx, sort_desc=sort_desc, sort_url=sort_url, # Search search_supported=self._search_supported, clear_search_url=self._get_url('.index_view', None, sort_idx, sort_desc), search=search, # Filters filters=self._filters, filter_groups=self._filter_groups, filter_types=self._filter_types, filter_data=filters_data, active_filters=filters, # Actions actions=actions, actions_confirmation=actions_confirmation ) @expose('/new/', methods=('GET', 'POST')) def create_view(self): """ Create model view """ return_url = request.args.get('url') or url_for('.index_view') if not self.can_create: return redirect(return_url) form = self.create_form() if form.validate_on_submit(): if self.create_model(form): if '_add_another' in request.form: flash(gettext('Model was successfully created.')) return redirect(url_for('.create_view', url=return_url)) else: return redirect(return_url) return self.render(self.create_template, form=form, return_url=return_url) @expose('/edit/', methods=('GET', 'POST')) def edit_view(self): """ Edit model view """ return_url = request.args.get('url') or url_for('.index_view') if not self.can_edit: return redirect(return_url) id = request.args.get('id') if id is None: return redirect(return_url) model = self.get_one(id) if model is None: return redirect(return_url) form = self.edit_form(obj=model) if form.validate_on_submit(): if self.update_model(form, model): return redirect(return_url) return self.render(self.edit_template, form=form, return_url=return_url) @expose('/delete/', methods=('POST',)) def delete_view(self): """ Delete model view. Only POST method is allowed. """ return_url = request.args.get('url') or url_for('.index_view') # TODO: Use post if not self.can_delete: return redirect(return_url) id = request.args.get('id') if id is None: return redirect(return_url) model = self.get_one(id) if model: self.delete_model(model) return redirect(return_url) @expose('/action/', methods=('POST',)) def action_view(self): """ Mass-model action view. """ return self.handle_action()