Commit 9f9fee13 authored by Alex Kerney's avatar Alex Kerney

Merge pull request #2 from mrjoes/master

Up to date
parents 94a10946 08a4de57
......@@ -8,6 +8,8 @@ Development Lead
Patches and Suggestions
```````````````````````
- Paul Brown <paul90brown@gmail.com>
- Petrus Janse van Rensburg <petrus.jvrensburg@gmail.com>
- Priit Laes <plaes@plaes.org>
- Sean Lynch
- Andy Wilson <wilson.andrew.j+github@gmail.com>
......
This diff is collapsed.
#!/bin/sh
pybabel extract -F babel.ini -k _gettext -k _ngettext -k lazy_gettext -o admin.pot --project Flask-Admin ../flask_admin
pybabel compile -D admin -d ../flask_admin/translations/
pybabel compile -f -D admin -d ../flask_admin/translations/
Changelog
=========
1.1 (dev)
---------
1.1.0
-----
Mostly bug fix release. Highlights:
* Inline model editing on the list page
* FileAdmin refactoring and fixes
* FileUploadField and ImageUploadField will work with Required() validator
* Bug fixes
1.0.9
-----
Highlights:
* Added the ``geoa`` contrib module, for working with `geoalchemy2`_
* Bootstrap 3 support
* WTForms 2.x support
* Updated DateTime picker
* SQLAlchemy backend: support for complex sortables, ability to search for related models, model inheritance support
* Customizable URL generation logic for all views
* New generic filter types: in list, empty, date range
* Added the ``geoa`` contrib module, for working with `geoalchemy2 <http://geoalchemy-2.readthedocs.org/>`_
* Portugese translation
* Lots of bug fixes
.. _geoalchemy2: http://geoalchemy-2.readthedocs.org/
1.0.8
-----
......@@ -43,17 +61,3 @@ Highlights:
* Redis cli
* SQLAlchemy backend can handle inherited models with multiple PKs
* Lots of bug fixes
1.0.6
-----
* Model views now support default sorting order
* Model type/column formatters now accept additional `view` parameter
* `is_visible` for administrative views
* Model views have `after_model_change` method that can be overridden
* In model views, `get_query` was split into `get_count_query` and `get_query`
* Bootstrap 2.3.1
* Bulk deletes go through `delete_model`
* Flask-Admin no longer uses floating navigation bar
* Translations: French, Persian (Farsi), Chinese (Simplified/Traditional), Czech
* Bug fixes
......@@ -37,14 +37,11 @@ Creating simple model
---------------------
GeoAlchemy comes with a `Geometry`_ field that is carefully divorced from the
`Shapely`_ library. Flask-Admin takes the approach that if you're using spatial
objects in your database, and you want an admin interface to edit those objects,
you're probably already using Shapely, so we provide a Geometry field that is
integrated with Shapely objects. To make your admin interface works, be sure to
use this field rather that the one that ships with GeoAlchemy when defining your
models::
from flask.ext.admin.contrib.geoa.sqltypes import Geometry
`Shapely`_ library. Flask-Admin will use this field so that there are no
changes necessary to other code. ``ModelView`` should be imported from
``geoa`` rather than the one imported from ``sqla``::
from geoalchemy2 import Geometry
from flask.ext.admin.contrib.geoa import ModelView
# .. flask initialization
......@@ -62,9 +59,6 @@ models::
db.create_all()
app.run('0.0.0.0', 8000)
Note that you also have to use the ``ModelView`` class imported from ``geoa``,
rather than the one imported from ``sqla``.
Limitations
-----------
......
......@@ -58,13 +58,36 @@ Form Rendering Rule Description
Enabling CSRF Validation
---------------
Adding CSRF validation will require overriding the :class:`flask.ext.admin.form.BaseForm` by using :attr:`flask.ext.admin.model.BaseModelView.form_base_class`.
Flask-Admin does not use Flask-WTF Form class - it uses the wtforms Form class, which does not have CSRF validation.
Adding CSRF validation will require importing flask_wtf and overriding the :class:`flask.ext.admin.form.BaseForm` by using :attr:`flask.ext.admin.model.BaseModelView.form_base_class`::
WTForms >=2::
from wtforms.csrf.session import SessionCSRF
from wtforms.meta import DefaultMeta
from flask import session
from datetime import timedelta
from flask.ext.admin import form
from flask.ext.admin.contrib import sqla
class SecureForm(form.BaseForm):
class Meta(DefaultMeta):
csrf = True
csrf_class = SessionCSRF
csrf_secret = b'EPj00jpfj8Gx1SjnyLxwBBSQfnQ9DJYe0Ym'
csrf_time_limit = timedelta(minutes=20)
@property
def csrf_context(self):
return session
class ModelAdmin(sqla.ModelView):
form_base_class = SecureForm
For WTForms 1, you can use use Flask-WTF's Form class::
import os
import flask
**import flask_wtf**
import flask_wtf
import flask_admin
import flask_sqlalchemy
from flask_admin.contrib.sqla import ModelView
......@@ -74,15 +97,15 @@ Adding CSRF validation will require importing flask_wtf and overriding the :clas
app = flask.Flask(__name__)
app.config['SECRET_KEY'] = 'Dnit7qz7mfcP0YuelDrF8vLFvk0snhwP'
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///' + DBFILE
**app.config['CSRF_ENABLED'] = True**
app.config['CSRF_ENABLED'] = True
**flask_wtf.CsrfProtect(app)**
flask_wtf.CsrfProtect(app)
db = flask_sqlalchemy.SQLAlchemy(app)
admin = flask_admin.Admin(app, name='Admin')
## Here is the fix:
class MyModelView(ModelView):
**form_base_class = flask_wtf.Form**
# Here is the fix:
form_base_class = flask_wtf.Form
class User(db.Model):
id = db.Column(db.Integer, primary_key=True)
......@@ -92,7 +115,6 @@ Adding CSRF validation will require importing flask_wtf and overriding the :clas
if not os.path.exists(DBFILE):
db.create_all()
## The subclass is used here:
admin.add_view( MyModelView(User, db.session, name='User') )
app.run(debug=True)
......
......@@ -165,45 +165,18 @@ have access to the view in question::
def is_accessible(self):
return login.current_user.is_authenticated()
You can also implement policy-based security, conditionally allowing or disallowing access to parts of the
administrative interface. If a user does not have access to a particular view, the menu item won't be visible.
Generating URLs
---------------
Internally, view classes work on top of Flask blueprints, so you can use *url_for* with a dot
prefix to get the URL for a local view::
from flask import url_for
To redirect the user to another page if authentication fails, you will need to specify an *_handle_view* method::
class MyView(BaseView):
@expose('/')
def index(self)
# Get URL for the test view method
url = url_for('.test')
return self.render('index.html', url=url)
@expose('/test/')
def test(self):
return self.render('test.html')
If you want to generate a URL for a particular view method from outside, the following rules apply:
1. You can override the endpoint name by passing *endpoint* parameter to the view class constructor::
admin = Admin(app)
admin.add_view(MyView(endpoint='testadmin'))
In this case, you can generate links by concatenating the view method name with an endpoint::
url_for('testadmin.index')
2. If you don't override the endpoint name, the lower-case class name can be used for generating URLs, like in::
url_for('myview.index')
def is_accessible(self):
return login.current_user.is_authenticated()
3. For model-based views the rules differ - the model class name should be used if an endpoint name is not provided. Model-based views will be explained in the next section.
def _handle_view(self, name, **kwargs):
if not self.is_accessible():
return redirect(url_for('login', next=request.url))
You can also implement policy-based security, conditionally allowing or disallowing access to parts of the
administrative interface. If a user does not have access to a particular view, the menu item won't be visible.
Model Views
-----------
......@@ -299,6 +272,51 @@ Sample screenshot:
You can disable uploads, disable file or directory deletion, restrict file uploads to certain types and so on.
Check :mod:`flask.ext.admin.contrib.fileadmin` documentation on how to do it.
Generating URLs
---------------
Internally, view classes work on top of Flask blueprints, so you can use *url_for* with a dot
prefix to get the URL for a local view::
from flask import url_for
class MyView(BaseView):
@expose('/')
def index(self)
# Get URL for the test view method
url = url_for('.test')
return self.render('index.html', url=url)
@expose('/test/')
def test(self):
return self.render('test.html')
If you want to generate a URL for a particular view method from outside, the following rules apply:
1. You can override the endpoint name by passing *endpoint* parameter to the view class constructor::
admin = Admin(app)
admin.add_view(MyView(endpoint='testadmin'))
In this case, you can generate links by concatenating the view method name with an endpoint::
url_for('testadmin.index')
2. If you don't override the endpoint name, the lower-case class name can be used for generating URLs, like in::
url_for('myview.index')
3. For model-based views the rules differ - the model class name should be used if an endpoint name is not provided. The ModelView also has these endpoints by default: *.index_view*, *.create_view*, and *.edit_view*. So, the following urls can be generated for a model named "User"::
# List View
url_for('user.index_view')
# Create View (redirect back to index_view)
url_for('user.create_view', url=url_for('user.index_view'))
# Edit View for record #1 (redirect back to index_view)
url_for('user.edit_view', id=1, url=url_for('user.index_view'))
Examples
--------
......
__version__ = '1.0.9.dev0'
__version__ = '1.1.0'
__author__ = 'Serge S. Koval'
__email__ = 'serge.koval+github@gmail.com'
......
......@@ -116,6 +116,10 @@ class BaseView(with_metaclass(AdminViewMeta, BaseViewClass)):
@expose('/')
def index(self):
return 'Hello World!'
Icons can be added to the menu by using `menu_icon_type` and `menu_icon_value`. For example::
admin.add_view(MyView(name='My View', menu_icon_type='glyph', menu_icon_value='glyphicon-home'))
"""
@property
def _template_args(self):
......@@ -229,9 +233,15 @@ class BaseView(with_metaclass(AdminViewMeta, BaseViewClass)):
if not self.url.startswith('/'):
self.url = '%s/%s' % (self.admin.url, self.url)
# If we're working from the root of the site, set prefix to None
if self.url == '/':
self.url = None
# prevent admin static files from conflicting with flask static files
if not self.static_url_path:
self.static_folder='static'
self.static_url_path='/static/admin'
# If name is not povided, use capitalized endpoint name
if self.name is None:
......@@ -383,10 +393,22 @@ class AdminIndexView(BaseView):
@expose('/')
def index(self):
arg1 = 'Hello'
return render_template('adminhome.html', arg1=arg1)
return self.render('admin/myhome.html', arg1=arg1)
admin = Admin(index_view=MyHomeView())
Also, you can change the root url from /admin to / with the following::
admin = Admin(
app,
index_view=AdminIndexView(
name='Home',
template='admin/myhome.html',
url='/'
)
)
Default values for the index page are:
* If a name is not provided, 'Home' will be used.
......@@ -397,12 +419,18 @@ class AdminIndexView(BaseView):
"""
def __init__(self, name=None, category=None,
endpoint=None, url=None,
template='admin/index.html'):
template='admin/index.html',
menu_class_name=None,
menu_icon_type=None,
menu_icon_value=None):
super(AdminIndexView, self).__init__(name or babel.lazy_gettext('Home'),
category,
endpoint or 'admin',
url or '/admin',
'static')
'static',
menu_class_name=menu_class_name,
menu_icon_type=menu_icon_type,
menu_icon_value=menu_icon_value)
self._template = template
@expose()
......
try:
import wtforms_appengine
except ImportError:
raise Exception('Please install wtforms_appengine in order to use appengine backend')
from .view import ModelView
import logging
from flask.ext.admin.model import BaseModelView
from wtforms_appengine import db as wt_db
from wtforms_appengine import ndb as wt_ndb
from google.appengine.ext import db
from google.appengine.ext import ndb
class NdbModelView(BaseModelView):
"""
AppEngine NDB model scaffolding.
"""
def get_pk_value(self, model):
return model.key.urlsafe()
def scaffold_list_columns(self):
return sorted([k for (k, v) in self.model.__dict__.iteritems() if isinstance(v, ndb.Property)])
def scaffold_sortable_columns(self):
return [k for (k, v) in self.model.__dict__.iteritems() if isinstance(v, ndb.Property) and v._indexed]
def init_search(self):
return None
def is_valid_filter(self):
pass
def scaffold_filters(self):
#TODO: implement
pass
def scaffold_form(self):
return wt_ndb.model_form(self.model())
def get_list(self, page, sort_field, sort_desc, search, filters):
#TODO: implement filters (don't think search can work here)
q = self.model.query()
if sort_field:
order_field = getattr(self.model, sort_field)
if sort_desc:
order_field = -order_field
q = q.order(order_field)
results = q.fetch(self.page_size, offset=page*self.page_size)
return q.count(), results
def get_one(self, urlsafe_key):
return ndb.Key(urlsafe=urlsafe_key).get()
def create_model(self, form):
try:
model = self.model()
form.populate_obj(model)
model.put()
return True
except Exception as ex:
if not self.handle_view_exception(ex):
#flash(gettext('Failed to create record. %(error)s',
# error=ex), 'error')
logging.exception('Failed to create record.')
return False
def update_model(self, form, model):
try:
form.populate_obj(model)
model.put()
return True
except Exception as ex:
if not self.handle_view_exception(ex):
#flash(gettext('Failed to update record. %(error)s',
# error=ex), 'error')
logging.exception('Failed to update record.')
return False
def delete_model(self, model):
try:
model.key.delete()
return True
except Exception as ex:
if not self.handle_view_exception(ex):
#flash(gettext('Failed to delete record. %(error)s',
# error=ex),
# 'error')
logging.exception('Failed to delete record.')
return False
class DbModelView(BaseModelView):
"""
AppEngine DB model scaffolding.
"""
def get_pk_value(self, model):
return str(model.key())
def scaffold_list_columns(self):
return sorted([k for (k, v) in self.model.__dict__.iteritems() if isinstance(v, db.Property)])
def scaffold_sortable_columns(self):
return [k for (k, v) in self.model.__dict__.iteritems() if isinstance(v, db.Property) and v._indexed]
def init_search(self):
return None
def is_valid_filter(self):
pass
def scaffold_filters(self):
#TODO: implement
pass
def scaffold_form(self):
return wt_db.model_form(self.model())
def get_list(self, page, sort_field, sort_desc, search, filters):
#TODO: implement filters (don't think search can work here)
q = self.model.all()
if sort_field:
if sort_desc:
sort_field = "-" + sort_field
q.order(sort_field)
results = q.fetch(self.page_size, offset=page*self.page_size)
return q.count(), results
def get_one(self, encoded_key):
return db.get(db.Key(encoded=encoded_key))
def create_model(self, form):
try:
model = self.model()
form.populate_obj(model)
model.put()
return True
except Exception as ex:
if not self.handle_view_exception(ex):
#flash(gettext('Failed to create record. %(error)s',
# error=ex), 'error')
logging.exception('Failed to create record.')
return False
def update_model(self, form, model):
try:
form.populate_obj(model)
model.put()
return True
except Exception as ex:
if not self.handle_view_exception(ex):
#flash(gettext('Failed to update record. %(error)s',
# error=ex), 'error')
logging.exception('Failed to update record.')
return False
def delete_model(self, model):
try:
model.delete()
return True
except Exception as ex:
if not self.handle_view_exception(ex):
#flash(gettext('Failed to delete record. %(error)s',
# error=ex),
# 'error')
logging.exception('Failed to delete record.')
return False
def ModelView(model):
if issubclass(model, ndb.Model):
return NdbModelView(model)
elif issubclass(model, db.Model):
return DbModelView(model)
else:
raise ValueError("Unsupported model: %s" % model)
This diff is collapsed.
......@@ -2,6 +2,10 @@ import json
from wtforms.fields import TextAreaField
from shapely.geometry import shape, mapping
from .widgets import LeafletWidget
from sqlalchemy import func
import geoalchemy2
#from types import NoneType
#from .. import db how do you get db.session in a Field?
class JSONField(TextAreaField):
......@@ -9,7 +13,7 @@ class JSONField(TextAreaField):
if self.raw_data:
return self.raw_data[0]
if self.data:
return self.to_json(self.data)
return self.data
return ""
def process_formdata(self, valuelist):
......@@ -34,18 +38,31 @@ class JSONField(TextAreaField):
class GeoJSONField(JSONField):
widget = LeafletWidget()
def __init__(self, label=None, validators=None, geometry_type="GEOMETRY", **kwargs):
def __init__(self, label=None, validators=None, geometry_type="GEOMETRY", srid='-1', session=None, **kwargs):
super(GeoJSONField, self).__init__(label, validators, **kwargs)
self.web_srid = 4326
self.srid = srid
if self.srid is -1:
self.transform_srid = self.web_srid
else:
self.transform_srid = self.srid
self.geometry_type = geometry_type.upper()
self.session = session
def _value(self):
if self.raw_data:
return self.raw_data[0]
if self.data:
self.data = mapping(self.data)
if type(self.data) is geoalchemy2.elements.WKBElement:
if self.srid is -1:
self.data = self.session.scalar(func.ST_AsGeoJson(self.data))
else:
self.data = self.session.scalar(func.ST_AsGeoJson(func.ST_Transform(self.data, self.web_srid)))
return super(GeoJSONField, self)._value()
def process_formdata(self, valuelist):
super(GeoJSONField, self).process_formdata(valuelist)
if self.data:
self.data = shape(self.data)
if str(self.data) is '':
self.data = None
if self.data is not None:
web_shape = self.session.scalar(func.ST_AsText(func.ST_Transform(func.ST_GeomFromText(shape(self.data).wkt, self.web_srid), self.transform_srid)))
self.data = 'SRID='+str(self.srid)+';'+str(web_shape)
......@@ -4,7 +4,9 @@ from .fields import GeoJSONField
class AdminModelConverter(SQLAAdminConverter):
@converts('Geometry')
@converts('Geography', 'Geometry')
def convert_geom(self, column, field_args, **extra):
field_args['geometry_type'] = column.type.geometry_type
field_args['srid'] = column.type.srid
field_args['session'] = self.session
return GeoJSONField(**field_args)
from geoalchemy2 import Geometry as BaseGeometry
from geoalchemy2.shape import to_shape
class Geometry(BaseGeometry):
"""
PostGIS datatype that can convert directly to/from Shapely objects,
without worrying about WKTElements or WKBElements.
"""
def result_processor(self, dialect, coltype):
to_wkbelement = super(Geometry, self).result_processor(dialect, coltype)
def process(value):
if value:
return to_shape(to_wkbelement(value))
else:
return None
return process
def bind_processor(self, dialect):
from_wktelement = super(Geometry, self).bind_processor(dialect)
def process(value):
if value:
return from_wktelement(value.wkt)
else:
return None
return process
......@@ -2,8 +2,10 @@ from flask.ext.admin.contrib.sqla.typefmt import DEFAULT_FORMATTERS as BASE_FORM
import json
from jinja2 import Markup
from wtforms.widgets import html_params
from shapely.geometry import mapping
from shapely.geometry.base import BaseGeometry
from geoalchemy2.shape import to_shape
from geoalchemy2.elements import WKBElement
from sqlalchemy import func
from flask import current_app
def geom_formatter(view, value):
......@@ -12,12 +14,15 @@ def geom_formatter(view, value):
"disabled": "disabled",
"data-width": 100,
"data-height": 70,
"data-geometry-type": value.geom_type,
"data-geometry-type": to_shape(value).geom_type,
"data-zoom": 15,
})
geojson = json.dumps(mapping(value))
if value.srid is -1:
geojson = current_app.extensions['sqlalchemy'].db.session.scalar(func.ST_AsGeoJson(value))
else:
geojson = current_app.extensions['sqlalchemy'].db.session.scalar(func.ST_AsGeoJson(value.ST_Transform( 4326)))
return Markup('<textarea %s>%s</textarea>' % (params, geojson))
DEFAULT_FORMATTERS = BASE_FORMATTERS.copy()
DEFAULT_FORMATTERS[BaseGeometry] = geom_formatter
DEFAULT_FORMATTERS[WKBElement] = geom_formatter
......@@ -10,6 +10,8 @@ def lng(pt):
class LeafletWidget(TextArea):
data_role = 'leaflet'
"""
`Leaflet <http://leafletjs.com/>`_ styled map widget. Inherits from
`TextArea` so that geographic data can be stored via the <textarea>
......@@ -31,14 +33,14 @@ class LeafletWidget(TextArea):
self.max_bounds = max_bounds
def __call__(self, field, **kwargs):
kwargs.setdefault('data-role', 'leaflet')
kwargs.setdefault('data-role', self.data_role)
gtype = getattr(field, "geometry_type", "GEOMETRY")
kwargs.setdefault('data-geometry-type', gtype)
# set optional values from constructor
if self.width:
if not "data-width" in kwargs:
kwargs["data-width"] = self.width
if self.height:
if not "data-height" in kwargs:
kwargs["data-height"] = self.height
if self.center:
kwargs["data-lat"] = lat(self.center)
......
......@@ -238,8 +238,10 @@ class FilterConverter(filters.BaseFilterConverter):
FilterEmpty)
def convert(self, type_name, column, name):
if type_name in self.converters:
return self.converters[type_name](column, name)
filter_name = type_name.lower()
if filter_name in self.converters:
return self.converters[filter_name](column, name)
return None
......
......@@ -5,6 +5,8 @@ from flask import request, flash, abort, Response
from flask.ext.admin import expose
from flask.ext.admin.babel import gettext, ngettext, lazy_gettext
from flask.ext.admin.model import BaseModelView
from flask.ext.admin.model.form import wrap_fields_in_fieldlist
from flask.ext.admin.model.fields import ListEditableFieldList
from flask.ext.admin._compat import iteritems, string_types
import mongoengine
......@@ -21,7 +23,6 @@ from .helpers import format_error
from .ajax import process_ajax_references, create_ajax_loader
from .subdoc import convert_subdocuments
# Set up logger
log = logging.getLogger("flask-admin.mongo")
......@@ -398,6 +399,28 @@ class ModelView(BaseModelView):
return form_class
def scaffold_list_form(self, custom_fieldlist=ListEditableFieldList,
validators=None):
"""
Create form for the `index_view` using only the columns from
`self.column_editable_list`.
:param validators:
`form_args` dict with only validators
{'name': {'validators': [required()]}}
:param custom_fieldlist:
A WTForm FieldList class. By default, `ListEditableFieldList`.
"""
form_class = get_form(self.model,
self.model_form_converter(self),
base_class=self.form_base_class,
only=self.column_editable_list,
field_args=validators)
return wrap_fields_in_fieldlist(self.form_base_class,
form_class,
custom_fieldlist)
# AJAX foreignkey support
def _create_ajax_loader(self, name, opts):
return create_ajax_loader(self.model, name, name, opts)
......@@ -409,6 +432,26 @@ class ModelView(BaseModelView):
"""
return self.model.objects
def _search(self, query, search_term):
# TODO: Unfortunately, MongoEngine contains bug which
# prevents running complex Q queries and, as a result,
# Flask-Admin does not support per-word searching like
# in other backends
op, term = parse_like_term(search_term)
criteria = None
for field in self._search_fields:
flt = {'%s__%s' % (field.name, op): term}
q = mongoengine.Q(**flt)
if criteria is None:
criteria = q
else:
criteria |= q
return query.filter(criteria)
def get_list(self, page, sort_column, sort_desc, search, filters,
execute=True):
"""
......@@ -437,24 +480,7 @@ class ModelView(BaseModelView):
# Search
if self._search_supported and search:
# TODO: Unfortunately, MongoEngine contains bug which
# prevents running complex Q queries and, as a result,
# Flask-Admin does not support per-word searching like
# in other backends
op, term = parse_like_term(search)
criteria = None
for field in self._search_fields:
flt = {'%s__%s' % (field.name, op): term}
q = mongoengine.Q(**flt)
if criteria is None:
criteria = q
else:
criteria |= q
query = query.filter(criteria)
query = self._search(query, search)
# Get count
count = query.count()
......
......@@ -305,8 +305,10 @@ class FilterConverter(filters.BaseFilterConverter):
FilterEmpty)
def convert(self, type_name, column, name):
if type_name in self.converters:
return self.converters[type_name](column, name)
filter_name = type_name.lower()
if filter_name in self.converters:
return self.converters[filter_name](column, name)
return None
......
......@@ -5,6 +5,8 @@ from flask import flash
from flask.ext.admin._compat import string_types
from flask.ext.admin.babel import gettext, ngettext, lazy_gettext
from flask.ext.admin.model import BaseModelView
from flask.ext.admin.model.form import wrap_fields_in_fieldlist
from flask.ext.admin.model.fields import ListEditableFieldList
from peewee import PrimaryKeyField, ForeignKeyField, Field, CharField, TextField
......@@ -237,6 +239,27 @@ class ModelView(BaseModelView):
return form_class
def scaffold_list_form(self, custom_fieldlist=ListEditableFieldList,
validators=None):
"""
Create form for the `index_view` using only the columns from
`self.column_editable_list`.
:param validators:
`form_args` dict with only validators
{'name': {'validators': [required()]}}
:param custom_fieldlist:
A WTForm FieldList class. By default, `ListEditableFieldList`.
"""
form_class = get_form(self.model, self.model_form_converter(self),
base_class=self.form_base_class,
only=self.column_editable_list,
field_args=validators)
return wrap_fields_in_fieldlist(self.form_base_class,
form_class,
custom_fieldlist)
def scaffold_inline_form_models(self, form_class):
converter = self.model_form_converter(self)
inline_converter = self.inline_model_form_converter(self)
......
......@@ -146,6 +146,42 @@ class ModelView(BaseModelView):
"""
return model.get(name)
def _search(self, query, search_term):
values = search_term.split(' ')
queries = []
# Construct inner querie
for value in values:
if not value:
continue
regex = parse_like_term(value)
stmt = []
for field in self._search_fields:
stmt.append({field: {'$regex': regex}})
if stmt:
if len(stmt) == 1:
queries.append(stmt[0])
else:
queries.append({'$or': stmt})
# Construct final query
if queries:
if len(queries) == 1:
final = queries[0]
else:
final = {'$and': queries}
if query:
query = {'$and': [query, final]}
else:
query = final
return query
def get_list(self, page, sort_column, sort_desc, search, filters,
execute=True):
"""
......@@ -182,38 +218,7 @@ class ModelView(BaseModelView):
# Search
if self._search_supported and search:
values = search.split(' ')
queries = []
# Construct inner querie
for value in values:
if not value:
continue
regex = parse_like_term(value)
stmt = []
for field in self._search_fields:
stmt.append({field: {'$regex': regex}})
if stmt:
if len(stmt) == 1:
queries.append(stmt[0])
else:
queries.append({'$or': stmt})
# Construct final query
if queries:
if len(queries) == 1:
final = queries[0]
else:
final = {'$and': queries}
if query:
query = {'$and': [query, final]}
else:
query = final
query = self._search(query, search)
# Get count
count = self.coll.find(query).count()
......
......@@ -58,7 +58,7 @@ class QueryAjaxModelLoader(AjaxModelLoader):
def get_list(self, term, offset=0, limit=DEFAULT_PAGE_SIZE):
query = self.session.query(self.model)
filters = (field.like(u'%%%s%%' % term) for field in self._cached_fields)
filters = (field.ilike(u'%%%s%%' % term) for field in self._cached_fields)
query = query.filter(or_(*filters))
return query.offset(offset).limit(limit).all()
......
......@@ -308,8 +308,11 @@ class FilterConverter(filters.BaseFilterConverter):
FilterEmpty)
def convert(self, type_name, column, name, **kwargs):
if type_name.lower() in self.converters:
return self.converters[type_name.lower()](column, name, **kwargs)
filter_name = type_name.lower()
if filter_name in self.converters:
return self.converters[filter_name](column, name, **kwargs)
return None
@filters.convert('string', 'char', 'unicode', 'varchar', 'tinytext',
......
......@@ -607,9 +607,8 @@ class InlineModelConverter(InlineModelConverterBase):
if label:
kwargs['label'] = label
view_info = self.get_info(self.view)
if view_info.form_args:
field_args = view_info.form_args.get(forward_prop.key, {})
if self.view.form_args:
field_args = self.view.form_args.get(forward_prop.key, {})
kwargs.update(**field_args)
# Contribute field
......
......@@ -4,7 +4,7 @@ from sqlalchemy.exc import DBAPIError
from ast import literal_eval
from flask.ext.admin._compat import filter_list
from flask.ext.admin.tools import iterencode, iterdecode
from flask.ext.admin.tools import iterencode, iterdecode, escape
def parse_like_term(term):
......
......@@ -11,6 +11,9 @@ from flask import flash
from flask.ext.admin._compat import string_types
from flask.ext.admin.babel import gettext, ngettext, lazy_gettext
from flask.ext.admin.model import BaseModelView
from flask.ext.admin.model.form import wrap_fields_in_fieldlist
from flask.ext.admin.model.fields import ListEditableFieldList
from flask.ext.admin.actions import action
from flask.ext.admin._backwards import ObsoleteAttr
......@@ -19,7 +22,6 @@ from .typefmt import DEFAULT_FORMATTERS
from .tools import get_query_for_ids
from .ajax import create_ajax_loader
# Set up logger
log = logging.getLogger("flask-admin.sqla")
......@@ -78,8 +80,7 @@ class ModelView(BaseModelView):
'searchable_columns',
None)
"""
Collection of the searchable columns. Only text-based columns
are searchable (`String`, `Unicode`, `Text`, `UnicodeText`).
Collection of the searchable columns.
Example::
......@@ -339,6 +340,18 @@ class ModelView(BaseModelView):
else:
attr = name
# determine joins if Table.column (relation object) is given
if isinstance(name, InstrumentedAttribute):
columns = self._get_columns_for_field(name)
if len(columns) > 1:
raise Exception('Can only handle one column for %s' % name)
column = columns[0]
if self._need_join(column.table):
join_tables.append(column.table)
return join_tables, attr
def _need_join(self, table):
......@@ -360,7 +373,7 @@ class ModelView(BaseModelView):
if isinstance(self._primary_key, tuple):
return tools.iterencode(getattr(model, attr) for attr in self._primary_key)
else:
return getattr(model, self._primary_key)
return tools.escape(getattr(model, self._primary_key))
def scaffold_list_columns(self):
"""
......@@ -439,15 +452,18 @@ class ModelView(BaseModelView):
for c in self.column_sortable_list:
if isinstance(c, tuple):
join_tables, column = self._get_field_with_path(c[1])
result[c[0]] = column
if join_tables:
self._sortable_joins[c[0]] = join_tables
column_name = c[0]
elif isinstance(c, InstrumentedAttribute):
join_tables, column = self._get_field_with_path(c)
column_name = str(c)
else:
join_tables, column = self._get_field_with_path(c)
column_name = c
result[c] = column
result[column_name] = column
if join_tables:
self._sortable_joins[column_name] = join_tables
return result
......@@ -474,10 +490,6 @@ class ModelView(BaseModelView):
for column in self._get_columns_for_field(attr):
column_type = type(column.type).__name__
if not self.is_text_column_type(column_type):
raise Exception('Can only search on text columns. ' +
'Failed to setup search for "%s"' % p)
self._search_fields.append(column)
# Store joins, avoid duplicates
......@@ -488,18 +500,6 @@ class ModelView(BaseModelView):
return bool(self.column_searchable_list)
def is_text_column_type(self, name):
"""
Verify if the provided column type is text-based.
:returns:
``True`` for ``String``, ``Unicode``, ``Text``, ``UnicodeText``, ``varchar``
"""
if name:
name = name.lower()
return name in ('string', 'unicode', 'text', 'unicodetext', 'varchar')
def scaffold_filters(self, name):
"""
Return list of enabled filters
......@@ -535,8 +535,8 @@ class ModelView(BaseModelView):
if join_tables:
self._filter_joins[table.name] = join_tables
elif self._need_join(table.name):
self._filter_joins[table.name] = [table.name]
elif self._need_join(table):
self._filter_joins[table.name] = [table]
filters.extend(flt)
return filters
......@@ -611,6 +611,28 @@ class ModelView(BaseModelView):
return form_class
def scaffold_list_form(self, custom_fieldlist=ListEditableFieldList,
validators=None):
"""
Create form for the `index_view` using only the columns from
`self.column_editable_list`.
:param validators:
`form_args` dict with only validators
{'name': {'validators': [required()]}}
:param custom_fieldlist:
A WTForm FieldList class. By default, `ListEditableFieldList`.
"""
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,
field_args=validators)
return wrap_fields_in_fieldlist(self.form_base_class,
form_class,
custom_fieldlist)
def scaffold_inline_form_models(self, form_class):
"""
Contribute inline models to the form
......@@ -664,6 +686,14 @@ class ModelView(BaseModelView):
Return a query for the model type.
If you override this method, don't forget to override `get_count_query` as well.
This method can be used to set a "persistent filter" on an index_view.
Example::
class MyView(ModelView):
def get_query(self):
return super(MyView, self).get_query().filter(User.username == current_user.username)
"""
return self.session.query(self.model)
......@@ -716,7 +746,7 @@ class ModelView(BaseModelView):
join_tables, attr = self._get_field_with_path(field)
return join_tables, field, direction
return join_tables, attr, direction
return None
......
......@@ -69,13 +69,16 @@ class TimeField(fields.Field):
def _value(self):
if self.raw_data:
return u' '.join(self.raw_data)
elif self.data is not None:
return self.data.strftime(self.default_format)
else:
return self.data and self.data.strftime(self.default_format) or u''
return u''
def process_formdata(self, valuelist):
if valuelist:
date_str = u' '.join(valuelist)
if date_str.strip():
for format in self.formats:
try:
timetuple = time.strptime(date_str, format)
......@@ -87,6 +90,8 @@ class TimeField(fields.Field):
pass
raise ValueError(gettext('Invalid time format'))
else:
self.data = None
class Select2Field(fields.SelectField):
......
......@@ -179,11 +179,13 @@ class FileUploadField(fields.StringField):
filename.rsplit('.', 1)[1].lower() in
map(lambda x: x.lower(), self.allowed_extensions))
def _is_uploaded_file(self, data):
return (data
and isinstance(data, FileStorage)
and data.filename)
def pre_validate(self, form):
if (self.data
and self.data.filename
and isinstance(self.data, FileStorage)
and not self.is_file_allowed(self.data.filename)):
if self._is_uploaded_file(self.data) and not self.is_file_allowed(self.data.filename):
raise ValidationError(gettext('Invalid file extension'))
def process(self, formdata, data=unset_value):
......@@ -194,6 +196,15 @@ class FileUploadField(fields.StringField):
return super(FileUploadField, self).process(formdata, data)
def process_formdata(self, valuelist):
if self._should_delete:
self.data = None
elif valuelist:
data = valuelist[0]
if self._is_uploaded_file(data):
self.data = data
def populate_obj(self, obj, name):
field = getattr(obj, name, None)
if field:
......@@ -203,7 +214,7 @@ class FileUploadField(fields.StringField):
setattr(obj, name, None)
return
if self.data and self.data.filename and isinstance(self.data, FileStorage):
if self._is_uploaded_file(self.data):
if field:
self._delete_file(field)
......@@ -299,7 +310,7 @@ class ImageUploadField(FileUploadField):
upload = FileUploadField('File', namegen=prefix_name)
:param allowed_extensions:
List of allowed extensions. If not provided, will allow any file.
List of allowed extensions. If not provided, then gif, jpg, jpeg, png and tiff will be allowed.
:param max_size:
Tuple of (width, height, force) or None. If provided, Flask-Admin will
resize image to the desired size.
......@@ -357,9 +368,7 @@ class ImageUploadField(FileUploadField):
def pre_validate(self, form):
super(ImageUploadField, self).pre_validate(form)
if (self.data and
isinstance(self.data, FileStorage) and
self.data.filename):
if self._is_uploaded_file(self.data):
try:
self.image = Image.open(self.data)
except Exception as e:
......@@ -396,7 +405,7 @@ class ImageUploadField(FileUploadField):
self._save_image(image, self._get_path(filename), format)
else:
data.seek(0)
data.save( self._get_path(filename) )
data.save(self._get_path(filename))
self._save_thumbnail(data, filename, format)
......
from re import sub
from jinja2 import contextfunction
from flask import g, request, url_for
from flask import g, request, url_for, flash
from wtforms.validators import DataRequired, InputRequired
from flask.ext.admin._compat import urljoin, urlparse
from flask.ext.admin._compat import urljoin, urlparse, iteritems
from ._compat import string_types
......@@ -56,7 +55,7 @@ def is_form_submitted():
"""
Check if current method is PUT or POST
"""
return request and request.method in ("PUT", "POST")
return request and request.method in ('PUT', 'POST')
def validate_form_on_submit(form):
......@@ -95,6 +94,12 @@ def is_field_error(errors):
return False
def flash_errors(form, message):
from flask.ext.admin.babel import gettext
for field_name, errors in iteritems(form.errors):
errors = form[field_name].label.text + u": " + u", ".join(errors)
flash(gettext(message, error=str(errors)), 'error')
@contextfunction
def resolve_ctx(context):
"""
......
This diff is collapsed.
......@@ -3,8 +3,14 @@ import itertools
from wtforms.validators import ValidationError
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 .widgets import InlineFieldListWidget, InlineFormWidget, AjaxSelect2Widget
from .widgets import (InlineFieldListWidget, InlineFormWidget,
AjaxSelect2Widget, XEditableWidget)
class InlineFieldList(FieldList):
......@@ -120,6 +126,58 @@ class InlineModelFormField(FormField):
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
def populate_obj(self, obj, name):
# return data from first item, instead of a list of items
setattr(obj, name, self.data.pop())
class AjaxSelectField(SelectFieldBase):
"""
Ajax Model Select Field
......
......@@ -266,7 +266,7 @@ def convert(*args):
See :mod:`flask.ext.admin.contrib.sqla.filters` for usage example.
"""
def _inner(func):
func._converter_for = args
func._converter_for = list(map(str.lower, args))
return func
return _inner
......
......@@ -3,6 +3,8 @@ import inspect
from flask.ext.admin.form import BaseForm, rules
from flask.ext.admin._compat import iteritems
from wtforms.fields.core import UnboundField
def converts(*args):
def _inner(func):
......@@ -11,6 +13,35 @@ def converts(*args):
return _inner
def wrap_fields_in_fieldlist(form_base_class, form_class, CustomFieldList):
"""
Create a form class with all the fields wrapped in a FieldList.
Wrapping each field in FieldList allows submitting POST requests
in this format: ('<field_name>-<primary_key>', '<value>')
Used in the editable list view.
:param form_base_class:
WTForms form class, by default `form_base_class` from base.
:param form_class:
WTForms form class generated by `form.get_form`.
:param CustomFieldList:
WTForms FieldList class.
By default, `CustomFieldList` is `ListEditableFieldList`.
"""
class FieldListForm(form_base_class):
pass
# iterate FormMeta to get unbound fields
for name, obj in iteritems(form_class.__dict__):
if isinstance(obj, UnboundField):
# wrap field in a WTForms FieldList
setattr(FieldListForm, name, CustomFieldList(obj))
return FieldListForm
class InlineBaseFormAdmin(object):
"""
Settings for inline form administration.
......
......@@ -25,7 +25,13 @@ def get_mdict_item_or_list(mdict, key):
if hasattr(mdict, 'getlist'):
v = mdict.getlist(key)
if len(v) == 1:
return v[0]
value = v[0]
# Special case for empty strings, treat them as "no-value"
if value == '':
value = None
return value
elif len(v) == 0:
return None
else:
......
......@@ -26,8 +26,8 @@ class AjaxSelect2Widget(object):
self.multiple = multiple
def __call__(self, field, **kwargs):
kwargs['data-role'] = u'select2-ajax'
kwargs['data-url'] = get_url('.ajax_lookup', name=field.loader.name)
kwargs.setdefault('data-role', 'select2-ajax')
kwargs.setdefault('data-url', get_url('.ajax_lookup', name=field.loader.name))
allow_blank = getattr(field, 'allow_blank', False)
if allow_blank and not self.multiple:
......@@ -61,3 +61,88 @@ class AjaxSelect2Widget(object):
kwargs.setdefault('data-placeholder', placeholder)
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):
kwargs.setdefault('data-value', kwargs.pop('value', ''))
kwargs.setdefault('data-role', 'x-editable')
kwargs.setdefault('data-url', './ajax/update/')
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", "")
# subfield is the first entry (subfield) from FieldList (field)
subfield = field.entries[0]
kwargs = self.get_kwargs(subfield, kwargs)
return HTMLString(
'<a %s>%s</a>' % (html_params(**kwargs), kwargs['data-value'])
)
def get_kwargs(self, subfield, kwargs):
"""
Return extra kwargs based on the subfield type.
"""
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 == '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 ['FloatField', 'DecimalField']:
kwargs['data-type'] = 'number'
kwargs['data-step'] = 'any'
elif subfield.type in ['QuerySelectField', 'ModelSelectField']:
kwargs['data-type'] = 'select'
choices = {}
for choice in subfield:
try:
choices[str(choice._value())] = str(choice.label.text)
except TypeError:
choices[str(choice._value())] = ""
kwargs['data-source'] = choices
else:
raise Exception('Unsupported field type: %s' % (type(subfield),))
return kwargs
/* Global styles */
body
{
padding-top: 4px;
}
/* Form customizations */
form.icon {
/* List View - fix trash icon inside table column */
.model-list form.icon {
display: inline;
}
form.icon button {
.model-list form.icon button {
border: none;
background: transparent;
text-decoration: none;
......@@ -17,76 +11,62 @@ form.icon button {
line-height: normal;
}
a.icon {
/* List View - link icons - prevent underline */
.model-list a.icon {
text-decoration: none;
}
/* Model search form */
form.search-form {
margin: 4px 0 0 0;
}
form.search-form .clear i {
margin: 2px 0 0 0;
/* List View - fix checkbox column width */
.list-checkbox-column {
width: 14px;
}
form.search-form div input {
margin: 0;
/* List View - fix gap between actions and table */
.model-list {
position: relative;
margin-top: -1px;
z-index: 999;
}
/* Filters */
table.filters {
border-collapse: collapse;
border-spacing: 4px;
.actions-nav {
margin-bottom: 0;
margin-left: 4px;
margin-right: 4px;
}
.filters input
{
#filter_form {
margin-bottom: 0;
}
.filters a.remove-filter {
margin-bottom: 0;
display: block;
text-align: left;
/* List View Search Form - fix gap between form and table */
.actions-nav form.search-form {
margin: -1px 0 0 0;
}
.filters .remove-filter
{
vertical-align: middle;
/* Filters */
table.filters {
border-collapse: collapse;
border-spacing: 4px;
}
.filters .remove-filter .close-icon
{
font-size: 16px;
/* prevents gap between table and actions while there are no filters set */
table.filters:not(:empty) {
margin: 12px 0px 20px 0px;
}
.filters .remove-filter .close-icon:hover
{
color: black;
opacity: 0.4;
/* spacing between filter X button, operation, and value field */
/* uses tables instead of form classes for bootstrap2-3 compatibility */
table.filters tr td {
padding-right: 5px;
padding-bottom: 3px;
}
/* match filter operation drop-down height with bootstrap input */
.filters .filter-op > a {
height: 28px;
line-height: 28px;
}
/* Inline forms */
.inline-field {
padding-bottom: 0.5em;
}
.inline-field-control {
float: right;
}
.inline-field .inline-form-field {
border-left: 1px solid #eeeeee;
padding-left: 8px;
margin-bottom: 4px;
}
/* Image thumbnails */
.image-thumbnail img {
max-width: 100px;
......@@ -94,21 +74,13 @@ table.filters {
}
/* Forms */
.form-horizontal .control-label {
.admin-form .control-label {
width: 100px;
text-align: left;
margin-left: 4px;
}
.form-horizontal .controls {
/* add spacing between labels and form fields */
.admin-form .controls {
margin-left: 110px;
}
\ No newline at end of file
/* Patch Select2 */
.select2-results li {
min-height: 24px !important;
}
.list-checkbox-column {
width: 14px;
}
/* Global styles */
body
{
padding-top: 4px;
}
/* Form customizations */
form.icon {
/* List View - fix trash icon inside table column */
.model-list form.icon {
display: inline;
}
form.icon button {
.model-list form.icon button {
border: none;
background: transparent;
text-decoration: none;
......@@ -17,28 +11,28 @@ form.icon button {
line-height: normal;
}
a.icon, button span.glyphicon {
/* List View - prevent link icons from differing from trash icon */
.model-list a.icon {
text-decoration: none;
margin-left: 10px;
color: #333;
color: inherit;
}
/* Model search form */
form.navbar-form {
margin: 1px 0 0 0;
}
form.navbar-form a.clear span {
margin-top: 8px;
margin-left: -20px;
/* List View - fix checkbox column width */
.list-checkbox-column {
width: 14px;
}
form.navbar-form div.input-append {
display: inline-flex;
/* List View - fix overlapping border between actions and table */
.model-list {
position: relative;
margin-top: -1px;
z-index: 999;
}
form.search-form div input {
margin: 0;
/* List View Search Form - fix gap between form and table */
.actions-nav form.navbar-form {
margin: 1px 0 0 0;
}
/* Filters */
......@@ -47,43 +41,19 @@ table.filters {
border-spacing: 4px;
}
/* prevents gap between table and actions while there are no filters set */
table.filters:not(:empty) {
margin: 12px 0px 20px 0px;
}
/* spacing between filter X button, operation, and value field */
/* uses tables instead of form classes for bootstrap2-3 compatibility */
table.filters tr td {
padding-right: 5px;
padding-bottom: 3px;
}
table.flters tr td:nth-child(2){
width: 60%;
}
.filters a.remove-filter {
margin-bottom: 0;
display: block;
text-align: left;
text-decoration: none;
}
.filters .remove-filter
{
vertical-align: middle;
}
.filters .remove-filter .close-icon
{
font-size: 16px;
}
.filters .remove-filter .close-icon:hover
{
color: black;
opacity: 0.4;
}
/* filters */
/* Filters - Select2 Boxes */
.filters .filter-op {
width: 130px;
}
......@@ -92,30 +62,6 @@ table.flters tr td:nth-child(2){
width: 220px;
}
/* Inline forms */
.inline-field {
margin-bottom: 5px;
}
.inline-field-control {
float: right;
}
.inline-field .inline-form {
border-left: 1px solid #eeeeee;
padding-left: 8px;
margin-bottom: 4px;
}
.inline-field-control .glyphicon-remove {
margin-left: -30px;
}
.panel {
padding-top: 25px;
padding-left: 30px;
}
/* Image thumbnails */
.image-thumbnail img {
max-width: 100px;
......@@ -123,58 +69,14 @@ table.flters tr td:nth-child(2){
}
/* Forms */
.form-horizontal {
/* required because form-horizontal removes top padding */
.admin-form {
margin-top: 35px;
}
.submit-row {
margin-top: 5px;
}
td>span.glyphicon {
padding-left: 35%;
}
/* link style */
a.btn-cancel {
border-radius: 4px;
border-color: #a08c8c;
color: #333;
background-color: #f0b8b8;
}
a.btn-link {
border-radius: 4px;
border-color: #95bee2;
}
a.btn-filter, a.btn-filter:hover {
border-radius: 4px;
border-color: #adadad;
color: #333;
background-color: #ebebeb;
max-height: 32px;
line-height: 16px;
}
.help-block {
/* Form Field Description - Appears when field has 'description' attribute */
/* Test with: form_args = {'name':{'description': 'test'}} */
/* prevents awkward gap after help-block - This is default for bootstrap2 */
.admin-form .help-block {
margin-bottom: 0px;
}
\ No newline at end of file
ul.has-error {
margin-bottom: -8px;
}
.tooltip.top {
font-size: 1.1em;
top: -35px;
}
.checkbox input[type="checkbox"] {
margin-left: 0px;
margin-right: 15px;
}
.list-checkbox-column {
width: 14px;
}
......@@ -101,12 +101,12 @@ var AdminFilters = function(element, filtersElement, filterGroups, activeFilters
}
function addFilter(name, subfilters, selectedIndex, filterValue) {
var $el = $('<tr />').appendTo($container);
var $el = $('<tr class="form-horizontal" />').appendTo($container);
// Filter list
$el.append(
$('<td/>').append(
$('<a href="#" class="btn btn-filter remove-filter" />')
$('<a href="#" class="btn btn-default remove-filter" />')
.append($('<span class="close-icon">&times;</span>'))
.append('&nbsp;')
.append(name)
......
......@@ -241,6 +241,17 @@
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) {
case 'select2':
var opts = {
......@@ -390,6 +401,32 @@
case 'leaflet':
processLeafletWidget($el, name);
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>');
}
}
}
});
}
};
......@@ -408,19 +445,25 @@
var $parentForm = $el.parent().closest('.inline-field');
if ($parentForm.length > 0 && elID.indexOf($parentForm.attr('id')) !== 0) {
if ($parentForm.hasClass('fresh')) {
id = $parentForm.attr('id') + '-' + elID;
}
var $fieldList = $el.find('> .inline-field-list');
var $lastField = $fieldList.children('.inline-field').last();
var maxId = 0;
$fieldList.children('.inline-field').each(function(idx, field) {
var $field = $(field);
var prefix = id + '-0';
if ($lastField.length > 0) {
var parts = $lastField.attr('id').split('-');
var parts = $field.attr('id').split('-');
idx = parseInt(parts[parts.length - 1], 10) + 1;
prefix = id + '-' + idx;
if (idx > maxId) {
maxId = idx;
}
});
var prefix = id + '-' + maxId;
// Get template
var $template = $($el.find('> .inline-field-template').text());
......@@ -428,6 +471,9 @@
// Set form ID
$template.attr('id', prefix);
// Mark form that we just created
$template.addClass('fresh');
// Fix form IDs
$('[name]', $template).each(function(e) {
var me = $(this);
......@@ -460,7 +506,7 @@
this.applyGlobalStyles = function(parent) {
var self = this;
$(':input[data-role]', parent).each(function() {
$(':input[data-role], a[data-role]', parent).each(function() {
var $el = $(this);
self.applyStyle($el, $el.attr('data-role'));
});
......
......@@ -13,6 +13,11 @@
<link href="{{ admin_static.url(filename='bootstrap/bootstrap2/css/bootstrap.css') }}" rel="stylesheet">
<link href="{{ admin_static.url(filename='bootstrap/bootstrap2/css/bootstrap-responsive.css') }}" rel="stylesheet">
<link href="{{ admin_static.url(filename='admin/css/bootstrap2/admin.css') }}" rel="stylesheet">
<style>
body {
padding-top: 4px;
}
</style>
{% endblock %}
{% block head %}
{% endblock %}
......
......@@ -32,8 +32,8 @@
</th>
{% endif %}
<th class="span1">&nbsp;</th>
<th>Name</th>
<th>Size</th>
<th>{{ _gettext('Name') }}</th>
<th>{{ _gettext('Size') }}</th>
{% endblock %}
</tr>
</thead>
......@@ -58,7 +58,8 @@
{% if is_dir %}
{% if name != '..' and admin_view.can_delete_dirs %}
<form class="icon" method="POST" action="{{ get_url('.delete') }}">
<input type="hidden" name="path" value="{{ path }}"></input>
{{ delete_form.path(value=path) }}
{{ delete_form.csrf_token }}
<button onclick="return confirm('{{ _gettext('Are you sure you want to delete \\\'%(name)s\\\' recursively?', name=name) }}')">
<i class="icon-remove"></i>
</button>
......@@ -66,7 +67,8 @@
{% endif %}
{% else %}
<form class="icon" method="POST" action="{{ get_url('.delete') }}">
<input type="hidden" name="path" value="{{ path }}"></input>
{{ delete_form.path(value=path) }}
{{ delete_form.csrf_token }}
<button onclick="return confirm('{{ _gettext('Are you sure you want to delete \\\'%(name)s\\\'?', name=name) }}')">
<i class="icon-remove"></i>
</button>
......
{% macro menu_icon(item) -%}
{% set icon_type = item.get_icon_type() %}
{% if icon_type %}
{%- if icon_type %}
{% set icon_value = item.get_icon_value() %}
{% if icon_type == 'glyph' %}
<i class="{{ icon_value }}"></i>
......@@ -13,45 +13,43 @@
{%- endmacro %}
{% macro menu() %}
{% for item in admin_view.admin.menu() %}
{% if item.is_category() %}
{%- for item in admin_view.admin.menu() %}
{%- if item.is_category() -%}
{% set children = item.get_children() %}
{% if children %}
{%- if children %}
{% set class_name = item.get_class_name() %}
{% if item.is_active(admin_view) %}
{%- if item.is_active(admin_view) %}
<li class="active dropdown{% if class_name %} {{class_name}}{% endif %}">
{% else %}
{% else -%}
<li class="dropdown{% if class_name %} {{class_name}}{% endif %}">
{% endif %}
<a class="dropdown-toggle" data-toggle="dropdown" href="javascript:void(0)">
{{ menu_icon(item) }}{{ item.name }}<b class="caret"></b>
</a>
{%- endif %}
<a class="dropdown-toggle" data-toggle="dropdown" href="javascript:void(0)">{{ menu_icon(item) }}{{ item.name }}<b class="caret"></b></a>
<ul class="dropdown-menu">
{% for child in children %}
{%- for child in children -%}
{% set class_name = child.get_class_name() %}
{% if child.is_active(admin_view) %}
{%- if child.is_active(admin_view) %}
<li class="active{% if class_name %} {{class_name}}{% endif %}">
{% else %}
<li{% if class_name %} class="{{class_name}}"{% endif %}>
{% endif %}
{%- endif %}
<a href="{{ child.get_url() }}">{{ menu_icon(child) }}{{ child.name }}</a>
</li>
{% endfor %}
{%- endfor %}
</ul>
</li>
{% endif %}
{% else %}
{% if item.is_accessible() and item.is_visible() %}
{%- else %}
{%- if item.is_accessible() and item.is_visible() -%}
{% set class_name = item.get_class_name() %}
{% if item.is_active(admin_view) %}
{%- if item.is_active(admin_view) %}
<li class="active{% if class_name %} {{class_name}}{% endif %}">
{% else %}
{%- else %}
<li{% if class_name %} class="{{class_name}}"{% endif %}>
{% endif %}
{%- endif %}
<a href="{{ item.get_url() }}">{{ menu_icon(item) }}{{ item.name }}</a>
</li>
{% endif %}
{% endif %}
{%- endif -%}
{% endif -%}
{% endfor %}
{% endmacro %}
......
......@@ -143,7 +143,7 @@
{% endmacro %}
{% macro form_tag(form=None) %}
<form action="" method="POST" class="form-horizontal" enctype="multipart/form-data">
<form action="" method="POST" class="admin-form form-horizontal" enctype="multipart/form-data">
<fieldset>
{{ caller() }}
</fieldset>
......@@ -158,7 +158,7 @@
{{ extra }}
{% endif %}
{% if cancel_url %}
<a href="{{ cancel_url }}" class="btn btn-large">{{ _gettext('Cancel') }}</a>
<a href="{{ cancel_url }}" class="btn btn-large btn-danger">{{ _gettext('Cancel') }}</a>
{% endif %}
</div>
</div>
......@@ -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.draw.css') }}" rel="stylesheet">
{% endif %}
{% if editable_columns %}
<link href="{{ admin_static.url(filename='vendor/x-editable/css/bootstrap2-editable-1.5.1.css') }}" rel="stylesheet">
{% endif %}
{% endmacro %}
{% macro form_js() %}
......@@ -189,5 +192,8 @@
<script src="{{ admin_static.url(filename='vendor/leaflet/leaflet.draw.js') }}"></script>
{% endif %}
<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>
{% endmacro %}
{% macro render_inline_fields(field, template, render, check=None) %}
<div class="inline-field">
<div class="inline-field" id="{{ field.id }}">
{# existing inline form fields #}
<div class="inline-field-list">
{% for subfield in field %}
<div id="{{ subfield.id }}" class="inline-field">
<div id="{{ subfield.id }}" class="inline-field well well-small">
{%- if not check or check(subfield) %}
<legend>
{{ field.label.text }} #{{ loop.index }}
<div class="pull-right">
{% if subfield.get_pk and subfield.get_pk() %}
<div class="inline-field-control">
<input type="checkbox" name="del-{{ subfield.id }}" id="del-{{ subfield.id }}" />
<label for="del-{{ subfield.id }}" style="display: inline">{{ _gettext('Delete?') }}</label>
</div>
{% else %}
<div class="inline-field-control">
<a href="javascript:void(0)" class="inline-remove-field"><i class="icon-remove"></i></a>
</div>
{% endif %}
</div>
</legend>
{%- endif -%}
{{ render(subfield) }}
</div>
{% endfor %}
</div>
{# template for new inline form fields #}
<div class="inline-field-template hide">
{% filter forceescape %}
<div class="inline-field">
<div class="inline-field-control">
<div class="inline-field well well-small">
<legend>
New {{ field.label.text }}
<div class="pull-right">
<a href="javascript:void(0)" class="inline-remove-field"><i class="icon-remove"></i></a>
</div>
</legend>
{{ render(template) }}
</div>
{% endfilter %}
</div>
<a id="{{ field.id }}-button" href="javascript:void(0)" class="btn" onclick="faForm.addInlineField(this, '{{ field.id }}');">{{ _gettext('Add') }} {{ field.label.text }}</a>
</div>
{% endmacro %}
......@@ -11,7 +11,7 @@
{% block body %}
{% block model_menu_bar %}
<ul class="nav nav-tabs">
<ul class="nav nav-tabs actions-nav">
<li class="active">
<a href="javascript:void(0)">{{ _gettext('List') }} ({{ count }})</a>
</li>
......@@ -107,10 +107,10 @@
</a>
{%- endif -%}
{%- if admin_view.can_delete -%}
<form class="icon" method="POST" action="{{ get_url('.delete_view', id=get_pk_value(row), url=return_url) }}">
{% if csrf_token %}
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
{% endif %}
<form class="icon" method="POST" action="{{ get_url('.delete_view') }}">
{{ delete_form.id(value=get_pk_value(row)) }}
{{ delete_form.url(value=return_url) }}
{{ delete_form.csrf_token }}
<button onclick="return confirm('{{ _gettext('Are you sure you want to delete this record?') }}');" title="{{ _gettext('Delete record') }}">
<i class="icon-trash"></i>
</button>
......@@ -119,8 +119,17 @@
{% endblock %}
</td>
{% endblock %}
{% for c, name in list_columns %}
{% 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 %}
{% endblock %}
</tr>
......
......@@ -15,6 +15,11 @@
<link href="{{ admin_static.url(filename='bootstrap/bootstrap3/css/bootstrap.min.css') }}" rel="stylesheet">
<link href="{{ admin_static.url(filename='bootstrap/bootstrap3/css/bootstrap-theme.min.css') }}" rel="stylesheet">
<link href="{{ admin_static.url(filename='admin/css/bootstrap3/admin.css') }}" rel="stylesheet">
<style>
body {
padding-top: 4px;
}
</style>
{% endblock %}
{% block head %}
{% endblock %}
......
......@@ -32,8 +32,8 @@
</th>
{% endif %}
<th class="col-md-1">&nbsp;</th>
<th>Name</th>
<th>Size</th>
<th>{{ _gettext('Name') }}</th>
<th>{{ _gettext('Size') }}</th>
{% endblock %}
</tr>
</thead>
......@@ -58,7 +58,8 @@
{% if is_dir %}
{% if name != '..' and admin_view.can_delete_dirs %}
<form class="icon" method="POST" action="{{ get_url('.delete') }}">
<input type="hidden" name="path" value="{{ path }}"></input>
{{ delete_form.path(value=path) }}
{{ delete_form.csrf_token }}
<button onclick="return confirm('{{ _gettext('Are you sure you want to delete \\\'%(name)s\\\' recursively?', name=name) }}')">
<i class="glyphicon glyphicon-remove"></i>
</button>
......@@ -66,7 +67,8 @@
{% endif %}
{% else %}
<form class="icon" method="POST" action="{{ get_url('.delete') }}">
<input type="hidden" name="path" value="{{ path }}"></input>
{{ delete_form.path(value=path) }}
{{ delete_form.csrf_token }}
<button onclick="return confirm('{{ _gettext('Are you sure you want to delete \\\'%(name)s\\\'?', name=name) }}')">
<i class="glyphicon glyphicon-trash"></i>
</button>
......
{% macro menu_icon(item) -%}
{% set icon_type = item.get_icon_type() %}
{% if icon_type %}
{%- if icon_type %}
{% set icon_value = item.get_icon_value() %}
{% if icon_type == 'glyph' %}
<i class="glyphicon {{ icon_value }}"></i>
......@@ -13,42 +13,43 @@
{%- endmacro %}
{% macro menu() %}
{% for item in admin_view.admin.menu() %}
{% if item.is_category() %}
{%- for item in admin_view.admin.menu() %}
{%- if item.is_category() -%}
{% set children = item.get_children() %}
{% if children %}
{% if item.is_active(admin_view) %}
{%- if children %}
{% set class_name = item.get_class_name() %}
{%- if item.is_active(admin_view) %}
<li class="active dropdown{% if class_name %} {{class_name}}{% endif %}">
{% else %}
{% else -%}
<li class="dropdown{% if class_name %} {{class_name}}{% endif %}">
{% endif %}
{%- endif %}
<a class="dropdown-toggle" data-toggle="dropdown" href="javascript:void(0)">{{ menu_icon(item) }}{{ item.name }}<b class="caret"></b></a>
<ul class="dropdown-menu">
{% for child in children %}
{%- for child in children -%}
{% set class_name = child.get_class_name() %}
{% if child.is_active(admin_view) %}
<li class="active"{% if class_name %} {{class_name}}{% endif %}>
{%- if child.is_active(admin_view) %}
<li class="active{% if class_name %} {{class_name}}{% endif %}">
{% else %}
<li{% if class_name %} class="{{class_name}}"{% endif %}>
{% endif %}
{%- endif %}
<a href="{{ child.get_url() }}">{{ menu_icon(child) }}{{ child.name }}</a>
</li>
{% endfor %}
{%- endfor %}
</ul>
</li>
{% endif %}
{% else %}
{% if item.is_accessible() and item.is_visible() %}
{%- else %}
{%- if item.is_accessible() and item.is_visible() -%}
{% set class_name = item.get_class_name() %}
{% if item.is_active(admin_view) %}
<li class="active"{% if class_name %} {{class_name}}{% endif %}>
{% else %}
{%- if item.is_active(admin_view) %}
<li class="active{% if class_name %} {{class_name}}{% endif %}">
{%- else %}
<li{% if class_name %} class="{{class_name}}"{% endif %}>
{% endif %}
{%- endif %}
<a href="{{ item.get_url() }}">{{ menu_icon(item) }}{{ item.name }}</a>
</li>
{% endif %}
{% endif %}
{%- endif -%}
{% endif -%}
{% endfor %}
{% endmacro %}
......
......@@ -2,7 +2,7 @@
{% import 'admin/lib.html' as lib with context %}
{% macro extra() %}
<input name="_continue_editing" type="submit" class="btn" value="{{ _gettext('Save and Continue') }}" />
<input name="_continue_editing" type="submit" class="btn btn-default" value="{{ _gettext('Save and Continue') }}" />
{% endmacro %}
{% block head %}
......
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
......@@ -101,6 +101,12 @@ def get_dict_attr(obj, attr, default=None):
return default
def escape(value):
return (as_unicode(value)
.replace(CHAR_ESCAPE, CHAR_ESCAPE + CHAR_ESCAPE)
.replace(CHAR_SEPARATOR, CHAR_ESCAPE + CHAR_SEPARATOR))
def iterencode(iter):
"""
Encode enumerable as compact string representation.
......
This diff is collapsed.
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