Commit eb37f32c authored by Paul Brown's avatar Paul Brown

add editable list view

parent ba4e10ce
...@@ -6,13 +6,16 @@ from flask.ext.admin import expose ...@@ -6,13 +6,16 @@ from flask.ext.admin import expose
from flask.ext.admin.babel import gettext, ngettext, lazy_gettext from flask.ext.admin.babel import gettext, ngettext, lazy_gettext
from flask.ext.admin.model import BaseModelView from flask.ext.admin.model import BaseModelView
from flask.ext.admin._compat import iteritems, string_types from flask.ext.admin._compat import iteritems, string_types
from flask.ext.admin.actions import action
from flask.ext.admin.model.fields import ListEditableFieldList
from wtforms.fields.core import UnboundField
import mongoengine import mongoengine
import gridfs import gridfs
from mongoengine.connection import get_db from mongoengine.connection import get_db
from bson.objectid import ObjectId from bson.objectid import ObjectId
from flask.ext.admin.actions import action
from .filters import FilterConverter, BaseMongoEngineFilter from .filters import FilterConverter, BaseMongoEngineFilter
from .form import get_form, CustomModelConverter from .form import get_form, CustomModelConverter
from .typefmt import DEFAULT_FORMATTERS from .typefmt import DEFAULT_FORMATTERS
...@@ -398,6 +401,23 @@ class ModelView(BaseModelView): ...@@ -398,6 +401,23 @@ class ModelView(BaseModelView):
return form_class return form_class
def scaffold_list_form(self):
"""
Create form for the list view editable columns.
"""
form_class = get_form(self.model, self.model_form_converter(self),
base_class=self.form_base_class,
only=self.column_editable_list)
# iterate FormMeta to get unbound fields
field_dict = {}
for name, field_object in iteritems(form_class.__dict__):
if not name.startswith('_') and isinstance(field_object, UnboundField):
# wrap each field in the form from get_form in FieldList
field_dict[name] = ListEditableFieldList(field_object)
return type(self.model.__name__ + 'Form', (self.form_base_class, ), field_dict)
# AJAX foreignkey support # AJAX foreignkey support
def _create_ajax_loader(self, name, opts): def _create_ajax_loader(self, name, opts):
return create_ajax_loader(self.model, name, name, opts) return create_ajax_loader(self.model, name, name, opts)
...@@ -545,6 +565,37 @@ class ModelView(BaseModelView): ...@@ -545,6 +565,37 @@ class ModelView(BaseModelView):
return True return True
def update_list_model(self, form):
"""
Update model from the list view.
Only supports updating a single field at a time.
:param form:
Form instance
"""
try:
model = self.model()
for field in form:
# FieldList's last_index will only be set if a field is submitted
# last_index will be the primary key of the updated record
if getattr(field, 'last_index', None):
record = self.get_one(field.last_index)
setattr(record, field.name, field.data.pop())
self._on_model_change(form, model, False)
record.save()
self.after_model_change(form, model, False)
return True
except Exception as ex:
if not self.handle_view_exception(ex):
log.exception(gettext('Failed to update record. %(error)s', error=str(ex)), 'error')
self.session.rollback()
# Error: Unable to update database or no records were changed.
return False
def delete_model(self, model): def delete_model(self, model):
""" """
Delete model helper Delete model helper
......
...@@ -2,7 +2,7 @@ import logging ...@@ -2,7 +2,7 @@ import logging
from flask import flash from flask import flash
from flask.ext.admin._compat import string_types from flask.ext.admin._compat import string_types, iteritems
from flask.ext.admin.babel import gettext, ngettext, lazy_gettext from flask.ext.admin.babel import gettext, ngettext, lazy_gettext
from flask.ext.admin.model import BaseModelView from flask.ext.admin.model import BaseModelView
...@@ -11,6 +11,9 @@ from peewee import PrimaryKeyField, ForeignKeyField, Field, CharField, TextField ...@@ -11,6 +11,9 @@ from peewee import PrimaryKeyField, ForeignKeyField, Field, CharField, TextField
from flask.ext.admin.actions import action from flask.ext.admin.actions import action
from flask.ext.admin.contrib.peewee import filters from flask.ext.admin.contrib.peewee import filters
from flask.ext.admin.model.fields import ListEditableFieldList
from wtforms.fields.core import UnboundField
from .form import get_form, CustomModelConverter, InlineModelConverter, save_inline from .form import get_form, CustomModelConverter, InlineModelConverter, save_inline
from .tools import get_primary_key, parse_like_term from .tools import get_primary_key, parse_like_term
from .ajax import create_ajax_loader from .ajax import create_ajax_loader
...@@ -237,6 +240,23 @@ class ModelView(BaseModelView): ...@@ -237,6 +240,23 @@ class ModelView(BaseModelView):
return form_class return form_class
def scaffold_list_form(self):
"""
Create form for the list view editable columns.
"""
form_class = get_form(self.model, self.model_form_converter(self),
base_class=self.form_base_class,
only=self.column_editable_list)
# iterate FormMeta to get unbound fields
field_dict = {}
for name, field_object in iteritems(form_class.__dict__):
if not name.startswith('_') and isinstance(field_object, UnboundField):
# wrap each field in the form from get_form in FieldList
field_dict[name] = ListEditableFieldList(field_object)
return type(self.model.__name__ + 'Form', (self.form_base_class, ), field_dict)
def scaffold_inline_form_models(self, form_class): def scaffold_inline_form_models(self, form_class):
converter = self.model_form_converter(self) converter = self.model_form_converter(self)
inline_converter = self.inline_model_form_converter(self) inline_converter = self.inline_model_form_converter(self)
...@@ -382,6 +402,40 @@ class ModelView(BaseModelView): ...@@ -382,6 +402,40 @@ class ModelView(BaseModelView):
return True return True
def update_list_model(self, form):
"""
Update model from the list view.
Only supports updating a single field at a time.
:param form:
Form instance
"""
try:
model = self.model()
for field in form:
# FieldList's last_index will only be set if a field is submitted
# last_index will be the primary key of the updated record
if getattr(field, 'last_index', None):
record = self.get_one(field.last_index)
setattr(record, field.name, field.data.pop())
self._on_model_change(form, model, False)
record.save()
# For peewee have to save inline forms after model was saved
save_inline(form, model)
self.after_model_change(form, model, False)
return True
except Exception as ex:
if not self.handle_view_exception(ex):
log.exception(gettext('Failed to update record. %(error)s', error=str(ex)), 'error')
self.session.rollback()
# Error: Unable to update database or no records were changed.
return False
def delete_model(self, model): def delete_model(self, model):
try: try:
self.on_model_delete(model) self.on_model_delete(model)
......
...@@ -8,12 +8,15 @@ from sqlalchemy.exc import IntegrityError ...@@ -8,12 +8,15 @@ from sqlalchemy.exc import IntegrityError
from flask import flash from flask import flash
from flask.ext.admin._compat import string_types from flask.ext.admin._compat import string_types, iteritems
from flask.ext.admin.babel import gettext, ngettext, lazy_gettext from flask.ext.admin.babel import gettext, ngettext, lazy_gettext
from flask.ext.admin.model import BaseModelView from flask.ext.admin.model import BaseModelView
from flask.ext.admin.actions import action from flask.ext.admin.actions import action
from flask.ext.admin._backwards import ObsoleteAttr from flask.ext.admin._backwards import ObsoleteAttr
from flask.ext.admin.model.fields import ListEditableFieldList
from wtforms.fields.core import UnboundField
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 get_query_for_ids from .tools import get_query_for_ids
...@@ -611,6 +614,27 @@ class ModelView(BaseModelView): ...@@ -611,6 +614,27 @@ class ModelView(BaseModelView):
return form_class return form_class
def scaffold_list_form(self):
"""
Create form for the list view editable columns.
The form is created using the existing get_form(),
but each field is wrapped in a WTForms FieldList.
"""
converter = self.model_form_converter(self.session, self)
form_class = form.get_form(self.model, converter,
base_class=self.form_base_class,
only=self.column_editable_list)
# iterate FormMeta to get unbound fields
field_dict = {}
for name, field_object in iteritems(form_class.__dict__):
if not name.startswith('_') and isinstance(field_object, UnboundField):
# wrap each field in the form from get_form in FieldList
field_dict[name] = ListEditableFieldList(field_object)
return type(self.model.__name__ + 'Form', (self.form_base_class, ), field_dict)
def scaffold_inline_form_models(self, form_class): def scaffold_inline_form_models(self, form_class):
""" """
Contribute inline models to the form Contribute inline models to the form
...@@ -898,6 +922,37 @@ class ModelView(BaseModelView): ...@@ -898,6 +922,37 @@ class ModelView(BaseModelView):
return True return True
def update_list_model(self, form):
"""
Update model from the list view.
Only supports updating a single field at a time.
:param form:
Form instance
"""
try:
model = self.model()
for field in form:
# FieldList's last_index will only be set if a field is submitted
# last_index will be the primary key of the updated record
if getattr(field, 'last_index', None):
record = self.session.query(self.model).get(field.last_index)
setattr(record, field.name, field.data.pop())
self._on_model_change(form, model, False)
self.session.commit()
self.after_model_change(form, model, False)
return True
except Exception as ex:
if not self.handle_view_exception(ex):
log.exception(gettext('Failed to update record. %(error)s', error=str(ex)), 'error')
self.session.rollback()
# Error: Unable to update database or no records were changed.
return False
def delete_model(self, model): def delete_model(self, model):
""" """
Delete model. Delete model.
......
...@@ -11,7 +11,8 @@ from flask.ext.admin.base import BaseView, expose ...@@ -11,7 +11,8 @@ from flask.ext.admin.base import BaseView, expose
from flask.ext.admin.form import BaseForm, FormOpts, rules from flask.ext.admin.form import BaseForm, FormOpts, rules
from flask.ext.admin.model import filters, typefmt from flask.ext.admin.model import filters, typefmt
from flask.ext.admin.actions import ActionsMixin from flask.ext.admin.actions import ActionsMixin
from flask.ext.admin.helpers import get_form_data, validate_form_on_submit, get_redirect_target from flask.ext.admin.helpers import (get_form_data, validate_form_on_submit,
get_redirect_target, is_form_submitted)
from flask.ext.admin.tools import rec_getattr 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, OrderedDict, as_unicode from flask.ext.admin._compat import iteritems, OrderedDict, as_unicode
...@@ -264,6 +265,16 @@ class BaseModelView(BaseView, ActionsMixin): ...@@ -264,6 +265,16 @@ class BaseModelView(BaseView, ActionsMixin):
column_searchable_list = ('name', 'email') column_searchable_list = ('name', 'email')
""" """
column_editable_list = None
"""
Collection of the columns which can be edited from the list view.
For example::
class MyModelView(BaseModelView):
column_editable_list = ('name', 'last_name')
"""
column_choices = None column_choices = None
""" """
Map choices to columns in list view Map choices to columns in list view
...@@ -579,6 +590,12 @@ class BaseModelView(BaseView, ActionsMixin): ...@@ -579,6 +590,12 @@ class BaseModelView(BaseView, ActionsMixin):
self._create_form_class = self.get_create_form() self._create_form_class = self.get_create_form()
self._edit_form_class = self.get_edit_form() self._edit_form_class = self.get_edit_form()
# List View In-Line Editing
if self.column_editable_list:
self._list_form_class = self.scaffold_list_form()
else:
self.column_editable_list = {}
def _refresh_filters_cache(self): def _refresh_filters_cache(self):
self._filters = self.get_filters() self._filters = self.get_filters()
...@@ -833,6 +850,12 @@ class BaseModelView(BaseView, ActionsMixin): ...@@ -833,6 +850,12 @@ class BaseModelView(BaseView, ActionsMixin):
""" """
raise NotImplementedError('Please implement scaffold_form method') raise NotImplementedError('Please implement scaffold_form method')
def scaffold_list_form(self):
"""
Create form class for list view in-line editing.
"""
raise NotImplementedError('Please implement scaffold_list_form method')
def get_form(self): def get_form(self):
""" """
Get form class. Get form class.
...@@ -879,6 +902,14 @@ class BaseModelView(BaseView, ActionsMixin): ...@@ -879,6 +902,14 @@ class BaseModelView(BaseView, ActionsMixin):
""" """
return self._edit_form_class(get_form_data(), obj=obj) return self._edit_form_class(get_form_data(), obj=obj)
def list_form(self, obj=None):
"""
Instantiate model editing form for list view and return it.
Override to implement custom behavior.
"""
return self._list_form_class(get_form_data(), obj=obj)
def validate_form(self, form): def validate_form(self, form):
""" """
Validate the form on submit. Validate the form on submit.
...@@ -898,6 +929,15 @@ class BaseModelView(BaseView, ActionsMixin): ...@@ -898,6 +929,15 @@ class BaseModelView(BaseView, ActionsMixin):
""" """
return name in self._sortable_columns return name in self._sortable_columns
def is_editable(self, name):
"""
Verify if column is editable.
:param name:
Column name.
"""
return name in self.column_editable_list
def _get_column_by_idx(self, idx): def _get_column_by_idx(self, idx):
""" """
Return column index by Return column index by
...@@ -1074,6 +1114,15 @@ class BaseModelView(BaseView, ActionsMixin): ...@@ -1074,6 +1114,15 @@ class BaseModelView(BaseView, ActionsMixin):
""" """
raise NotImplementedError() raise NotImplementedError()
def update_list_model(self, form, model):
"""
Update model from the list view.
:param form:
Form instance
"""
raise NotImplementedError()
def delete_model(self, model): def delete_model(self, model):
""" """
Delete model. Delete model.
...@@ -1242,11 +1291,45 @@ class BaseModelView(BaseView, ActionsMixin): ...@@ -1242,11 +1291,45 @@ class BaseModelView(BaseView, ActionsMixin):
raise NotImplementedError() raise NotImplementedError()
# Views # Views
@expose('/') @expose('/', methods=('POST', 'GET'))
def index_view(self): def index_view(self):
""" """
List view List view
""" """
if self.column_editable_list:
form = self.list_form()
# prevent validation issues due to submitting a single field
# delete all fields except the field being submitted
if is_form_submitted():
for field in form:
# only submitted fields have last_index
if getattr(field, 'last_index', None):
pass
elif field.name == 'csrf_token':
pass
else:
form.__delitem__(field.name)
if self.validate_form(form):
if self.update_list_model(form):
return gettext('Record was successfully saved.')
else:
# No records changed, or error saving to database.
return gettext('Failed to update record. %(error)s',
error=''), 500
if form.errors:
for field in form:
for error in field.errors:
# return error to x-editable
if isinstance(error, list):
return ", ".join(error), 500
else:
return error, 500
else:
form = None
# Grab parameters from URL # Grab parameters from URL
view_args = self._get_list_extra_args() view_args = self._get_list_extra_args()
...@@ -1289,37 +1372,47 @@ class BaseModelView(BaseView, ActionsMixin): ...@@ -1289,37 +1372,47 @@ class BaseModelView(BaseView, ActionsMixin):
search=None, search=None,
filters=None)) filters=None))
return self.render(self.list_template, return self.render(
data=data, self.list_template,
# List data=data,
list_columns=self._list_columns, form=form,
sortable_columns=self._sortable_columns,
# Stuff # List
enumerate=enumerate, list_columns=self._list_columns,
get_pk_value=self.get_pk_value, sortable_columns=self._sortable_columns,
get_value=self.get_list_value, editable_columns=self.column_editable_list,
return_url=self._get_list_url(view_args),
# Pagination # Pagination
count=count, count=count,
pager_url=pager_url, pager_url=pager_url,
num_pages=num_pages, num_pages=num_pages,
page=view_args.page, page=view_args.page,
# Sorting
sort_column=view_args.sort, # Sorting
sort_desc=view_args.sort_desc, sort_column=view_args.sort,
sort_url=sort_url, sort_desc=view_args.sort_desc,
# Search sort_url=sort_url,
search_supported=self._search_supported,
clear_search_url=clear_search_url, # Search
search=view_args.search, search_supported=self._search_supported,
# Filters clear_search_url=clear_search_url,
filters=self._filters, search=view_args.search,
filter_groups=self._filter_groups,
active_filters=view_args.filters, # Filters
filters=self._filters,
# Actions filter_groups=self._filter_groups,
actions=actions, active_filters=view_args.filters,
actions_confirmation=actions_confirmation)
# Actions
actions=actions,
actions_confirmation=actions_confirmation,
# Misc
enumerate=enumerate,
get_pk_value=self.get_pk_value,
get_value=self.get_list_value,
return_url=self._get_list_url(view_args),
)
@expose('/new/', methods=('GET', 'POST')) @expose('/new/', methods=('GET', 'POST'))
def create_view(self): def create_view(self):
......
...@@ -3,8 +3,14 @@ import itertools ...@@ -3,8 +3,14 @@ import itertools
from wtforms.validators import ValidationError from wtforms.validators import ValidationError
from wtforms.fields import FieldList, FormField, SelectFieldBase from wtforms.fields import FieldList, FormField, SelectFieldBase
try:
from wtforms.fields import _unset_value as unset_value
except ImportError:
from wtforms.utils import unset_value
from flask.ext.admin._compat import iteritems from flask.ext.admin._compat import iteritems
from .widgets import InlineFieldListWidget, InlineFormWidget, AjaxSelect2Widget from .widgets import (InlineFieldListWidget, InlineFormWidget,
AjaxSelect2Widget, XEditableWidget)
class InlineFieldList(FieldList): class InlineFieldList(FieldList):
...@@ -120,6 +126,54 @@ class InlineModelFormField(FormField): ...@@ -120,6 +126,54 @@ class InlineModelFormField(FormField):
field.populate_obj(obj, name) field.populate_obj(obj, name)
class ListEditableFieldList(FieldList):
"""
Modified FieldList to allow for alphanumeric primary keys.
Used in the editable list view.
"""
widget = XEditableWidget()
def __init__(self, *args, **kwargs):
super(ListEditableFieldList, self).__init__(*args, **kwargs)
# min_entries = 1 is required for the widget to determine the type
self.min_entries = 1
def _extract_indices(self, prefix, formdata):
offset = len(prefix) + 1
for k in formdata:
if k.startswith(prefix):
k = k[offset:].split('-', 1)[0]
# removed "if k.isdigit():"
yield k
def _add_entry(self, formdata=None, data=unset_value, index=None):
assert not self.max_entries or len(self.entries) < self.max_entries, \
'You cannot have more than max_entries entries in this FieldList'
if index is None:
index = self.last_index + 1
self.last_index = index
# '%s-%s' instead of '%s-%d' to allow alphanumeric
name = '%s-%s' % (self.short_name, index)
id = '%s-%s' % (self.id, index)
# support both wtforms 1 and 2
meta = getattr(self, 'meta', None)
if meta:
field = self.unbound_field.bind(
form=None, name=name, prefix=self._prefix, id=id, _meta=meta
)
else:
field = self.unbound_field.bind(
form=None, name=name, prefix=self._prefix, id=id
)
field.process(formdata, data)
self.entries.append(field)
return field
class AjaxSelectField(SelectFieldBase): class AjaxSelectField(SelectFieldBase):
""" """
Ajax Model Select Field Ajax Model Select Field
......
...@@ -61,3 +61,81 @@ class AjaxSelect2Widget(object): ...@@ -61,3 +61,81 @@ class AjaxSelect2Widget(object):
kwargs.setdefault('data-placeholder', placeholder) kwargs.setdefault('data-placeholder', placeholder)
return HTMLString('<input %s>' % html_params(name=field.name, **kwargs)) return HTMLString('<input %s>' % html_params(name=field.name, **kwargs))
class XEditableWidget(object):
"""
WTForms widget that provides in-line editing for the list view.
Determines how to display the x-editable/ajax form based on the
field inside of the FieldList (StringField, IntegerField, etc).
"""
def __call__(self, field, **kwargs):
value = kwargs.pop("value", "")
kwargs.setdefault('data-role', 'x-editable')
kwargs.setdefault('data-url', './')
kwargs.setdefault('id', field.id)
kwargs.setdefault('name', field.name)
kwargs.setdefault('href', '#')
if not kwargs.get('pk'):
raise Exception('pk required')
kwargs['data-pk'] = str(kwargs.pop("pk"))
kwargs['data-csrf'] = kwargs.pop("csrf", "")
# get first entry from FieldList to determine field type
subfield = field.entries[0]
if subfield.type == 'StringField':
kwargs['data-type'] = 'text'
elif subfield.type == 'TextAreaField':
kwargs['data-type'] = 'textarea'
kwargs['data-rows'] = '5'
elif subfield.type == 'BooleanField':
kwargs['data-type'] = 'select'
# data-source = dropdown options
kwargs['data-source'] = {'': 'False', '1': 'True'}
kwargs['data-role'] = 'x-editable-boolean'
elif subfield.type == 'Select2Field':
kwargs['data-type'] = 'select'
kwargs['data-source'] = dict(subfield.choices)
elif subfield.type in ['QuerySelectField', 'ModelSelectField']:
kwargs['data-type'] = 'select'
# MongoEngine throws an error on blank object names
# this prevents "TypeError: cannot create 'NoneType' instances"
choices = {}
for choice in subfield:
try:
choices[str(choice._value())] = str(choice.label.text)
except TypeError:
choices[str(choice._value())] = ""
kwargs['data-source'] = choices
elif subfield.type == 'DateField':
kwargs['data-type'] = 'combodate'
kwargs['data-format'] = 'YYYY-MM-DD'
kwargs['data-template'] = 'YYYY-MM-DD'
elif subfield.type == 'DateTimeField':
kwargs['data-type'] = 'combodate'
kwargs['data-format'] = 'YYYY-MM-DD HH:mm:ss'
kwargs['data-template'] = 'YYYY-MM-DD HH:mm:ss'
# x-editable-combodate uses 1 minute increments
kwargs['data-role'] = 'x-editable-combodate'
elif subfield.type == 'TimeField':
kwargs['data-type'] = 'combodate'
kwargs['data-format'] = 'HH:mm:ss'
kwargs['data-template'] = 'HH:mm:ss'
kwargs['data-role'] = 'x-editable-combodate'
elif subfield.type == 'IntegerField':
kwargs['data-type'] = 'number'
elif subfield.type in ['DecimalField', 'FloatField']:
kwargs['data-type'] = 'number'
kwargs['data-step'] = 'any'
else:
raise Exception('Unsupported field type: %s' % (type(subfield),))
return HTMLString(
'<a %s>%s</a>' % (html_params(**kwargs), value)
)
...@@ -241,6 +241,17 @@ ...@@ -241,6 +241,17 @@
return true; return true;
} }
// make x-editable's POST act like a normal FieldList field
// for x-editable, x-editable-combodate, and x-editable-boolean cases
var overrideXeditableParams = function(params) {
var newParams = {};
newParams[params.name + '-' + params.pk] = params.value;
if ($(this).data('csrf')) {
newParams['csrf_token'] = $(this).data('csrf');
}
return newParams;
}
switch (name) { switch (name) {
case 'select2': case 'select2':
var opts = { var opts = {
...@@ -390,6 +401,32 @@ ...@@ -390,6 +401,32 @@
case 'leaflet': case 'leaflet':
processLeafletWidget($el, name); processLeafletWidget($el, name);
return true; return true;
case 'x-editable':
$el.editable({params: overrideXeditableParams});
return true;
case 'x-editable-combodate':
$el.editable({
params: overrideXeditableParams,
combodate: {
// prevent minutes from showing in 5 minute increments
minuteStep: 1
}
});
return true;
case 'x-editable-boolean':
$el.editable({
params: overrideXeditableParams,
display: function(value, sourceData, response) {
// display new boolean value as an icon
if(response) {
if(value == '1') {
$(this).html('<span class="glyphicon glyphicon-ok-circle icon-ok-circle"></span>');
} else {
$(this).html('<span class="glyphicon glyphicon-minus-sign icon-minus-sign"></span>');
}
}
}
});
} }
}; };
...@@ -463,7 +500,7 @@ ...@@ -463,7 +500,7 @@
this.applyGlobalStyles = function(parent) { this.applyGlobalStyles = function(parent) {
var self = this; var self = this;
$(':input[data-role]', parent).each(function() { $(':input[data-role], a[data-role]', parent).each(function() {
var $el = $(this); var $el = $(this);
self.applyStyle($el, $el.attr('data-role')); self.applyStyle($el, $el.attr('data-role'));
}); });
......
...@@ -178,6 +178,9 @@ ...@@ -178,6 +178,9 @@
<link href="{{ admin_static.url(filename='vendor/leaflet/leaflet.css') }}" rel="stylesheet"> <link href="{{ admin_static.url(filename='vendor/leaflet/leaflet.css') }}" rel="stylesheet">
<link href="{{ admin_static.url(filename='vendor/leaflet/leaflet.draw.css') }}" rel="stylesheet"> <link href="{{ admin_static.url(filename='vendor/leaflet/leaflet.draw.css') }}" rel="stylesheet">
{% endif %} {% endif %}
{% if editable_columns %}
<link href="{{ admin_static.url(filename='vendor/x-editable/css/bootstrap2-editable-1.5.1.css') }}" rel="stylesheet">
{% endif %}
{% endmacro %} {% endmacro %}
{% macro form_js() %} {% macro form_js() %}
...@@ -189,5 +192,8 @@ ...@@ -189,5 +192,8 @@
<script src="{{ admin_static.url(filename='vendor/leaflet/leaflet.draw.js') }}"></script> <script src="{{ admin_static.url(filename='vendor/leaflet/leaflet.draw.js') }}"></script>
{% endif %} {% endif %}
<script src="{{ admin_static.url(filename='vendor/bootstrap-daterangepicker/daterangepicker.js') }}"></script> <script src="{{ admin_static.url(filename='vendor/bootstrap-daterangepicker/daterangepicker.js') }}"></script>
{% if editable_columns %}
<script src="{{ admin_static.url(filename='vendor/x-editable/js/bootstrap2-editable-1.5.1.min.js') }}"></script>
{% endif %}
<script src="{{ admin_static.url(filename='admin/js/form-1.0.0.js') }}"></script> <script src="{{ admin_static.url(filename='admin/js/form-1.0.0.js') }}"></script>
{% endmacro %} {% endmacro %}
...@@ -119,8 +119,17 @@ ...@@ -119,8 +119,17 @@
{% endblock %} {% endblock %}
</td> </td>
{% endblock %} {% endblock %}
{% for c, name in list_columns %} {% for c, name in list_columns %}
<td>{{ get_value(row, c) }}</td> {% if admin_view.is_editable(c) %}
{% if form.csrf_token %}
<td>{{ form[c](pk=get_pk_value(row), value=get_value(row, c), csrf=form.csrf_token._value()) }}</td>
{% else %}
<td>{{ form[c](pk=get_pk_value(row), value=get_value(row, c)) }}</td>
{% endif %}
{% else %}
<td>{{ get_value(row, c) }}</td>
{% endif %}
{% endfor %} {% endfor %}
{% endblock %} {% endblock %}
</tr> </tr>
...@@ -148,8 +157,8 @@ ...@@ -148,8 +157,8 @@
<script src="{{ admin_static.url(filename='admin/js/filters-1.0.0.js') }}"></script> <script src="{{ admin_static.url(filename='admin/js/filters-1.0.0.js') }}"></script>
{{ actionlib.script(_gettext('Please select at least one record.'), {{ actionlib.script(_gettext('Please select at least one record.'),
actions, actions,
actions_confirmation) }} actions_confirmation) }}
<script language="javascript"> <script language="javascript">
(function($) { (function($) {
......
...@@ -147,13 +147,13 @@ ...@@ -147,13 +147,13 @@
<hr> <hr>
<div class="form-group"> <div class="form-group">
<div class="col-md-offset-2 col-md-10 submit-row"> <div class="col-md-offset-2 col-md-10 submit-row">
<input type="submit" class="btn btn-primary" value="{{ _gettext('Submit') }}" /> <input type="submit" class="btn btn-primary" value="{{ _gettext('Submit') }}" />
{% if extra %} {% if extra %}
{{ extra }} {{ extra }}
{% endif %} {% endif %}
{% if cancel_url %} {% if cancel_url %}
<a href="{{ cancel_url }}" class="btn btn-danger" role="button">{{ _gettext('Cancel') }}</a> <a href="{{ cancel_url }}" class="btn btn-danger" role="button">{{ _gettext('Cancel') }}</a>
{% endif %} {% endif %}
</div> </div>
</div> </div>
{% endmacro %} {% endmacro %}
...@@ -173,6 +173,9 @@ ...@@ -173,6 +173,9 @@
<link href="{{ admin_static.url(filename='vendor/leaflet/leaflet.css') }}" rel="stylesheet"> <link href="{{ admin_static.url(filename='vendor/leaflet/leaflet.css') }}" rel="stylesheet">
<link href="{{ admin_static.url(filename='vendor/leaflet/leaflet.draw.css') }}" rel="stylesheet"> <link href="{{ admin_static.url(filename='vendor/leaflet/leaflet.draw.css') }}" rel="stylesheet">
{% endif %} {% endif %}
{% if editable_columns %}
<link href="{{ admin_static.url(filename='vendor/x-editable/css/bootstrap3-editable-1.5.1.css') }}" rel="stylesheet">
{% endif %}
{% endmacro %} {% endmacro %}
{% macro form_js() %} {% macro form_js() %}
...@@ -184,5 +187,8 @@ ...@@ -184,5 +187,8 @@
<script src="{{ admin_static.url(filename='vendor/leaflet/leaflet.draw.js') }}"></script> <script src="{{ admin_static.url(filename='vendor/leaflet/leaflet.draw.js') }}"></script>
{% endif %} {% endif %}
<script src="{{ admin_static.url(filename='vendor/bootstrap-daterangepicker/daterangepicker.js') }}"></script> <script src="{{ admin_static.url(filename='vendor/bootstrap-daterangepicker/daterangepicker.js') }}"></script>
{% if editable_columns %}
<script src="{{ admin_static.url(filename='vendor/x-editable/js/bootstrap3-editable-1.5.1.min.js') }}"></script>
{% endif %}
<script src="{{ admin_static.url(filename='admin/js/form-1.0.0.js') }}"></script> <script src="{{ admin_static.url(filename='admin/js/form-1.0.0.js') }}"></script>
{% endmacro %} {% endmacro %}
...@@ -120,7 +120,15 @@ ...@@ -120,7 +120,15 @@
</td> </td>
{% endblock %} {% endblock %}
{% for c, name in list_columns %} {% for c, name in list_columns %}
<td>{{ get_value(row, c) }}</td> {% if admin_view.is_editable(c) %}
{% if form.csrf_token %}
<td>{{ form[c](pk=get_pk_value(row), value=get_value(row, c), csrf=form.csrf_token._value()) }}</td>
{% else %}
<td>{{ form[c](pk=get_pk_value(row), value=get_value(row, c)) }}</td>
{% endif %}
{% else %}
<td>{{ get_value(row, c) }}</td>
{% endif %}
{% endfor %} {% endfor %}
{% endblock %} {% endblock %}
</tr> </tr>
...@@ -148,8 +156,8 @@ ...@@ -148,8 +156,8 @@
{{ lib.form_js() }} {{ lib.form_js() }}
{{ actionlib.script(_gettext('Please select at least one record.'), {{ actionlib.script(_gettext('Please select at least one record.'),
actions, actions,
actions_confirmation) }} actions_confirmation) }}
<script language="javascript"> <script language="javascript">
(function($) { (function($) {
......
...@@ -52,6 +52,22 @@ def create_models(db): ...@@ -52,6 +52,22 @@ def create_models(db):
return Model1, Model2 return Model1, Model2
def fill_db(Model1, Model2):
Model1('test1_val_1', 'test2_val_1').save()
Model1('test1_val_2', 'test2_val_2').save()
Model1('test1_val_3', 'test2_val_3').save()
Model1('test1_val_4', 'test2_val_4').save()
Model1(None, 'empty_obj').save()
Model2('string_field_val_1', None, None).save()
Model2('string_field_val_2', None, None).save()
Model2('string_field_val_3', 5000, 25.9).save()
Model2('string_field_val_4', 9000, 75.5).save()
Model1('datetime_obj1', datetime_field=datetime(2014,4,3,1,9,0)).save()
Model1('datetime_obj2', datetime_field=datetime(2013,3,2,0,8,0)).save()
def test_model(): def test_model():
app, db, admin = setup() app, db, admin = setup()
...@@ -124,25 +140,72 @@ def test_model(): ...@@ -124,25 +140,72 @@ def test_model():
eq_(rv.status_code, 302) eq_(rv.status_code, 302)
eq_(Model1.objects.count(), 0) eq_(Model1.objects.count(), 0)
def test_column_editable_list():
app, db, admin = setup()
Model1, Model2 = create_models(db)
view = CustomModelView(Model1,
column_editable_list=[
'test1', 'datetime_field'])
admin.add_view(view)
fill_db(Model1, Model2)
client = app.test_client()
# Test in-line edit field rendering
rv = client.get('/admin/model1/')
data = rv.data.decode('utf-8')
ok_('data-role="x-editable"' in data)
# Form - Test basic in-line edit functionality
obj1 = Model1.objects.get(test1 = 'test1_val_3')
rv = client.post('/admin/model1/', data={
'test1-' + str(obj1.id): 'change-success-1',
})
data = rv.data.decode('utf-8')
ok_('Record was successfully saved.' == data)
# confirm the value has changed
rv = client.get('/admin/model1/')
data = rv.data.decode('utf-8')
ok_('change-success-1' in data)
# Test errors
obj2 = Model1.objects.get(test1 = 'datetime_obj1')
rv = client.post('/admin/model1/', data={
'datetime_field-' + str(obj2.id): 'problematic-input',
})
eq_(rv.status_code, 500)
view = CustomModelView(Model2,
column_editable_list=[
'model1'])
admin.add_view(view)
# Test in-line editing for relations
obj3 = Model2.objects.get(string_field = 'string_field_val_1')
rv = client.post('/admin/model2/', data={
'model1-' + str(obj3.id): str(obj1.id),
})
data = rv.data.decode('utf-8')
ok_('Record was successfully saved.' == data)
# confirm the value has changed
rv = client.get('/admin/model2/')
data = rv.data.decode('utf-8')
ok_('test1_val_1' in data)
def test_column_filters(): def test_column_filters():
app, db, admin = setup() app, db, admin = setup()
Model1, Model2 = create_models(db) Model1, Model2 = create_models(db)
# fill DB with values # fill DB with values
Model1('test1_val_1', 'test2_val_1').save() fill_db(Model1, Model2)
Model1('test1_val_2', 'test2_val_2').save()
Model1('test1_val_3', 'test2_val_3').save()
Model1('test1_val_4', 'test2_val_4').save()
Model1(None, 'empty_obj').save()
Model2('string_field_val_1', None, None).save()
Model2('string_field_val_2', None, None).save()
Model2('string_field_val_3', 5000, 25.9).save()
Model2('string_field_val_4', 9000, 75.5).save()
Model1('datetime_obj1', datetime_field=datetime(2014,4,3,1,9,0)).save()
Model1('datetime_obj2', datetime_field=datetime(2013,3,2,0,8,0)).save()
# Test string filter # Test string filter
view = CustomModelView(Model1, column_filters=['test1']) view = CustomModelView(Model1, column_filters=['test1'])
......
...@@ -63,25 +63,49 @@ def create_models(db): ...@@ -63,25 +63,49 @@ def create_models(db):
class Model2(BaseModel): class Model2(BaseModel):
def __init__(self, char_field=None, int_field=None, float_field=None, def __init__(self, char_field=None, int_field=None, float_field=None,
bool_field=0): bool_field=0, model1=None):
super(Model2, self).__init__() super(Model2, self).__init__()
self.char_field = char_field self.char_field = char_field
self.int_field = int_field self.int_field = int_field
self.float_field = float_field self.float_field = float_field
self.bool_field = bool_field self.bool_field = bool_field
self.model1 = model1
char_field = peewee.CharField(max_length=20) char_field = peewee.CharField(max_length=20)
int_field = peewee.IntegerField(null=True) int_field = peewee.IntegerField(null=True)
float_field = peewee.FloatField(null=True) float_field = peewee.FloatField(null=True)
bool_field = peewee.BooleanField() bool_field = peewee.BooleanField()
# Relation
model1 = peewee.ForeignKeyField(Model1, null=True)
Model1.create_table() Model1.create_table()
Model2.create_table() Model2.create_table()
return Model1, Model2 return Model1, Model2
def fill_db(Model1, Model2):
Model1('test1_val_1', 'test2_val_1').save()
Model1('test1_val_2', 'test2_val_2').save()
Model1('test1_val_3', 'test2_val_3').save()
Model1('test1_val_4', 'test2_val_4').save()
Model1(None, 'empty_obj').save()
Model2('char_field_val_1', None, None).save()
Model2('char_field_val_2', None, None).save()
Model2('char_field_val_3', 5000, 25.9).save()
Model2('char_field_val_4', 9000, 75.5).save()
Model1('date_obj1', date_field=date(2014,11,17)).save()
Model1('date_obj2', date_field=date(2013,10,16)).save()
Model1('timeonly_obj1', timeonly_field=time(11,10,9)).save()
Model1('timeonly_obj2', timeonly_field=time(10,9,8)).save()
Model1('datetime_obj1', datetime_field=datetime(2014,4,3,1,9,0)).save()
Model1('datetime_obj2', datetime_field=datetime(2013,3,2,0,8,0)).save()
def test_model(): def test_model():
app, db, admin = setup() app, db, admin = setup()
Model1, Model2 = create_models(db) Model1, Model2 = create_models(db)
...@@ -153,29 +177,63 @@ def test_model(): ...@@ -153,29 +177,63 @@ def test_model():
eq_(rv.status_code, 302) eq_(rv.status_code, 302)
eq_(Model1.select().count(), 0) eq_(Model1.select().count(), 0)
def test_column_editable_list():
app, db, admin = setup()
Model1, Model2 = create_models(db)
view = CustomModelView(Model1,
column_editable_list=[
'test1', 'enum_field'])
admin.add_view(view)
fill_db(Model1, Model2)
client = app.test_client()
# Test in-line edit field rendering
rv = client.get('/admin/model1/')
data = rv.data.decode('utf-8')
ok_('data-role="x-editable"' in data)
# Form - Test basic in-line edit functionality
rv = client.post('/admin/model1/', data={
'test1-1': 'change-success-1',
})
data = rv.data.decode('utf-8')
ok_('Record was successfully saved.' == data)
# ensure the value has changed
rv = client.get('/admin/model1/')
data = rv.data.decode('utf-8')
ok_('change-success-1' in data)
# Test errors
rv = client.post('/admin/model1/', data={
'enum_field-1': 'problematic-input',
})
eq_(rv.status_code, 500)
view = CustomModelView(Model2,
column_editable_list=[
'model1'])
admin.add_view(view)
# Test in-line editing for relations
rv = client.post('/admin/model2/', data={
'model1-1': '3',
})
data = rv.data.decode('utf-8')
ok_('Record was successfully saved.' == data)
def test_column_filters(): def test_column_filters():
app, db, admin = setup() app, db, admin = setup()
Model1, Model2 = create_models(db) Model1, Model2 = create_models(db)
# fill DB with values fill_db(Model1, Model2)
Model1('test1_val_1', 'test2_val_1').save()
Model1('test1_val_2', 'test2_val_2').save()
Model1('test1_val_3', 'test2_val_3').save()
Model1('test1_val_4', 'test2_val_4').save()
Model1(None, 'empty_obj').save()
Model2('char_field_val_1', None, None).save()
Model2('char_field_val_2', None, None).save()
Model2('char_field_val_3', 5000, 25.9).save()
Model2('char_field_val_4', 9000, 75.5).save()
Model1('date_obj1', date_field=date(2014,11,17)).save()
Model1('date_obj2', date_field=date(2013,10,16)).save()
Model1('timeonly_obj1', timeonly_field=time(11,10,9)).save()
Model1('timeonly_obj2', timeonly_field=time(10,9,8)).save()
Model1('datetime_obj1', datetime_field=datetime(2014,4,3,1,9,0)).save()
Model1('datetime_obj2', datetime_field=datetime(2013,3,2,0,8,0)).save()
# Test string filter # Test string filter
view = CustomModelView(Model1, column_filters=['test1']) view = CustomModelView(Model1, column_filters=['test1'])
......
...@@ -81,6 +81,39 @@ def create_models(db): ...@@ -81,6 +81,39 @@ def create_models(db):
return Model1, Model2 return Model1, Model2
def fill_db(db, Model1, Model2):
model1_obj1 = Model1('test1_val_1', 'test2_val_1', bool_field=True)
model1_obj2 = Model1('test1_val_2', 'test2_val_2')
model1_obj3 = Model1('test1_val_3', 'test2_val_3')
model1_obj4 = Model1('test1_val_4', 'test2_val_4')
model2_obj1 = Model2('test2_val_1', model1=model1_obj1, float_field=None)
model2_obj2 = Model2('test2_val_2', model1=model1_obj2, float_field=None)
model2_obj3 = Model2('test2_val_3', int_field=5000, float_field=25.9)
model2_obj4 = Model2('test2_val_4', int_field=9000, float_field=75.5)
date_obj1 = Model1('date_obj1', date_field=date(2014,11,17))
date_obj2 = Model1('date_obj2', date_field=date(2013,10,16))
timeonly_obj1 = Model1('timeonly_obj1', time_field=time(11,10,9))
timeonly_obj2 = Model1('timeonly_obj2', time_field=time(10,9,8))
datetime_obj1 = Model1('datetime_obj1', datetime_field=datetime(2014,4,3,1,9,0))
datetime_obj2 = Model1('datetime_obj2', datetime_field=datetime(2013,3,2,0,8,0))
enum_obj1 = Model1('enum_obj1', enum_field="model1_v1")
enum_obj2 = Model1('enum_obj2', enum_field="model1_v2")
empty_obj = Model1(test2="empty_obj")
db.session.add_all([
model1_obj1, model1_obj2, model1_obj3, model1_obj4,
model2_obj1, model2_obj2, model2_obj3, model2_obj4,
date_obj1, timeonly_obj1, datetime_obj1,
date_obj2, timeonly_obj2, datetime_obj2,
enum_obj1, enum_obj2, empty_obj
])
db.session.commit()
def test_model(): def test_model():
app, db, admin = setup() app, db, admin = setup()
Model1, Model2 = create_models(db) Model1, Model2 = create_models(db)
...@@ -286,6 +319,56 @@ def test_complex_searchable_list_missing_children(): ...@@ -286,6 +319,56 @@ def test_complex_searchable_list_missing_children():
ok_('magic string' in data) ok_('magic string' in data)
def test_column_editable_list():
app, db, admin = setup()
Model1, Model2 = create_models(db)
view = CustomModelView(Model1, db.session,
column_editable_list=[
'test1', 'enum_field'])
admin.add_view(view)
fill_db(db, Model1, Model2)
client = app.test_client()
# Test in-line edit field rendering
rv = client.get('/admin/model1/')
data = rv.data.decode('utf-8')
ok_('data-role="x-editable"' in data)
# Form - Test basic in-line edit functionality
rv = client.post('/admin/model1/', data={
'test1-1': 'change-success-1',
})
data = rv.data.decode('utf-8')
ok_('Record was successfully saved.' == data)
# ensure the value has changed
rv = client.get('/admin/model1/')
data = rv.data.decode('utf-8')
ok_('change-success-1' in data)
# Test errors
rv = client.post('/admin/model1/', data={
'enum_field-1': 'problematic-input',
})
eq_(rv.status_code, 500)
view = CustomModelView(Model2, db.session,
column_editable_list=[
'model1'])
admin.add_view(view)
# Test in-line editing for relations
rv = client.post('/admin/model2/', data={
'model1-1': '3',
})
data = rv.data.decode('utf-8')
ok_('Record was successfully saved.' == data)
def test_column_filters(): def test_column_filters():
app, db, admin = setup() app, db, admin = setup()
...@@ -393,37 +476,7 @@ def test_column_filters(): ...@@ -393,37 +476,7 @@ def test_column_filters():
eq_(list(view._filter_groups.keys()), [u'Test Filter #1', u'Test Filter #2']) eq_(list(view._filter_groups.keys()), [u'Test Filter #1', u'Test Filter #2'])
# Fill DB fill_db(db, Model1, Model2)
model1_obj1 = Model1('test1_val_1', 'test2_val_1', bool_field=True)
model1_obj2 = Model1('test1_val_2', 'test2_val_2')
model1_obj3 = Model1('test1_val_3', 'test2_val_3')
model1_obj4 = Model1('test1_val_4', 'test2_val_4')
model2_obj1 = Model2('test2_val_1', model1=model1_obj1, float_field=None)
model2_obj2 = Model2('test2_val_2', model1=model1_obj2, float_field=None)
model2_obj3 = Model2('test2_val_3', int_field=5000, float_field=25.9)
model2_obj4 = Model2('test2_val_4', int_field=9000, float_field=75.5)
date_obj1 = Model1('date_obj1', date_field=date(2014,11,17))
date_obj2 = Model1('date_obj2', date_field=date(2013,10,16))
timeonly_obj1 = Model1('timeonly_obj1', time_field=time(11,10,9))
timeonly_obj2 = Model1('timeonly_obj2', time_field=time(10,9,8))
datetime_obj1 = Model1('datetime_obj1', datetime_field=datetime(2014,4,3,1,9,0))
datetime_obj2 = Model1('datetime_obj2', datetime_field=datetime(2013,3,2,0,8,0))
enum_obj1 = Model1('enum_obj1', enum_field="model1_v1")
enum_obj2 = Model1('enum_obj2', enum_field="model1_v2")
empty_obj = Model1(test2="empty_obj")
db.session.add_all([
model1_obj1, model1_obj2, model1_obj3, model1_obj4,
model2_obj1, model2_obj2, model2_obj3, model2_obj4,
date_obj1, timeonly_obj1, datetime_obj1,
date_obj2, timeonly_obj2, datetime_obj2,
enum_obj1, enum_obj2, empty_obj
])
db.session.commit()
client = app.test_client() client = app.test_client()
......
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