Commit cade92de authored by Serge S. Koval's avatar Serge S. Koval

Added inline sqlalchemy model admin (WIP)

parent e81c1312
......@@ -28,6 +28,18 @@ class User(db.Model):
return self.username
class UserInfo(db.Model):
id = db.Column(db.Integer, primary_key=True)
key = db.Column(db.String(64))
value = db.Column(db.String(64))
user_id = db.Column(db.Integer(), db.ForeignKey(User.id))
user = db.relationship(User, backref='info')
def __unicode__(self):
return '%s - %s' % (self.key, self.value)
# Create M2M table
post_tags_table = db.Table('post_tags', db.Model.metadata,
db.Column('post_id', db.Integer, db.ForeignKey('post.id')),
......@@ -64,6 +76,11 @@ def index():
return '<a href="/admin/">Click me to get to Admin!</a>'
# Customized User model admin
class UserAdmin(sqlamodel.ModelView):
inline_models = ('info',)
# Customized Post model admin
class PostAdmin(sqlamodel.ModelView):
# Visible columns in the list view
......@@ -100,7 +117,7 @@ if __name__ == '__main__':
admin = admin.Admin(app, 'Simple Models')
# Add views
admin.add_view(sqlamodel.ModelView(User, db.session))
admin.add_view(UserAdmin(User, db.session))
admin.add_view(sqlamodel.ModelView(Tag, db.session))
admin.add_view(PostAdmin(db.session))
......
......@@ -4,9 +4,14 @@
import operator
from wtforms import widgets
from wtforms.fields import SelectFieldBase
from wtforms.fields import SelectFieldBase, FormField, FieldList
from wtforms.validators import ValidationError
from .tools import get_primary_key
from flask.ext.admin.model.widgets import InlineFormListWidget
from flask import request
try:
from sqlalchemy.orm.util import identity_key
has_identity_key = True
......@@ -176,6 +181,79 @@ class QuerySelectMultipleField(QuerySelectField):
raise ValidationError(self.gettext('Not a valid choice'))
class InlineModelFormField(FormField):
def __init__(self, form, model, **kwargs):
super(InlineModelFormField, self).__init__(form, **kwargs)
self.model = model
self._pk = get_primary_key(model)
self._should_delete = False
def process(self, formdata, data=None):
super(InlineModelFormField, self).process(formdata, data)
# Grab delete key
key = 'del-%s' % self.id
if key in request.form:
self._should_delete = True
def should_delete(self):
return self._should_delete
def get_pk(self):
return getattr(self.form, self._pk).data
def populate_obj(self, obj, name):
for name, field in self.form._fields.iteritems():
if name != self._pk:
field.populate_obj(obj, name)
class InlineModelFormList(FieldList):
widget = InlineFormListWidget()
def __init__(self, form, session, model, **kwargs):
self.form = form
self.session = session
self.model = model
self._pk = get_primary_key(model)
super(InlineModelFormList, self).__init__(InlineModelFormField(form, model), **kwargs)
def __call__(self, **kwargs):
return self.widget(self, template=self.form(), **kwargs)
def populate_obj(self, obj, name):
values = getattr(obj, name, None)
if values is None:
return
# Create primary key map
pk_map = dict((str(getattr(v, self._pk)), v) for v in values)
# Create fake object to work around wtforms limitations
for field in self.entries:
field_id = field.get_pk()
if field_id in pk_map:
model = pk_map[field_id]
if field.should_delete():
self.session.delete(model)
continue
else:
model = self.model()
values.append(model)
field.populate_obj(model, None)
# Force relation
model.user = obj
def get_pk_from_identity(obj):
cls, key = identity_key(instance=obj)
return u':'.join(unicode(x) for x in key)
from wtforms import fields, validators
from flask.ext.admin import form
from flask.ext.admin.model.form import converts, ModelConverterBase
from flask.ext.admin.model.form import converts, ModelConverterBase, InlineFormAdmin
from .validators import Unique
from .fields import QuerySelectField, QuerySelectMultipleField
from .fields import QuerySelectField, QuerySelectMultipleField, InlineModelFormList
class AdminModelConverter(ModelConverterBase):
"""
SQLAlchemy model to form converter
"""
def __init__(self, view):
def __init__(self, session, view):
super(AdminModelConverter, self).__init__()
self.session = session
self.view = view
def _get_label(self, name, field_args):
if 'label' in field_args:
return field_args['label']
if self.view.rename_columns:
return self.view.rename_columns.get(name)
rename_columns = getattr(self.view, 'rename_columns', None)
if rename_columns:
return rename_columns.get(name)
return None
def _get_field_override(self, name):
if self.view.form_overrides:
return self.view.form_overrides.get(name)
form_overrides = getattr(self.view, 'form_overrides', None)
if form_overrides:
return form_overrides.get(name)
return None
def convert(self, model, mapper, prop, field_args):
def convert(self, model, mapper, prop, field_args, hidden_pk):
kwargs = {
'validators': [],
'filters': []
......@@ -48,7 +53,7 @@ class AdminModelConverter(ModelConverterBase):
kwargs.update({
'allow_blank': local_column.nullable,
'label': self._get_label(prop.key, kwargs),
'query_factory': lambda: self.view.session.query(remote_model)
'query_factory': lambda: self.session.query(remote_model)
})
if local_column.nullable:
......@@ -66,7 +71,7 @@ class AdminModelConverter(ModelConverterBase):
**kwargs)
elif prop.direction.name == 'ONETOMANY':
# Skip backrefs
if not local_column.foreign_keys and self.view.hide_backrefs:
if not local_column.foreign_keys and getattr(self.view, 'hide_backrefs', False):
return None
return QuerySelectMultipleField(
......@@ -93,22 +98,28 @@ class AdminModelConverter(ModelConverterBase):
unique = False
if column.primary_key:
# By default, don't show primary keys either
if self.view.form_columns is None:
return None
if hidden_pk:
# If requested to add hidden field, show it
return fields.HiddenField()
else:
# By default, don't show primary keys either
form_columns = getattr(self.view, 'form_columns', None)
# If PK is not explicitly allowed, ignore it
if prop.key not in self.view.form_columns:
return None
if form_columns is None:
return None
kwargs['validators'].append(Unique(self.view.session,
model,
column))
unique = True
# If PK is not explicitly allowed, ignore it
if prop.key not in form_columns:
return None
kwargs['validators'].append(Unique(self.session,
model,
column))
unique = True
# If field is unique, validate it
if column.unique and not unique:
kwargs['validators'].append(Unique(self.view.session,
kwargs['validators'].append(Unique(self.session,
model,
column))
......@@ -221,27 +232,86 @@ class AdminModelConverter(ModelConverterBase):
field_args['validators'].append(validators.UUID())
return fields.TextField(**field_args)
# Get list of fields and generate form
def get_form(self, model, base_class=form.BaseForm,
only=None, exclude=None,
field_args=None):
# TODO: Support new 0.8 API
if not hasattr(model, '_sa_class_manager'):
raise TypeError('model must be a sqlalchemy mapped model')
mapper = model._sa_class_manager.mapper
field_args = field_args or {}
properties = ((p.key, p) for p in mapper.iterate_properties)
if only:
properties = (x for x in properties if x[0] in only)
elif exclude:
properties = (x for x in properties if x[0] not in exclude)
field_dict = {}
for name, prop in properties:
field = self.convert(model, mapper, prop, field_args.get(name))
if field is not None:
field_dict[name] = field
return type(model.__name__ + 'Form', (base_class, ), field_dict)
# Get list of fields and generate form
def get_form(model, converter,
base_class=form.BaseForm,
only=None, exclude=None,
field_args=None,
hidden_pk=False):
# TODO: Support new 0.8 API
if not hasattr(model, '_sa_class_manager'):
raise TypeError('model must be a sqlalchemy mapped model')
mapper = model._sa_class_manager.mapper
field_args = field_args or {}
properties = ((p.key, p) for p in mapper.iterate_properties)
if only:
properties = (x for x in properties if x[0] in only)
elif exclude:
properties = (x for x in properties if x[0] not in exclude)
field_dict = {}
for name, prop in properties:
field = converter.convert(model, mapper, prop, field_args.get(name), hidden_pk)
if field is not None:
field_dict[name] = field
return type(model.__name__ + 'Form', (base_class, ), field_dict)
def contribute_inline(session, model, form_class, inline_models):
# Get mapper
mapper = model._sa_class_manager.mapper
# Contribute columns
for p in inline_models:
# Figure out
if isinstance(p, basestring):
info = InlineFormAdmin(p)
elif isinstance(p, tuple):
info = InlineFormAdmin(p[0], **p[1])
elif isinstance(p, InlineFormAdmin):
info = p
else:
raise Exception('Unknown inline model admin: %s' % repr(p))
prop = mapper.get_property(info.field)
if prop is None:
raise Exception('Inline form property %s.%s was not found' % (model.__name__,
info.field))
if not hasattr(prop, 'direction'):
raise Exception('Failed to convert inline admin %s - only one-to-many relations are supported' % info.field)
if prop.direction.name != 'ONETOMANY':
raise Exception('Failed to convert inline admin %s - only one-to-many relations are supported' % info.field)
# Find reverse relationship (to exlude from the list)
ignore = []
for remote_prop in prop.mapper.iterate_properties:
if hasattr(remote_prop, 'direction') and remote_prop.direction.name == 'MANYTOONE':
if remote_prop.mapper.class_ == prop.parent.class_:
ignore.append(remote_prop.key)
print remote_prop.key
if info.exclude:
exclude = ignore + info.exclude
else:
exclude = ignore
# Create field
remote_model = prop.mapper.class_
converter = AdminModelConverter(session, info)
child_form = get_form(remote_model, converter,
only=info.include,
exclude=exclude,
hidden_pk=True)
setattr(form_class, p, InlineModelFormList(child_form, session, remote_model))
return form_class
......@@ -7,3 +7,18 @@ def parse_like_term(term):
stmt = '%%%s%%' % term
return stmt
def get_primary_key(model):
"""
Return primary key name from a model
"""
props = model._sa_class_manager.mapper.iterate_properties
for p in props:
if hasattr(p, 'columns'):
for c in p.columns:
if c.primary_key:
return p.key
return None
......@@ -124,6 +124,17 @@ class ModelView(BaseModelView):
for your model.
"""
inline_models = None
"""
Inline related-model editing for parent to child relation.
If you have child relation with name 'posts', you can generate inline
administration interface by using this code::
class MyModelView(BaseModelView):
inline_models = ('posts',)
"""
def __init__(self, model, session,
name=None, category=None, endpoint=None, url=None):
"""
......@@ -178,13 +189,7 @@ class ModelView(BaseModelView):
"""
Return primary key name from a model
"""
for p in self._get_model_iterator():
if hasattr(p, 'columns'):
for c in p.columns:
if c.primary_key:
return p.key
return None
return tools.get_primary_key(self.model)
def get_pk_value(self, model):
"""
......@@ -370,12 +375,18 @@ class ModelView(BaseModelView):
"""
Create form from the model.
"""
converter = form.AdminModelConverter(self)
return converter.get_form(self.model,
converter = form.AdminModelConverter(self.session, self)
form_class = form.get_form(self.model, converter,
only=self.form_columns,
exclude=self.excluded_form_columns,
field_args=self.form_args)
if self.inline_models:
form_class = form.contribute_inline(self.session, self.model,
form_class, self.inline_models)
return form_class
def scaffold_auto_joins(self):
"""
Return list of joined tables by going through the
......
from .base import BaseModelView
from .form import InlineFormAdmin
from flask.ext.admin.actions import action
......@@ -10,6 +10,18 @@ def converts(*args):
return _inner
class InlineFormAdmin(object):
def __init__(self, field, **kwargs):
self.field = field
defaults = dict(include=None,
exclude=None)
defaults.update(kwargs)
for k, v in defaults.iteritems():
setattr(self, k, v)
class ModelConverterBase(object):
def __init__(self, converters=None, use_mro=True):
self.use_mro = use_mro
......
from flask.globals import _request_ctx_stack
class RenderTemplateWidget(object):
def __init__(self, template):
self.template = template
def __call__(self, field, **kwargs):
ctx = _request_ctx_stack.top
jinja_env = ctx.app.jinja_env
print kwargs
kwargs['field'] = field
template = jinja_env.get_template(self.template)
return template.render(kwargs)
class InlineFormListWidget(RenderTemplateWidget):
def __init__(self):
super(InlineFormListWidget, self).__init__('admin/model/inline_form_list.html')
var AdminFilters = function(element, filters_element, adminForm, operations, options, types) {
var AdminFilters = function(element, filters_element, operations, options, types) {
var $root = $(element);
var $container = $('.filters', $root);
var lastCount = 0;
......@@ -68,7 +68,7 @@ var AdminFilters = function(element, filters_element, adminForm, operations, opt
if (optId in types) {
$field.attr('data-role', types[optId]);
adminForm.applyStyle($field, types[optId]);
faForm.applyStyle($field, types[optId]);
}
lastCount += 1;
......
var AdminForm = function() {
this.applyStyle = function(el, name) {
switch (name) {
case 'chosen':
$(el).chosen();
break;
case 'chosenblank':
$(el).chosen({allow_single_deselect: true});
break;
case 'datepicker':
$(el).datepicker();
break;
case 'datetimepicker':
$(el).datepicker({displayTime: true});
break;
(function() {
var AdminForm = function() {
this.applyStyle = function(el, name) {
switch (name) {
case 'chosen':
$(el).chosen();
break;
case 'chosenblank':
$(el).chosen({allow_single_deselect: true});
break;
case 'datepicker':
$(el).datepicker();
break;
case 'datetimepicker':
$(el).datepicker({displayTime: true});
break;
}
};
this.addInlineModel = function(id, el, template) {
var $el = $(el);
var $template = $(template);
// Figure out new form ID
var lastForm = $el.children('.fa-inline-form').last();
var prefix = id + '-0';
if (lastForm.length > 0) {
var parts = $(lastForm[0]).attr('id').split('-');
idx = parseInt(parts[parts.length - 1]) + 1;
prefix = id + '-' + idx;
}
// Set form ID
$template.attr('id', prefix);
// Fix form IDs
$('[name]', $template).each(function(e) {
var me = $(this);
me.attr('id', prefix + '-' + me.attr('id'));
me.attr('name', prefix + '-' + me.attr('name'));
});
$template.appendTo($el);
// Apply styles
this.applyGlobalStyles($template);
};
this.applyGlobalStyles = function(parent) {
$('[data-role=chosen]', parent).chosen();
$('[data-role=chosenblank]', parent).chosen({allow_single_deselect: true});
$('[data-role=datepicker]', parent).datepicker();
$('[data-role=datetimepicker]', parent).datepicker({displayTime: true});
};
};
}
};
// Apply automatic styles
$('[data-role=chosen]').chosen();
$('[data-role=chosenblank]').chosen({allow_single_deselect: true});
$('[data-role=datepicker]').datepicker();
$('[data-role=datetimepicker]').datepicker({displayTime: true});
// Add live event handler
$('.fa-remove-form').live('click', function(e) {
e.preventDefault();
var form = $(this).closest('.fa-inline-form');
form.remove();
});
// Expose faForm globally
var faForm = window.faForm = new AdminForm();
// Apply global styles
faForm.applyGlobalStyles(document);
})();
......@@ -73,48 +73,62 @@
{% endif %}
{%- endmacro %}
{% macro render_form(form, cancel_url, extra=None) -%}
<form action="" method="POST" class="form-horizontal"{% if form.has_file_field %} enctype="multipart/form-data"{% endif %}>
<fieldset>
{{ form.hidden_tag() }}
{% macro render_form_fields(form, focus_set=False) %}
{{ form.hidden_tag() }}
{% for f in form if f.name != 'csrf_token' and f.name != 'csrf' %}
<div class="control-group{% if f.errors %} error{% endif %}">
{{ f.label(class='control-label') }}
<div class="controls">
<div>
{% if not focus_set %}
{{ f(autofocus='autofocus') }}
{% set focus_set = True %}
{% else %}
{{ f() }}
{% endif %}
</div>
{% if f.description %}
<p class="help-block">{{ f.description }}</p>
{% endif %}
{% if f.errors %}
<ul>
{% for e in f.errors %}
<li>{{ e }}</li>
{% endfor %}
</ul>
{% endif %}
</div>
</div>
{% endfor %}
<div class="control-group">
<div class="controls">
<input type="submit" class="btn btn-primary btn-large" value="{{ _gettext('Submit') }}" />
{% if extra %}
{{ extra }}
{% endif %}
{% if cancel_url %}
<a href="{{ cancel_url }}" class="btn btn-large">{{ _gettext('Cancel') }}</a>
{% endif %}
</div>
{% for f in form if f.type != 'HiddenField' and f.type != 'CSRFTokenField' %}
<div class="control-group{% if f.errors %} error{% endif %}">
{{ f.label(class='control-label') }}
<div class="controls">
<div>
{% if not focus_set %}
{{ f(autofocus='autofocus')|safe }}
{% set focus_set = True %}
{% else %}
{{ f()|safe }}
{% endif %}
</div>
{% if f.description %}
<p class="help-block">{{ f.description }}</p>
{% endif %}
{% if f.errors %}
<ul>
{% for e in f.errors %}
<li>{{ e }}</li>
{% endfor %}
</ul>
{% endif %}
</div>
</div>
{% endfor %}
{% endmacro %}
{% macro form_tag() %}
<form action="" method="POST" class="form-horizontal"{% if form.has_file_field %} enctype="multipart/form-data"{% endif %}>
<fieldset>
{{ caller() }}
</fieldset>
</form>
{% endmacro %}
{% macro render_form_buttons(cancel_url, extra=None) %}
<div class="control-group">
<div class="controls">
<input type="submit" class="btn btn-primary btn-large" value="{{ _gettext('Submit') }}" />
{% if extra %}
{{ extra }}
{% endif %}
{% if cancel_url %}
<a href="{{ cancel_url }}" class="btn btn-large">{{ _gettext('Cancel') }}</a>
{% endif %}
</div>
</div>
{% endmacro %}
{% macro render_form(form, cancel_url, extra=None) -%}
{% call form_tag() %}
{{ render_form_fields(form) }}
{{ render_form_buttons(cancel_url, extra) }}
{% endcall %}
{% endmacro %}
......@@ -7,19 +7,23 @@
{% endblock %}
{% block body %}
<ul class="nav nav-tabs">
<li>
<a href="{{ return_url }}">{{ _gettext('List') }}</a>
</li>
<li class="active">
<a href="#">{{ _gettext('Create') }}</a>
</li>
</ul>
{% macro extra() %}
<input name="_add_another" type="submit" class="btn btn-large" value="{{ _gettext('Save and Add') }}" />
{% endmacro %}
<ul class="nav nav-tabs">
<li>
<a href="{{ return_url }}">{{ _gettext('List') }}</a>
</li>
<li class="active">
<a href="#">{{ _gettext('Create') }}</a>
</li>
</ul>
{{ lib.render_form(form, return_url, extra()) }}
{% call lib.form_tag() %}
{{ lib.render_form_fields(form) }}
{{ lib.render_form_buttons(cancel_url, extra()) }}
{% endcall %}
{% endblock %}
{% block tail %}
......
......@@ -7,7 +7,10 @@
{% endblock %}
{% block body %}
{{ lib.render_form(form, return_url) }}
{% call lib.form_tag() %}
{{ lib.render_form_fields(form) }}
{{ lib.render_form_buttons(cancel_url) }}
{% endcall %}
{% endblock %}
{% block tail %}
......
{% import 'admin/lib.html' as lib with context %}
{% import 'admin/lib.html' as lib with context %}
{% macro render_form(form) %}
{% endmacro %}
{% macro render_template(template) -%}
<div class="fa-inline-form">
<div style="float: right">
<a href="#" class="fa-remove-form">x</a>
</div>
{{ lib.render_form_fields(template) }}
<hr/>
</div>
{%- endmacro %}
<div class="well">
<div id="{{ field.id }}-forms">
{% for subfield in field %}
<div id="{{ subfield.id }}" class="fa-inline-form">
{% set pk = subfield.get_pk() %}
{%- if pk %}
<div style="float: right">
<input type="checkbox" name="del-{{ subfield.id }}" id="del-{{ subfield.id }}" />Delete?
</div>
{%- endif -%}
{{ lib.render_form_fields(subfield, True) }}
<hr/>
</div>
{% endfor %}
</div>
<a href="#" class="btn" onclick="faForm.addInlineModel('{{ field.id }}', '#{{ field.id }}-forms', {{ render_template(template)|tojson }});">Add {{ field.name }}</a>
</div>
......@@ -174,11 +174,12 @@
{% if filter_groups is not none and filter_data is not none %}
<script language="javascript">
var form = new AdminForm();
var filter = new AdminFilters('#filter_form', '.field-filters', form,
{{ admin_view._filter_dict|tojson|safe }},
{{ filter_data|tojson|safe }},
{{ filter_types|tojson|safe }});
(function() {
var filter = new AdminFilters('#filter_form', '.field-filters',
{{ admin_view._filter_dict|tojson|safe }},
{{ filter_data|tojson|safe }},
{{ filter_types|tojson|safe }});
})();
</script>
{% endif %}
{% endblock %}
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