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:
# By default, don't show primary keys either if hidden_pk:
if self.view.form_columns is None: # If requested to add hidden field, show it
return None 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 form_columns is None:
if prop.key not in self.view.form_columns: return None
return None
kwargs['validators'].append(Unique(self.view.session, # If PK is not explicitly allowed, ignore it
model, if prop.key not in form_columns:
column)) return None
unique = True
kwargs['validators'].append(Unique(self.session,
model,
column))
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,27 +232,86 @@ class AdminModelConverter(ModelConverterBase): ...@@ -221,27 +232,86 @@ 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
only=None, exclude=None, def get_form(model, converter,
field_args=None): base_class=form.BaseForm,
# TODO: Support new 0.8 API only=None, exclude=None,
if not hasattr(model, '_sa_class_manager'): field_args=None,
raise TypeError('model must be a sqlalchemy mapped model') hidden_pk=False):
mapper = model._sa_class_manager.mapper # TODO: Support new 0.8 API
field_args = field_args or {} if not hasattr(model, '_sa_class_manager'):
raise TypeError('model must be a sqlalchemy mapped model')
properties = ((p.key, p) for p in mapper.iterate_properties)
if only: mapper = model._sa_class_manager.mapper
properties = (x for x in properties if x[0] in only) field_args = field_args or {}
elif exclude:
properties = (x for x in properties if x[0] not in exclude) properties = ((p.key, p) for p in mapper.iterate_properties)
if only:
field_dict = {} properties = (x for x in properties if x[0] in only)
for name, prop in properties: elif exclude:
field = self.convert(model, mapper, prop, field_args.get(name)) properties = (x for x in properties if x[0] not in exclude)
if field is not None:
field_dict[name] = field field_dict = {}
for name, prop in properties:
return type(model.__name__ + 'Form', (base_class, ), field_dict) 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): ...@@ -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() {
this.applyStyle = function(el, name) { var AdminForm = function() {
switch (name) { this.applyStyle = function(el, name) {
case 'chosen': switch (name) {
$(el).chosen(); case 'chosen':
break; $(el).chosen();
case 'chosenblank': break;
$(el).chosen({allow_single_deselect: true}); case 'chosenblank':
break; $(el).chosen({allow_single_deselect: true});
case 'datepicker': break;
$(el).datepicker(); case 'datepicker':
break; $(el).datepicker();
case 'datetimepicker': break;
$(el).datepicker({displayTime: true}); case 'datetimepicker':
break; $(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});
};
}; };
}
}; // Add live event handler
$('.fa-remove-form').live('click', function(e) {
// Apply automatic styles e.preventDefault();
$('[data-role=chosen]').chosen();
$('[data-role=chosenblank]').chosen({allow_single_deselect: true}); var form = $(this).closest('.fa-inline-form');
$('[data-role=datepicker]').datepicker(); form.remove();
$('[data-role=datetimepicker]').datepicker({displayTime: true}); });
// Expose faForm globally
var faForm = window.faForm = new AdminForm();
// Apply global styles
faForm.applyGlobalStyles(document);
})();
...@@ -73,48 +73,62 @@ ...@@ -73,48 +73,62 @@
{% 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 %}> {{ form.hidden_tag() }}
<fieldset>
{{ 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>
{% 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>
</div> </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> </fieldset>
</form> </form>
{% endmacro %} {% 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 @@ ...@@ -7,19 +7,23 @@
{% endblock %} {% endblock %}
{% block body %} {% 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() %} {% macro extra() %}
<input name="_add_another" type="submit" class="btn btn-large" value="{{ _gettext('Save and Add') }}" /> <input name="_add_another" type="submit" class="btn btn-large" value="{{ _gettext('Save and Add') }}" />
{% endmacro %} {% endmacro %}
<ul class="nav nav-tabs"> {% call lib.form_tag() %}
<li> {{ lib.render_form_fields(form) }}
<a href="{{ return_url }}">{{ _gettext('List') }}</a> {{ lib.render_form_buttons(cancel_url, extra()) }}
</li> {% endcall %}
<li class="active">
<a href="#">{{ _gettext('Create') }}</a>
</li>
</ul>
{{ lib.render_form(form, return_url, extra()) }}
{% 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