Commit 4f019346 authored by Serge S. Koval's avatar Serge S. Koval

Support for sortable columns, FK now sortable, some docs.

parent a90f1514
- Core
- Pregenerate URLs for menu
- Page titles
- Create base model-admin class and derive SQLA admin from it
- Override base URL (/admin/)
- Model admin
- Ability to override sortable fields
- SQLA Model Admin
- Sort by foreign key
- Validation of the joins in the query
- Automatic joined load for foreign keys
- Automatic PK detection
- Ability to override displayed form fields
- Ability to rename list columns
- Ability to change form without messing with form creation
......
......@@ -41,12 +41,16 @@ class Post(db.Model):
# Flask routes
@app.route('/')
def index():
db.session.query(Post).join(User).order_by(User.username).all()
return '<a href="/admin/">Click me to get to Admin!</a>'
class PostAdmin(sqlamodel.ModelView):
list_columns = ('title', 'user')
sortable_columns = dict(title='title', user=User.username)
def __init__(self, session):
super(PostAdmin, self).__init__(Post, session)
......
......@@ -5,6 +5,13 @@ from flask import Blueprint, render_template, url_for, abort
def expose(url='/', methods=('GET',)):
"""
Use this decorator to expose views in your view classes.
`url`
Relative URL for the view
`methods`
Allowed HTTP methods. By default only GET is allowed.
"""
def wrap(f):
if not hasattr(f, '_urls'):
f._urls = []
......@@ -29,6 +36,12 @@ def _wrap_view(f):
class AdminViewMeta(type):
"""
View metaclass.
Does some precalculations (like getting list of view methods from the class) to avoid
calculating them for each view class instance.
"""
def __init__(cls, classname, bases, fields):
type.__init__(cls, classname, bases, fields)
......@@ -56,9 +69,35 @@ class AdminViewMeta(type):
class BaseView(object):
"""
Base administrative view.
Derive from this class to implement your administrative interface piece. For example::
class MyView(BaseView):
@expose('/')
def index(self):
return 'Hello World!'
"""
__metaclass__ = AdminViewMeta
def __init__(self, name=None, category=None, endpoint=None, url=None, static_folder=None):
"""
Constructor.
`name`
Name of this view. If not provided, will be defaulted to the class name.
`category`
View category. If not provided, will be shown as a top-level menu item. Otherwise, will
be in a submenu.
`endpoint`
Base endpoint name for the view. For example, if there's view method called "index" and
endpoint was set to "myadmin", you can use `url_for('myadmin.index')` to get URL to the
view method. By default, equals to the class name in lower case.
`url`
Base URL. If provided, affects how URLs are generated. For example, if url parameter
equals to "test", resulting URL will look like "/admin/test/". If not provided, will
use endpoint as a base url.
"""
self.name = name
self.category = category
self.endpoint = endpoint
......@@ -70,9 +109,15 @@ class BaseView(object):
self._create_blueprint()
def _set_admin(self, admin):
"""
Associate this view with Admin class instance.
"""
self.admin = admin
def _create_blueprint(self):
"""
Create Flask blueprint.
"""
# If endpoint name is not provided, get it from the class name
if self.endpoint is None:
self.endpoint = self.__class__.__name__.lower()
......@@ -98,9 +143,20 @@ class BaseView(object):
methods=methods)
def _prettify_name(self, name):
"""
Prettify class name by splitting name by capital characters. So, 'MySuperClass' will look like 'My Super Class'
"""
return sub(r'(?<=.)([A-Z])', r' \1', name)
def is_accessible(self):
"""
Override this method to add permission checks.
Flask-AdminEx does not make any assumptions about authentication system used in your application, so it is
up for you to implement it.
By default, it will allow access for the everyone.
"""
return True
def _handle_view(self, name, **kwargs):
......@@ -109,6 +165,18 @@ class BaseView(object):
class AdminIndexView(BaseView):
"""
Administrative interface entry page. You can see it by going to the /admin/ URL.
You can override this page by passing your own view class to the `Admin` constructor::
class MyHomeView(AdminIndexView):
@expose('/')
def index(self):
return render_template('adminhome.html')
admin = Admin(index_view=MyHomeView)
"""
def __init__(self, name=None, category=None, endpoint=None, url=None):
super(AdminIndexView, self).__init__(name or 'Home', category, endpoint or 'admin', url or '/admin', 'static')
......@@ -161,7 +229,18 @@ class MenuItem(object):
class Admin(object):
"""
Collection of the views. Also manages menu structure.
"""
def __init__(self, name=None, index_view=None):
"""
Constructor.
`name`
Application name. Will be displayed in main menu and as a page title. If not provided, defaulted to "Flask"
`index_view`
Home page view to use. If not provided, will use `AdminIndexView`.
"""
self._views = []
self._menu = []
......@@ -176,10 +255,22 @@ class Admin(object):
self.add_view(index_view)
def add_view(self, view):
"""
Add view to the collection.
`view`
View to add.
"""
view._set_admin(self)
self._views.append(view)
def apply(self, app):
"""
Register all views with Flask application.
`app`
Flask application instance
"""
self.app = app
for v in self._views:
......@@ -206,4 +297,7 @@ class Admin(object):
category.add_child(MenuItem(v.name, v))
def menu(self):
"""
Return menu hierarchy.
"""
return self._menu
from sqlalchemy.orm.properties import RelationshipProperty, ColumnProperty
from sqlalchemy.orm.interfaces import MANYTOONE
from sqlalchemy.orm.attributes import InstrumentedAttribute
from sqlalchemy.sql.expression import desc
from wtforms.ext.sqlalchemy.orm import model_form, ModelConverter
......@@ -42,41 +43,64 @@ class AdminModelConverter(ModelConverter):
if column.foreign_keys or column.primary_key:
return None
return super(AdminModelConverter, self).convert(model, mapper, prop, field_args)
return super(AdminModelConverter, self).convert(model, mapper,
prop, field_args)
class ModelView(BaseModelView):
def __init__(self, model, session, name=None, category=None, endpoint=None, url=None):
def __init__(self, model, session,
name=None, category=None, endpoint=None, url=None):
self.session = session
super(ModelView, self).__init__(model, name, category, endpoint, url)
# Public API
def get_list_columns(self):
columns = self.list_columns
def scaffold_list_columns(self):
columns = []
if columns is None:
mapper = self.model._sa_class_manager.mapper
mapper = self.model._sa_class_manager.mapper
columns = []
for p in mapper.iterate_properties:
if isinstance(p, RelationshipProperty):
if p.direction is MANYTOONE:
columns.append(p.key)
elif isinstance(p, ColumnProperty):
# TODO: Check for multiple columns
column = p.columns[0]
for p in mapper.iterate_properties:
if isinstance(p, RelationshipProperty):
if p.direction is MANYTOONE:
columns.append(p.key)
elif isinstance(p, ColumnProperty):
# TODO: Check for multiple columns
column = p.columns[0]
if column.foreign_keys or column.primary_key:
continue
if column.foreign_keys or column.primary_key:
continue
columns.append((p.key, self.prettify_name(p.key)))
columns.append(p.key)
return columns
return [(c, self.prettify_name(c)) for c in columns]
def scaffold_sortable_columns(self):
columns = dict()
mapper = self.model._sa_class_manager.mapper
for p in mapper.iterate_properties:
if isinstance(p, RelationshipProperty):
if p.direction is MANYTOONE:
# TODO: Detect PK
columns[p.key] = '%s.id' % p.target.name
elif isinstance(p, ColumnProperty):
# TODO: Check for multiple columns
column = p.columns[0]
if column.foreign_keys or column.primary_key:
continue
columns[p.key] = p.key
return columns
def scaffold_form(self):
return model_form(self.model, wtf.Form, self.form_columns, converter=AdminModelConverter(self.session))
return model_form(self.model,
wtf.Form,
self.form_columns,
converter=AdminModelConverter(self.session))
# Database-related API
def get_list(self, page, sort_column, sort_desc):
......@@ -84,19 +108,34 @@ class ModelView(BaseModelView):
count = query.count()
if sort_column is not None:
# Validate first
if sort_column >= 0 and sort_column < len(self.list_columns):
if sort_desc:
query = query.order_by(desc(self.list_columns[sort_column][0]))
# Sorting
column = self._get_column_by_idx(sort_column)
if column is not None:
name = column[0]
if name in self._sortable_columns:
sort_field = self._sortable_columns[name]
# Try to handle it as a string
if isinstance(sort_field, basestring):
# Create automatic join if string contains dot
if '.' in sort_field:
parts = sort_field.split('.', 1)
query = query.join(parts[0])
elif isinstance(sort_field, InstrumentedAttribute):
query = query.join(sort_field.parententity)
else:
query = query.order_by(self.list_columns[sort_column][0])
sort_field = None
if page is not None:
if page < 1:
page = 1
if sort_field is not None:
if sort_desc:
query = query.order_by(desc(sort_field))
else:
query = query.order_by(sort_field)
query = query.offset((page - 1) * self.page_size)
# Pagination
if page is not None:
query = query.offset(page * self.page_size)
query = query.limit(self.page_size)
......
......@@ -4,29 +4,115 @@ from .base import BaseView, expose
class BaseModelView(BaseView):
"""
Base model 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 'id' attribute which uniquely identifies it (TBD: Make it more flexible)
4. You can get list of sorted models with pagination applied from a data source
5. You can get one model by its 'id' 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`, etc)
"""
# 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/edit.html'
"""Default create template"""
# Customizations
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):
list_columns = ('name', 'last_name', 'email')
If you want to rename column, use tuple instead of the name,
where first value is field name and second is display name.
You can also mix these values::
class MyModelView(BaseModelView):
list_columns = (('name', 'First Name'),
('last_name', 'Family Name'),
'email')
"""
sortable_columns = None
"""
Dictionary of the sortable columns names and property references.
If set to `None`, will get them from the model.
For example::
class MyModelView(BaseModelView):
sortable_columns = dict(name='name', user='user.id')
"""
form_columns = None
"""
Collection of the model field names for the form. If set to `None` will
get them from the model.
For example:
class MyModelView(BaseModelView):
list_columns = ('name', 'email')
"""
# Various settings
page_size = 20
def __init__(self, model, name=None, category=None, endpoint=None, url=None):
# If name not provided, it is modelname
"""
Page size. You can change it to something you want.
"""
def __init__(self, model,
name=None, category=None, endpoint=None, url=None):
"""
Constructor.
`model`
Model class
`name`
View name. If not provided, will use model class name
`category`
View category
`endpoint`
Base endpoint. If not provided, will use model name + 'view'.
For example if model name was 'User', endpoint will be
'userview'
`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 modelname + 'view'
# If endpoint not provided, it is model name + 'view'
if endpoint is None:
endpoint = ('%sview' % model.__name__).lower()
......@@ -35,26 +121,134 @@ class BaseModelView(BaseView):
self.model = model
# Scaffolding
self.list_columns = self.get_list_columns()
self._list_columns = self.get_list_columns()
self._sortable_columns = self.get_sortable_columns()
self.create_form = self.scaffold_create_form()
self.edit_form = self.scaffold_edit_form()
self._create_form_class = self.scaffold_create_form()
self._edit_form_class = self.scaffold_edit_form()
# Public API
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', 'Name'),
('email', 'Email'),
('last_name', 'Last Name')]
"""
raise NotImplemented('Please implement scaffold_list_columns method')
def get_list_columns(self):
raise NotImplemented('Please implement get_list_columns method')
"""
Returns list of the model field names. If `list_columns` was
set, returns it. Otherwise calls `scaffold_list_columns`
to generate list from the model.
"""
if self.list_columns is None:
columns = self.scaffold_list_columns()
else:
columns = []
for c in self.list_columns:
if not isinstance(c, tuple):
columns.append((c, self.prettify_name(c)))
else:
columns.append(c)
return 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 `sortable_columns` is set, will use it. Otherwise, will call
`scaffold_sortable_columns` to get them from the model.
"""
if self.sortable_columns is None:
print self.__class__.__name__
return self.scaffold_sortable_columns()
else:
return self.sortable_columns
def scaffold_form(self):
"""
Create WTForm class from the model. Must be implemented in
the child class.
"""
raise NotImplemented('Please implement scaffold_form method')
def scaffold_create_form(self):
"""
Create form class for model creation view.
Override to implement customized behavior.
"""
return self.scaffold_form()
def scaffold_edit_form(self):
"""
Create form class for model editing view.
Override to implement customized behavior.
"""
return self.scaffold_form()
def create_form(self, form, obj=None):
"""
Instantiate model creation form and return it.
Override to implement custom behavior.
"""
return self._create_form_class(form, obj)
def edit_form(self, form, obj=None):
"""
Instantiate model editing form and return it.
Override to implement custom behavior.
"""
return self._edit_form_class(form, obj)
# Helpers
def is_sortable(self, name):
return name in self._sortable_columns
def _get_column_by_idx(self, idx):
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):
"""
Return list of models from the data source with applied pagination
and sorting.
Must be implemented in child class.
`page`
Page number, 0 based. Can be set to None if it is first page.
`sort_field`
Sort field index in the `self.list_columns` or None.
`sort_desc`
If set to True, sorting is in descending order.
"""
raise NotImplemented('Please implement get_list method')
def get_one(self, id):
......@@ -76,7 +270,7 @@ class BaseModelView(BaseView):
# URL generation helper
def _get_extra_args(self):
page = request.args.get('page', None, type=int)
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)
......@@ -101,6 +295,10 @@ class BaseModelView(BaseView):
# 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, sort_desc)
def sort_url(column, invert=False):
......@@ -111,10 +309,17 @@ class BaseModelView(BaseView):
return self._get_url('.index_view', page, column, desc)
def get_value(obj, field):
return getattr(obj, field, None)
return render_template(self.list_template,
view=self,
data=data,
# Return URL
# List
list_columns=self._list_columns,
sortable_columns=self._sortable_columns,
# Stuff
get_value=get_value,
return_url=self._get_url('.index_view', page, sort, sort_desc),
# Pagination
pager_url=pager_url,
......
......@@ -2,58 +2,70 @@
{% if pages > 1 %}
<div class="pagination">
<ul>
{% if not page %}
{% set page = 1 %}
{% endif %}
{% set min = page - 3 %}
{% set max = page + 3 + 1 %}
{% if min < 0 %}
{% set max = max - min + 1 %}
{% set max = max - min %}
{% endif %}
{% if max > pages %}
{% set min = min + pages - max - 1 %}
{% if max >= pages %}
{% set min = min - max + pages %}
{% endif %}
{% if min < 1 %}
{% set min = 1 %}
{% if min < 0 %}
{% set min = 0 %}
{% endif %}
{% if max > pages + 1 %}
{% set max = pages + 1 %}
{% if max >= pages %}
{% set max = pages %}
{% endif %}
{% if min > 1 %}
{% if min > 0 %}
<li>
<a href="{{ generator(1) }}">&lt;&lt;</a>
<a href="{{ generator(0) }}">&laquo;</a>
</li>
{% else %}
<li class="disabled">
<a href="#">&laquo;</a>
</li>
{% endif %}
{% if page > 1 %}
{% if page > 0 %}
<li>
<a href="{{ generator(page-1) }}">&lt;</a>
</li>
{% else %}
<li class="disabled">
<a href="#">&lt;</a>
</li>
{% endif %}
{% for p in range(min, max) %}
{% if page == p %}
<li class="disabled">
<a href="#">{{ p }}</a>
<li class="active">
<a href="#">{{ p + 1 }}</a>
</li>
{% else %}
<li>
<a href="{{ generator(p) }}">{{ p }}</a>
<a href="{{ generator(p) }}">{{ p + 1 }}</a>
</li>
{% endif %}
{% endfor %}
{% if page+1 <= pages %}
{% if page + 1 < pages %}
<li>
<a href="{{ generator(page+1) }}">&gt;</a>
<a href="{{ generator(page + 1) }}">&gt;</a>
</li>
{% else %}
<li class="disabled">
<a href="#">&gt;</a>
</li>
{% endif %}
{% if max <= pages %}
{% if max < pages %}
<li>
<a href="{{ generator(pages) }}">&gt;&gt;</a>
<a href="{{ generator(pages - 1) }}">&raquo;</a>
</li>
{% else %}
<li class="disabled">
<a href="#">&raquo;</a>
</li>
{% endif %}
</ul>
......
......@@ -7,7 +7,7 @@
<tr>
<th class="span1">&nbsp;</th>
{% set column = 0 %}
{% for c, name in view.list_columns %}
{% for c, name in list_columns %}
<th>
{% if sort_column == column %}
<a href="{{ sort_url(column, True) }}">
......@@ -40,14 +40,14 @@
</a>
{%- endif -%}
</td>
{% for c, name in view.list_columns %}
<td>{{ row[c] }}</td>
{% for c, name in list_columns %}
<td>{{ get_value(row, c) }}</td>
{% endfor %}
</tr>
{% endfor %}
</table>
{{ lib.pager(page, num_pages, pager_url) }}
{% if view.can_create %}
<a class="btn btn-primary btn-large" href="{{ url_for('.create_view') }}">Create New</a>
<a class="btn btn-primary btn-large" href="{{ url_for('.create_view', return=return_url) }}">Create New</a>
{% endif %}
{% endblock %}
import sys, traceback
def import_module(name, required=True):
try:
__import__(name, globals(), locals(), [])
except ImportError:
if not required and module_not_found():
return None
raise
return sys.modules[name]
def import_attribute(name):
"""
Import attribute using string reference.
Example:
import_attribute('a.b.c.foo')
Throws ImportError or AttributeError if module or attribute do not exist.
"""
path, attr = name.rsplit('.', 1)
module = __import__(path, globals(), locals(), [attr])
return getattr(module, attr)
def module_not_found(additional_depth=0):
'''Checks if ImportError was raised because module does not exist or
something inside it raised ImportError
- additional_depth - supply int of depth of your call if you're not doing
import on the same level of code - f.e., if you call function, which is
doing import, you should pass 1 for single additional level of depth
'''
tb = sys.exc_info()[2]
if len(traceback.extract_tb(tb)) > (1 + additional_depth):
return False
return True
def rec_getattr(obj, attr, default=None):
try:
return reduce(getattr, attr.split('.'), obj)
except AttributeError:
return None
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment