Commit 890863fe authored by Serge S. Koval's avatar Serge S. Koval

AJAX foreign key loading for SQLa backend

parent bb5d8efc
...@@ -121,6 +121,11 @@ class PostAdmin(sqla.ModelView): ...@@ -121,6 +121,11 @@ class PostAdmin(sqla.ModelView):
text=dict(label='Big Text', validators=[validators.required()]) text=dict(label='Big Text', validators=[validators.required()])
) )
form_ajax_refs = {
'user': (User.username, User.email),
'tags': (Tag.name,)
}
def __init__(self, session): def __init__(self, session):
# Just call parent class with predefined model. # Just call parent class with predefined model.
super(PostAdmin, self).__init__(Post, session) super(PostAdmin, self).__init__(Post, session)
......
...@@ -6,6 +6,7 @@ from flask.ext.admin.form import Select2Field ...@@ -6,6 +6,7 @@ from flask.ext.admin.form import Select2Field
from flask.ext.admin.model.form import (converts, ModelConverterBase, from flask.ext.admin.model.form import (converts, ModelConverterBase,
InlineFormAdmin, InlineModelConverterBase, InlineFormAdmin, InlineModelConverterBase,
FieldPlaceholder) FieldPlaceholder)
from flask.ext.admin.model.fields import AjaxSelectField, AjaxSelectMultipleField
from flask.ext.admin.model.helpers import prettify_name from flask.ext.admin.model.helpers import prettify_name
from flask.ext.admin._backwards import get_property from flask.ext.admin._backwards import get_property
from flask.ext.admin._compat import iteritems from flask.ext.admin._compat import iteritems
...@@ -70,6 +71,31 @@ class AdminModelConverter(ModelConverterBase): ...@@ -70,6 +71,31 @@ class AdminModelConverter(ModelConverterBase):
return None return None
def _model_select_field(self, prop, multiple, remote_model, **kwargs):
loader = self.view._form_ajax_refs.get(prop.key)
if loader:
if multiple:
return AjaxSelectMultipleField(loader, **kwargs)
else:
return AjaxSelectField(loader, **kwargs)
if 'query_factory' not in kwargs:
kwargs['query_factory'] = lambda: self.session.query(remote_model)
if 'widget' not in kwargs:
if prop.direction.name == 'MANYTOONE':
kwargs['widget'] = form.Select2Widget()
elif prop.direction.name == 'ONETOMANY':
kwargs['widget'] = form.Select2Widget(multiple=True)
elif prop.direction.name == 'MANYTOMANY':
kwargs['widget'] = form.Select2Widget(multiple=True)
if multiple:
return QuerySelectMultipleField(**kwargs)
else:
return QuerySelectField(**kwargs)
def _convert_relation(self, prop, kwargs): def _convert_relation(self, prop, kwargs):
remote_model = prop.mapper.class_ remote_model = prop.mapper.class_
local_column = prop.local_remote_pairs[0][0] local_column = prop.local_remote_pairs[0][0]
...@@ -85,16 +111,6 @@ class AdminModelConverter(ModelConverterBase): ...@@ -85,16 +111,6 @@ class AdminModelConverter(ModelConverterBase):
# Contribute model-related parameters # Contribute model-related parameters
if 'allow_blank' not in kwargs: if 'allow_blank' not in kwargs:
kwargs['allow_blank'] = local_column.nullable kwargs['allow_blank'] = local_column.nullable
if 'query_factory' not in kwargs:
kwargs['query_factory'] = lambda: self.session.query(remote_model)
if 'widget' not in kwargs:
if prop.direction.name == 'MANYTOONE':
kwargs['widget'] = form.Select2Widget()
elif prop.direction.name == 'ONETOMANY':
kwargs['widget'] = form.Select2Widget(multiple=True)
elif prop.direction.name == 'MANYTOMANY':
kwargs['widget'] = form.Select2Widget(multiple=True)
# Override field type if necessary # Override field type if necessary
override = self._get_field_override(prop.key) override = self._get_field_override(prop.key)
...@@ -102,15 +118,15 @@ class AdminModelConverter(ModelConverterBase): ...@@ -102,15 +118,15 @@ class AdminModelConverter(ModelConverterBase):
return override(**kwargs) return override(**kwargs)
if prop.direction.name == 'MANYTOONE': if prop.direction.name == 'MANYTOONE':
return QuerySelectField(**kwargs) return self._model_select_field(prop, False, remote_model, **kwargs)
elif prop.direction.name == 'ONETOMANY': elif prop.direction.name == 'ONETOMANY':
# Skip backrefs # Skip backrefs
if not local_column.foreign_keys and getattr(self.view, 'column_hide_backrefs', True): if not local_column.foreign_keys and getattr(self.view, 'column_hide_backrefs', True):
return None return None
return QuerySelectMultipleField(**kwargs) return self._model_select_field(prop, True, remote_model, **kwargs)
elif prop.direction.name == 'MANYTOMANY': elif prop.direction.name == 'MANYTOMANY':
return QuerySelectMultipleField(**kwargs) return self._model_select_field(prop, True, remote_model, **kwargs)
def convert(self, model, mapper, prop, field_args, hidden_pk): def convert(self, model, mapper, prop, field_args, hidden_pk):
# Properly handle forced fields # Properly handle forced fields
......
...@@ -16,6 +16,8 @@ from flask.ext.admin._backwards import ObsoleteAttr ...@@ -16,6 +16,8 @@ from flask.ext.admin._backwards import ObsoleteAttr
from flask.ext.admin.contrib.sqla import form, filters, tools from flask.ext.admin.contrib.sqla import form, filters, tools
from .typefmt import DEFAULT_FORMATTERS from .typefmt import DEFAULT_FORMATTERS
from .tools import is_inherited_primary_key, get_column_for_current_model, get_query_for_ids from .tools import is_inherited_primary_key, get_column_for_current_model, get_query_for_ids
from .ajax import QueryAjaxModelLoader
class ModelView(BaseModelView): class ModelView(BaseModelView):
""" """
...@@ -585,6 +587,33 @@ class ModelView(BaseModelView): ...@@ -585,6 +587,33 @@ class ModelView(BaseModelView):
return joined return joined
# AJAX foreignkey support
def _create_ajax_loader(self, name, fields):
attr = getattr(self.model, name, None)
if attr is None:
raise ValueError('Model %s does not have field %s.' % (self.model, name))
if not hasattr(attr, 'property') or not hasattr(attr.property, 'direction'):
raise ValueError('%s.%s is not a relation.' % (self.model, name))
remote_model = attr.prop.mapper.class_
remote_fields = []
for field in fields:
if isinstance(field, string_types):
attr = getattr(remote_model, field, None)
if not attr:
raise ValueError('%s.%s does not exist.' % (remote_model, field))
remote_fields.append(attr)
else:
# TODO: Figure out if it is valid SQLAlchemy property?
remote_fields.append(field)
return QueryAjaxModelLoader(name, self.session, remote_model, remote_fields)
# Database-related API # Database-related API
def get_query(self): def get_query(self):
""" """
......
...@@ -30,7 +30,7 @@ class Select2TagsWidget(widgets.TextInput): ...@@ -30,7 +30,7 @@ class Select2TagsWidget(widgets.TextInput):
You must include select2.js, form.js and select2 stylesheet for it to work. You must include select2.js, form.js and select2 stylesheet for it to work.
""" """
def __call__(self, field, **kwargs): def __call__(self, field, **kwargs):
kwargs['data-role'] = u'select' kwargs['data-role'] = u'select2'
kwargs['data-tags'] = u'1' kwargs['data-tags'] = u'1'
return super(Select2TagsWidget, self).__call__(field, **kwargs) return super(Select2TagsWidget, self).__call__(field, **kwargs)
......
import warnings import warnings
from flask import request, url_for, redirect, flash from flask import request, url_for, redirect, flash, abort, json, Response
from jinja2 import contextfunction from jinja2 import contextfunction
...@@ -15,6 +15,7 @@ from flask.ext.admin.tools import rec_getattr ...@@ -15,6 +15,7 @@ from flask.ext.admin.tools import rec_getattr
from flask.ext.admin._backwards import ObsoleteAttr from flask.ext.admin._backwards import ObsoleteAttr
from flask.ext.admin._compat import iteritems, as_unicode from flask.ext.admin._compat import iteritems, as_unicode
from .helpers import prettify_name, get_mdict_item_or_list from .helpers import prettify_name, get_mdict_item_or_list
from .ajax import AjaxModelLoader
class BaseModelView(BaseView, ActionsMixin): class BaseModelView(BaseView, ActionsMixin):
...@@ -362,6 +363,21 @@ class BaseModelView(BaseView, ActionsMixin): ...@@ -362,6 +363,21 @@ class BaseModelView(BaseView, ActionsMixin):
In this case, password field will be put between email and secret fields that are autogenerated. In this case, password field will be put between email and secret fields that are autogenerated.
""" """
form_ajax_refs = None
"""
Use AJAX for foreign key model loading.
Should contain dictionary, where key is field name and value is enumerable with list to fields
to check against (in remote model).
For example, it can look like::
class MyModelView(BaseModelView):
form_ajax_refs = {
'user': ('first_name', 'last_name', 'email')
}
"""
# Actions # Actions
action_disallowed_list = ObsoleteAttr('action_disallowed_list', action_disallowed_list = ObsoleteAttr('action_disallowed_list',
'disallowed_actions', 'disallowed_actions',
...@@ -434,12 +450,14 @@ class BaseModelView(BaseView, ActionsMixin): ...@@ -434,12 +450,14 @@ class BaseModelView(BaseView, ActionsMixin):
self.column_labels = {} self.column_labels = {}
# Forms # Forms
self._create_form_class = self.get_create_form() self._form_ajax_refs = self._process_ajax_references()
self._edit_form_class = self.get_edit_form()
if self.form_widget_args is None: if self.form_widget_args is None:
self.form_widget_args = {} self.form_widget_args = {}
self._create_form_class = self.get_create_form()
self._edit_form_class = self.get_edit_form()
# Search # Search
self._search_supported = self.init_search() self._search_supported = self.init_search()
...@@ -971,6 +989,31 @@ class BaseModelView(BaseView, ActionsMixin): ...@@ -971,6 +989,31 @@ class BaseModelView(BaseView, ActionsMixin):
return value return value
# AJAX references
def _process_ajax_references(self):
"""
Process `form_ajax_refs` and generate model loaders that
will be used by the `ajax_lookup` view.
"""
result = {}
if self.form_ajax_refs:
for name, value in iteritems(self.form_ajax_refs):
if isinstance(value, AjaxModelLoader):
result[name] = value
elif isinstance(value, (list, tuple)):
result[name] = self._create_ajax_loader(name, value)
else:
raise ValueError('%s.form_ajax_refs can not handle %s types' % (self, type(value)))
return result
def _create_ajax_loader(self, name, fields):
"""
Model backend will override this to implement AJAX model loading.
"""
raise NotImplemented()
# Views # Views
@expose('/') @expose('/')
def index_view(self): def index_view(self):
...@@ -1157,3 +1200,18 @@ class BaseModelView(BaseView, ActionsMixin): ...@@ -1157,3 +1200,18 @@ class BaseModelView(BaseView, ActionsMixin):
Mass-model action view. Mass-model action view.
""" """
return self.handle_action() return self.handle_action()
@expose('/ajax/lookup/')
def ajax_lookup(self):
name = request.args.get('name')
query = request.args.get('query')
offset = request.args.get('offset', type=int)
limit = request.args.get('limit', 10, type=int)
loader = self._form_ajax_refs.get(name)
if not loader:
abort(404)
data = [loader.format(m) for m in loader.get_models(query, offset, limit)]
return Response(json.dumps(data), mimetype='application/json')
import itertools import itertools
from wtforms.fields import FieldList, FormField from wtforms.validators import ValidationError
from wtforms.fields import FieldList, FormField, SelectFieldBase
from flask.ext.admin._compat import iteritems from flask.ext.admin._compat import iteritems
from .widgets import InlineFieldListWidget, InlineFormWidget from .widgets import InlineFieldListWidget, InlineFormWidget, AjaxSelect2Widget
class InlineFieldList(FieldList): class InlineFieldList(FieldList):
...@@ -113,3 +114,96 @@ class InlineFormField(FormField): ...@@ -113,3 +114,96 @@ class InlineFormField(FormField):
Inline version of the ``FormField`` widget. Inline version of the ``FormField`` widget.
""" """
widget = InlineFormWidget() widget = InlineFormWidget()
class AjaxSelectField(SelectFieldBase):
"""
Ajax Model Select Field
"""
widget = AjaxSelect2Widget()
def __init__(self, loader, label=None, validators=None, allow_blank=False, blank_text=u'', **kwargs):
super(AjaxSelectField, self).__init__(label, validators, **kwargs)
self.loader = loader
self.allow_blank = allow_blank
self.blank_text = blank_text
def _get_data(self):
if self._formdata is not None:
model = self.loader.get_one(self._formdata)
if model is not None:
self._set_data(model)
return self._data
def _set_data(self, data):
self._data = data
self._formdata = None
data = property(_get_data, _set_data)
def _format_item(self, item):
value = self.loader.format(self.data)
return (value[0], value[1], True)
def process_formdata(self, valuelist):
if valuelist:
if self.allow_blank and valuelist[0] == u'__None':
self.data = None
else:
self._data = None
self._formdata = valuelist[0]
def pre_validate(self, form):
if not self.allow_blank and self.data is None:
raise ValidationError(self.gettext(u'Not a valid choice'))
class AjaxSelectMultipleField(AjaxSelectField):
"""
Ajax-enabled model multi-select field.
"""
widget = AjaxSelect2Widget(multiple=True)
def __init__(self, loader, label=None, validators=None, default=None, **kwargs):
if default is None:
default = []
super(AjaxSelectMultipleField, self).__init__(loader, label, validators, default=default, **kwargs)
self._invalid_formdata = False
def _get_data(self):
formdata = self._formdata
if formdata is not None:
data = []
# TODO: Optimize?
for item in formdata:
model = self.loader.get_one(item)
if model:
data.append(model)
else:
self._invalid_formdata = True
self._set_data(data)
return self._data
def _set_data(self, data):
self._data = data
self._formdata = None
data = property(_get_data, _set_data)
def process_formdata(self, valuelist):
self._formdata = set(valuelist)
def pre_validate(self, form):
if self._invalid_formdata:
raise ValidationError(self.gettext(u'Not a valid choice'))
elif self.data:
for item in self.data:
if not self.loader.get_one(item):
raise ValidationError(self.gettext(u'Not a valid choice'))
from flask import url_for, json
from wtforms.widgets import HTMLString, html_params
from flask.ext.admin.form import RenderTemplateWidget from flask.ext.admin.form import RenderTemplateWidget
...@@ -9,3 +12,32 @@ class InlineFieldListWidget(RenderTemplateWidget): ...@@ -9,3 +12,32 @@ class InlineFieldListWidget(RenderTemplateWidget):
class InlineFormWidget(RenderTemplateWidget): class InlineFormWidget(RenderTemplateWidget):
def __init__(self): def __init__(self):
super(InlineFormWidget, self).__init__('admin/model/inline_form.html') super(InlineFormWidget, self).__init__('admin/model/inline_form.html')
class AjaxSelect2Widget(object):
def __init__(self, multiple=False):
self.multiple = multiple
def __call__(self, field, **kwargs):
kwargs['data-role'] = u'select2-ajax'
kwargs['data-url'] = url_for('.ajax_lookup', name=field.loader.name)
allow_blank = getattr(field, 'allow_blank', False)
if allow_blank and not self.multiple:
kwargs['data-allow-blank'] = u'1'
kwargs.setdefault('id', field.id)
kwargs.setdefault('type', 'hidden')
if self.multiple:
result = []
for value in field.data:
result.append(field.loader.format(value))
kwargs['value'] = json.dumps(result)
kwargs['data-multiple'] = u'1'
else:
kwargs['value'] = json.dumps(field.loader.format(field.data))
return HTMLString('<input %s>' % html_params(name=field.name, **kwargs))
...@@ -3,6 +3,70 @@ ...@@ -3,6 +3,70 @@
// Field converters // Field converters
var fieldConverters = []; var fieldConverters = [];
/**
* Process AJAX fk-widget
*/
function processAjaxWidget($el, name) {
var multiple = $el.attr('data-multiple') == '1';
var opts = {
width: 'resolve',
minimumInputLength: 1,
ajax: {
url: $el.attr('data-url'),
data: function(term, page) {
return {
query: term,
offset: (page - 1) * 10,
limit: 10
};
},
results: function(data, page) {
var results = [];
for (var k in data) {
var v = data[k];
results.push({id: v[0], text: v[1]});
}
return {
results: results,
more: results.length == 10
};
}
},
initSelection: function(element, callback) {
var value = jQuery.parseJSON(element.val());
var result = null;
if (value) {
if (multiple) {
result = [];
for (var k in value) {
var v = value[k];
result.push({id: v[0], text: v[1]});
}
callback(result);
} else {
result = {id: value[0], text: value[1]};
}
}
callback(result);
}
};
if ($el.attr('data-allow-blank'))
opts['allowClear'] = true;
opts['multiple'] = multiple;
$el.select2(opts);
}
/** /**
* Process data-role attribute for the given input element. Feel free to override * Process data-role attribute for the given input element. Feel free to override
* *
...@@ -20,7 +84,7 @@ ...@@ -20,7 +84,7 @@
switch (name) { switch (name) {
case 'select2': case 'select2':
opts = { var opts = {
width: 'resolve' width: 'resolve'
}; };
...@@ -36,6 +100,9 @@ ...@@ -36,6 +100,9 @@
$el.select2(opts); $el.select2(opts);
return true; return true;
case 'select2-ajax':
processAjaxWidget($el, name);
return true;
case 'datepicker': case 'datepicker':
$el.datepicker(); $el.datepicker();
return true; return true;
......
File mode changed from 100644 to 100755
This diff is collapsed.
This source diff could not be displayed because it is too large. You can view the blob instead.
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