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): ...@@ -28,6 +28,18 @@ class User(db.Model):
return self.username 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 # Create M2M table
post_tags_table = db.Table('post_tags', db.Model.metadata, post_tags_table = db.Table('post_tags', db.Model.metadata,
db.Column('post_id', db.Integer, db.ForeignKey('post.id')), db.Column('post_id', db.Integer, db.ForeignKey('post.id')),
...@@ -64,6 +76,11 @@ def index(): ...@@ -64,6 +76,11 @@ def index():
return '<a href="/admin/">Click me to get to Admin!</a>' 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 # Customized Post model admin
class PostAdmin(sqlamodel.ModelView): class PostAdmin(sqlamodel.ModelView):
# Visible columns in the list view # Visible columns in the list view
...@@ -100,7 +117,7 @@ if __name__ == '__main__': ...@@ -100,7 +117,7 @@ if __name__ == '__main__':
admin = admin.Admin(app, 'Simple Models') admin = admin.Admin(app, 'Simple Models')
# Add views # 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(sqlamodel.ModelView(Tag, db.session))
admin.add_view(PostAdmin(db.session)) admin.add_view(PostAdmin(db.session))
......
...@@ -4,9 +4,14 @@ ...@@ -4,9 +4,14 @@
import operator import operator
from wtforms import widgets from wtforms import widgets
from wtforms.fields import SelectFieldBase from wtforms.fields import SelectFieldBase, FormField, FieldList
from wtforms.validators import ValidationError from wtforms.validators import ValidationError
from .tools import get_primary_key
from flask.ext.admin.model.widgets import InlineFormListWidget
from flask import request
try: try:
from sqlalchemy.orm.util import identity_key from sqlalchemy.orm.util import identity_key
has_identity_key = True has_identity_key = True
...@@ -176,6 +181,79 @@ class QuerySelectMultipleField(QuerySelectField): ...@@ -176,6 +181,79 @@ class QuerySelectMultipleField(QuerySelectField):
raise ValidationError(self.gettext('Not a valid choice')) 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): def get_pk_from_identity(obj):
cls, key = identity_key(instance=obj) cls, key = identity_key(instance=obj)
return u':'.join(unicode(x) for x in key) return u':'.join(unicode(x) for x in key)
from wtforms import fields, validators from wtforms import fields, validators
from flask.ext.admin import form 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 .validators import Unique
from .fields import QuerySelectField, QuerySelectMultipleField from .fields import QuerySelectField, QuerySelectMultipleField, InlineModelFormList
class AdminModelConverter(ModelConverterBase): class AdminModelConverter(ModelConverterBase):
""" """
SQLAlchemy model to form converter SQLAlchemy model to form converter
""" """
def __init__(self, view): def __init__(self, session, view):
super(AdminModelConverter, self).__init__() super(AdminModelConverter, self).__init__()
self.session = session
self.view = view self.view = view
def _get_label(self, name, field_args): def _get_label(self, name, field_args):
if 'label' in field_args: if 'label' in field_args:
return field_args['label'] return field_args['label']
if self.view.rename_columns: rename_columns = getattr(self.view, 'rename_columns', None)
return self.view.rename_columns.get(name)
if rename_columns:
return rename_columns.get(name)
return None return None
def _get_field_override(self, name): def _get_field_override(self, name):
if self.view.form_overrides: form_overrides = getattr(self.view, 'form_overrides', None)
return self.view.form_overrides.get(name)
if form_overrides:
return form_overrides.get(name)
return None return None
def convert(self, model, mapper, prop, field_args): def convert(self, model, mapper, prop, field_args, hidden_pk):
kwargs = { kwargs = {
'validators': [], 'validators': [],
'filters': [] 'filters': []
...@@ -48,7 +53,7 @@ class AdminModelConverter(ModelConverterBase): ...@@ -48,7 +53,7 @@ class AdminModelConverter(ModelConverterBase):
kwargs.update({ kwargs.update({
'allow_blank': local_column.nullable, 'allow_blank': local_column.nullable,
'label': self._get_label(prop.key, kwargs), '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: if local_column.nullable:
...@@ -66,7 +71,7 @@ class AdminModelConverter(ModelConverterBase): ...@@ -66,7 +71,7 @@ class AdminModelConverter(ModelConverterBase):
**kwargs) **kwargs)
elif prop.direction.name == 'ONETOMANY': elif prop.direction.name == 'ONETOMANY':
# Skip backrefs # 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 None
return QuerySelectMultipleField( return QuerySelectMultipleField(
...@@ -93,22 +98,28 @@ class AdminModelConverter(ModelConverterBase): ...@@ -93,22 +98,28 @@ class AdminModelConverter(ModelConverterBase):
unique = False unique = False
if column.primary_key: if column.primary_key:
if hidden_pk:
# If requested to add hidden field, show it
return fields.HiddenField()
else:
# By default, don't show primary keys either # By default, don't show primary keys either
if self.view.form_columns is None: form_columns = getattr(self.view, 'form_columns', None)
if form_columns is None:
return None return None
# If PK is not explicitly allowed, ignore it # If PK is not explicitly allowed, ignore it
if prop.key not in self.view.form_columns: if prop.key not in form_columns:
return None return None
kwargs['validators'].append(Unique(self.view.session, kwargs['validators'].append(Unique(self.session,
model, model,
column)) column))
unique = True unique = True
# If field is unique, validate it # If field is unique, validate it
if column.unique and not unique: if column.unique and not unique:
kwargs['validators'].append(Unique(self.view.session, kwargs['validators'].append(Unique(self.session,
model, model,
column)) column))
...@@ -221,10 +232,14 @@ class AdminModelConverter(ModelConverterBase): ...@@ -221,10 +232,14 @@ class AdminModelConverter(ModelConverterBase):
field_args['validators'].append(validators.UUID()) field_args['validators'].append(validators.UUID())
return fields.TextField(**field_args) return fields.TextField(**field_args)
# Get list of fields and generate form
def get_form(self, model, base_class=form.BaseForm, # Get list of fields and generate form
def get_form(model, converter,
base_class=form.BaseForm,
only=None, exclude=None, only=None, exclude=None,
field_args=None): field_args=None,
hidden_pk=False):
# TODO: Support new 0.8 API # TODO: Support new 0.8 API
if not hasattr(model, '_sa_class_manager'): if not hasattr(model, '_sa_class_manager'):
raise TypeError('model must be a sqlalchemy mapped model') raise TypeError('model must be a sqlalchemy mapped model')
...@@ -240,8 +255,63 @@ class AdminModelConverter(ModelConverterBase): ...@@ -240,8 +255,63 @@ class AdminModelConverter(ModelConverterBase):
field_dict = {} field_dict = {}
for name, prop in properties: for name, prop in properties:
field = self.convert(model, mapper, prop, field_args.get(name)) field = converter.convert(model, mapper, prop, field_args.get(name), hidden_pk)
if field is not None: if field is not None:
field_dict[name] = field field_dict[name] = field
return type(model.__name__ + 'Form', (base_class, ), field_dict) 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): ...@@ -7,3 +7,18 @@ def parse_like_term(term):
stmt = '%%%s%%' % term stmt = '%%%s%%' % term
return stmt 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): ...@@ -124,6 +124,17 @@ class ModelView(BaseModelView):
for your model. 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, def __init__(self, model, session,
name=None, category=None, endpoint=None, url=None): name=None, category=None, endpoint=None, url=None):
""" """
...@@ -178,13 +189,7 @@ class ModelView(BaseModelView): ...@@ -178,13 +189,7 @@ class ModelView(BaseModelView):
""" """
Return primary key name from a model Return primary key name from a model
""" """
for p in self._get_model_iterator(): return tools.get_primary_key(self.model)
if hasattr(p, 'columns'):
for c in p.columns:
if c.primary_key:
return p.key
return None
def get_pk_value(self, model): def get_pk_value(self, model):
""" """
...@@ -370,12 +375,18 @@ class ModelView(BaseModelView): ...@@ -370,12 +375,18 @@ class ModelView(BaseModelView):
""" """
Create form from the model. Create form from the model.
""" """
converter = form.AdminModelConverter(self) converter = form.AdminModelConverter(self.session, self)
return converter.get_form(self.model, form_class = form.get_form(self.model, converter,
only=self.form_columns, only=self.form_columns,
exclude=self.excluded_form_columns, exclude=self.excluded_form_columns,
field_args=self.form_args) 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): def scaffold_auto_joins(self):
""" """
Return list of joined tables by going through the Return list of joined tables by going through the
......
from .base import BaseModelView from .base import BaseModelView
from .form import InlineFormAdmin
from flask.ext.admin.actions import action from flask.ext.admin.actions import action
...@@ -10,6 +10,18 @@ def converts(*args): ...@@ -10,6 +10,18 @@ def converts(*args):
return _inner 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): class ModelConverterBase(object):
def __init__(self, converters=None, use_mro=True): def __init__(self, converters=None, use_mro=True):
self.use_mro = use_mro 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 $root = $(element);
var $container = $('.filters', $root); var $container = $('.filters', $root);
var lastCount = 0; var lastCount = 0;
...@@ -68,7 +68,7 @@ var AdminFilters = function(element, filters_element, adminForm, operations, opt ...@@ -68,7 +68,7 @@ var AdminFilters = function(element, filters_element, adminForm, operations, opt
if (optId in types) { if (optId in types) {
$field.attr('data-role', types[optId]); $field.attr('data-role', types[optId]);
adminForm.applyStyle($field, types[optId]); faForm.applyStyle($field, types[optId]);
} }
lastCount += 1; lastCount += 1;
......
var AdminForm = function() { (function() {
var AdminForm = function() {
this.applyStyle = function(el, name) { this.applyStyle = function(el, name) {
switch (name) { switch (name) {
case 'chosen': case 'chosen':
...@@ -13,12 +14,59 @@ var AdminForm = function() { ...@@ -13,12 +14,59 @@ var AdminForm = function() {
case 'datetimepicker': case 'datetimepicker':
$(el).datepicker({displayTime: true}); $(el).datepicker({displayTime: true});
break; 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;
} }
};
// Apply automatic styles // Set form ID
$('[data-role=chosen]').chosen(); $template.attr('id', prefix);
$('[data-role=chosenblank]').chosen({allow_single_deselect: true});
$('[data-role=datepicker]').datepicker(); // Fix form IDs
$('[data-role=datetimepicker]').datepicker({displayTime: true}); $('[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});
};
};
// 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,21 +73,19 @@ ...@@ -73,21 +73,19 @@
{% endif %} {% endif %}
{%- endmacro %} {%- endmacro %}
{% macro render_form(form, cancel_url, extra=None) -%} {% macro render_form_fields(form, focus_set=False) %}
<form action="" method="POST" class="form-horizontal"{% if form.has_file_field %} enctype="multipart/form-data"{% endif %}>
<fieldset>
{{ form.hidden_tag() }} {{ form.hidden_tag() }}
{% for f in form if f.name != 'csrf_token' and f.name != 'csrf' %} {% for f in form if f.type != 'HiddenField' and f.type != 'CSRFTokenField' %}
<div class="control-group{% if f.errors %} error{% endif %}"> <div class="control-group{% if f.errors %} error{% endif %}">
{{ f.label(class='control-label') }} {{ f.label(class='control-label') }}
<div class="controls"> <div class="controls">
<div> <div>
{% if not focus_set %} {% if not focus_set %}
{{ f(autofocus='autofocus') }} {{ f(autofocus='autofocus')|safe }}
{% set focus_set = True %} {% set focus_set = True %}
{% else %} {% else %}
{{ f() }} {{ f()|safe }}
{% endif %} {% endif %}
</div> </div>
{% if f.description %} {% if f.description %}
...@@ -103,6 +101,17 @@ ...@@ -103,6 +101,17 @@
</div> </div>
</div> </div>
{% endfor %} {% 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="control-group">
<div class="controls"> <div class="controls">
<input type="submit" class="btn btn-primary btn-large" value="{{ _gettext('Submit') }}" /> <input type="submit" class="btn btn-primary btn-large" value="{{ _gettext('Submit') }}" />
...@@ -114,7 +123,12 @@ ...@@ -114,7 +123,12 @@
{% endif %} {% endif %}
</div> </div>
</div> </div>
</fieldset> {% endmacro %}
</form>
{% macro render_form(form, cancel_url, extra=None) -%}
{% call form_tag() %}
{{ render_form_fields(form) }}
{{ render_form_buttons(cancel_url, extra) }}
{% endcall %}
{% endmacro %} {% endmacro %}
...@@ -7,10 +7,6 @@ ...@@ -7,10 +7,6 @@
{% endblock %} {% endblock %}
{% block body %} {% block body %}
{% macro extra() %}
<input name="_add_another" type="submit" class="btn btn-large" value="{{ _gettext('Save and Add') }}" />
{% endmacro %}
<ul class="nav nav-tabs"> <ul class="nav nav-tabs">
<li> <li>
<a href="{{ return_url }}">{{ _gettext('List') }}</a> <a href="{{ return_url }}">{{ _gettext('List') }}</a>
...@@ -19,7 +15,15 @@ ...@@ -19,7 +15,15 @@
<a href="#">{{ _gettext('Create') }}</a> <a href="#">{{ _gettext('Create') }}</a>
</li> </li>
</ul> </ul>
{{ lib.render_form(form, return_url, extra()) }}
{% macro extra() %}
<input name="_add_another" type="submit" class="btn btn-large" value="{{ _gettext('Save and Add') }}" />
{% endmacro %}
{% call lib.form_tag() %}
{{ lib.render_form_fields(form) }}
{{ lib.render_form_buttons(cancel_url, extra()) }}
{% endcall %}
{% endblock %} {% endblock %}
{% block tail %} {% block tail %}
......
...@@ -7,7 +7,10 @@ ...@@ -7,7 +7,10 @@
{% endblock %} {% endblock %}
{% block body %} {% 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 %} {% endblock %}
{% block tail %} {% 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 @@ ...@@ -174,11 +174,12 @@
{% if filter_groups is not none and filter_data is not none %} {% if filter_groups is not none and filter_data is not none %}
<script language="javascript"> <script language="javascript">
var form = new AdminForm(); (function() {
var filter = new AdminFilters('#filter_form', '.field-filters', form, var filter = new AdminFilters('#filter_form', '.field-filters',
{{ admin_view._filter_dict|tojson|safe }}, {{ admin_view._filter_dict|tojson|safe }},
{{ filter_data|tojson|safe }}, {{ filter_data|tojson|safe }},
{{ filter_types|tojson|safe }}); {{ filter_types|tojson|safe }});
})();
</script> </script>
{% endif %} {% endif %}
{% endblock %} {% 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