Commit ee297aa3 authored by Serge S. Koval's avatar Serge S. Koval

Time, DateTime, Date widgets and fields, documentation update.

parent d463725e
- Core
- Right-side menu items (auth?)
- Pregenerate URLs for menu
- Conditional js include for forms or pages
- More form widgets (date time, time, etc)
- Model Admin
- Ability to sort by fields that are not visible?
- SQLA Model Admin
- Use of date time widgets
- Validation of the joins in the query
- Automatic joined load for foreign keys
- Built-in filtering support
- Many2Many support
- Ability to override form field types
- WYSIWYG editor support
- File admin
- Documentation
- Unit tests
......
......@@ -8,7 +8,7 @@ Introduction
------------
While developing the library, I attempted to make it as flexible as possible. Developer should
not patch a library to achieve desired functionality.
not monkey-patch anything to achieve desired functionality.
Library uses one simple, but powerful concept - administrative pieces are built as classes with
view methods.
......@@ -30,12 +30,12 @@ implementing reusable functional pieces that are highly customizable.
For example, Flask-AdminEx provides ready-to-use SQLAlchemy model interface. It is implemented as a
class which accepts two parameters: model and a database session. While it exposes some
class-level variables which change behavior of the interface (somewhat similar to django.contrib.admin),
nothing prohibits you from overriding form creation or database access methods or adding more views.
nothing prohibits you from overriding form creation logic, database access methods or adding more views.
Initialization
--------------
To start using Admin, you have to create `Admin` class instance and associate it with Flask application::
To start using Flask-AdminEx, you have to create `Admin` class instance and associate it with Flask application::
from flask import Flask
from flask.ext.adminex import Admin
......@@ -47,7 +47,7 @@ To start using Admin, you have to create `Admin` class instance and associate it
app.run()
If you will run this application and will navigate to `http://localhost:5000/admin/ <http://localhost:5000/admin/>`_,
If you start this application and navigate to `http://localhost:5000/admin/ <http://localhost:5000/admin/>`_,
you should see empty "Home" page with a navigation bar on top.
You can change application name by passing `name` parameter to the `Admin` class constructor::
......
......@@ -31,6 +31,7 @@ class Post(db.Model):
id = db.Column(db.Integer, primary_key=True)
title = db.Column(db.String(120))
text = db.Column(db.Text)
date = db.Column(db.DateTime)
user_id = db.Column(db.Integer, db.ForeignKey(User.id))
user = db.relationship(User, backref='posts')
......
......@@ -3,13 +3,14 @@ from sqlalchemy.orm.interfaces import MANYTOONE, ONETOMANY
from sqlalchemy.orm.attributes import InstrumentedAttribute
from sqlalchemy.sql.expression import desc
from wtforms.ext.sqlalchemy.orm import model_form, ModelConverter
from wtforms import fields
from wtforms.ext.sqlalchemy.orm import model_form, converts, ModelConverter
from wtforms.ext.sqlalchemy.fields import QuerySelectField, QuerySelectMultipleField
from flask import flash
from flask.ext.adminex.model import BaseModelView
from flask.ext.adminex.form import AdminForm
from flask.ext.adminex import form
class AdminModelConverter(ModelConverter):
......@@ -40,13 +41,17 @@ class AdminModelConverter(ModelConverter):
return self.view.session.query(remote_model)
if prop.direction is MANYTOONE:
return QuerySelectField(query_factory=query_factory, **kwargs)
return QuerySelectField(query_factory=query_factory,
widget=form.ChosenSelectWidget(),
**kwargs)
elif prop.direction is ONETOMANY:
# Skip backrefs
if not local_column.foreign_keys and self.view.hide_backrefs:
return None
return QuerySelectMultipleField(query_factory=query_factory, **kwargs)
return QuerySelectMultipleField(query_factory=query_factory,
widget=form.ChosenSelectWidget(multiple=True),
**kwargs)
else:
# Ignore pk/fk
if isinstance(prop, ColumnProperty):
......@@ -58,6 +63,20 @@ class AdminModelConverter(ModelConverter):
return super(AdminModelConverter, self).convert(model, mapper,
prop, field_args)
@converts('Date')
def conv_date(self, field_args, **kwargs):
field_args['widget'] = form.DatePickerWidget()
return fields.DateField(**field_args)
@converts('DateTime')
def conv_datetime(self, field_args, **kwargs):
field_args['widget'] = form.DateTimePickerWidget()
return fields.DateTimeField(**field_args)
@converts('Time')
def conv_time(self, field_args, **kwargs):
return form.TimeField(**field_args)
class ModelView(BaseModelView):
"""
......@@ -151,7 +170,7 @@ class ModelView(BaseModelView):
Create form from the model.
"""
return model_form(self.model,
AdminForm,
form.AdminForm,
self.form_columns,
field_args=self.form_args,
converter=AdminModelConverter(self))
......
import time
import datetime
from flask.ext import wtf
from wtforms import fields, widgets
class AdminForm(wtf.Form):
......@@ -17,3 +21,77 @@ class AdminForm(wtf.Form):
return True
return False
class TimeField(fields.Field):
"""
A text field which stores a `datetime.time` matching a format.
"""
widget = widgets.TextInput()
def __init__(self, label=None, validators=None, formats=None, **kwargs):
"""
Constructor
`label`
Label
`validators`
Field validators
`formats`
Supported time formats, as a enumerable.
`kwargs`
Any additional parameters
"""
super(TimeField, self).__init__(label, validators, **kwargs)
self.format = formats or ('%H:%M:%S', '%H:%M',
'%I:%M:%S%p', '%I:%M%p',
'%I:%M:%S %p', '%I:%M %p')
def _value(self):
if self.raw_data:
return u' '.join(self.raw_data)
else:
return self.data and self.data.strftime(self.format) or u''
def process_formdata(self, valuelist):
if valuelist:
date_str = u' '.join(valuelist)
for format in self.formats:
try:
timetuple = time.strptime(date_str, format)
self.data = datetime.time(timetuple.tm_hour,
timetuple.tm_min,
timetuple.tm_sec)
return
except ValueError:
pass
raise ValueError('Invalid time format')
class ChosenSelectWidget(widgets.Select):
def __call__(self, field, **kwargs):
if field.allow_blank and not self.multiple:
kwargs['data-role'] = u'chosenblank'
else:
kwargs['data-role'] = u'chosen'
return super(ChosenSelectWidget, self).__call__(field, **kwargs)
class ChosenSelectField(fields.SelectField):
widget = ChosenSelectWidget
class DatePickerWidget(widgets.TextInput):
def __call__(self, field, **kwargs):
kwargs['data-role'] = u'datepicker'
return super(DatePickerWidget, self).__call__(field, **kwargs)
class DateTimePickerWidget(widgets.TextInput):
def __call__(self, field, **kwargs):
kwargs['data-role'] = u'datetimepicker'
return super(DateTimePickerWidget, self).__call__(field, **kwargs)
......@@ -98,7 +98,7 @@ class BaseModelView(BaseView):
form_args = None
"""
Dictionary of form field arguments. Refer to WTForm documentation on
Dictionary of form field arguments. Refer to WTForm documentation for
list of possible options.
Example::
......@@ -106,7 +106,7 @@ class BaseModelView(BaseView):
class MyModelView(BaseModelView):
form_args = dict(
name=dict(label='First Name', validators=[wtf.required()])
}
)
"""
# Various settings
......
.datepicker {
background-color: #ffffff;
border-color: #999;
border-color: rgba(0, 0, 0, 0.2);
border-style: solid;
border-width: 1px;
-webkit-border-radius: 4px;
-moz-border-radius: 4px;
border-radius: 4px;
-webkit-box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
-moz-box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
-webkit-background-clip: padding-box;
-moz-background-clip: padding-box;
background-clip: padding-box;
display: none;
position: absolute;
z-index: 900;
margin-left: 0;
margin-right: 0;
margin-bottom: 18px;
padding-bottom: 4px;
width: 218px;
}
.datepicker .nav {
font-weight: bold;
width: 100%;
padding: 4px 0;
background-color: #f5f5f5;
color: #808080;
border-bottom: 1px solid #ddd;
-webkit-box-shadow: inset 0 1px 0 #ffffff;
-moz-box-shadow: inset 0 1px 0 #ffffff;
box-shadow: inset 0 1px 0 #ffffff;
zoom: 1;
}
.datepicker .nav:before, .datepicker .nav:after {
display: table;
content: "";
zoom: 1;
*display: inline;
}
.datepicker .nav:after {
clear: both;
}
.datepicker .nav span {
display: block;
float: left;
text-align: center;
height: 28px;
line-height: 28px;
position: relative;
}
.datepicker .nav .bg {
width: 100%;
background-color: #fdf5d9;
height: 28px;
position: absolute;
top: 0;
left: 0;
-webkit-border-radius: 4px;
-moz-border-radius: 4px;
border-radius: 4px;
}
.datepicker .nav .fg {
width: 100%;
position: absolute;
top: 0;
left: 0;
}
.datepicker .button {
cursor: pointer;
padding: 0 4px;
-webkit-border-radius: 4px;
-moz-border-radius: 4px;
border-radius: 4px;
}
.datepicker .button:hover {
background-color: #808080;
color: #ffffff;
}
.datepicker .months {
float: left;
margin-left: 4px;
}
.datepicker .months .name {
width: 72px;
padding: 0;
}
.datepicker .years {
float: right;
margin-right: 4px;
}
.datepicker .years .name {
width: 36px;
padding: 0;
}
.datepicker .dow, .datepicker .days div {
float: left;
width: 30px;
line-height: 25px;
text-align: center;
}
.datepicker .dow {
font-weight: bold;
color: #808080;
}
.datepicker .calendar {
padding: 4px;
}
.datepicker .days div {
cursor: pointer;
-webkit-border-radius: 4px;
-moz-border-radius: 4px;
border-radius: 4px;
}
.datepicker .days div:hover {
background-color: #0064cd;
color: #ffffff;
}
.datepicker .overlap {
color: #bfbfbf;
}
.datepicker .today {
background-color: #fee9cc;
}
.datepicker .selected {
background-color: #bfbfbf;
color: #ffffff;
}
.datepicker .time {
clear: both;
padding-top: 8px;
margin-left: 4px;
}
.datepicker .time label {
text-align: center;
}
.datepicker .time input {
width: 200px;
}
/* ===========================================================
* bootstrap-datepicker.js v1.3.0
* http://twitter.github.com/bootstrap/javascript.html#datepicker
* ===========================================================
* Copyright 2011 Twitter, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* Contributed by Scott Torborg - github.com/storborg
* Loosely based on jquery.date_input.js by Jon Leighton, heavily updated and
* rewritten to match bootstrap javascript approach and add UI features.
*
* Enhancements and fixes by Serge S. Koval - github.com/mrjoes
*
* =========================================================== */
!function ( $ ) {
var all = [];
function clearDatePickers(except) {
var ii;
for(ii = 0; ii < all.length; ii++) {
if(all[ii] != except) {
all[ii].hide();
}
}
}
function DatePicker( element, options ) {
this.$el = $(element);
this.proxy('show').proxy('ahead').proxy('hide').proxy('keyHandler').proxy('selectDate');
var options = $.extend({}, $.fn.datepicker.defaults, options );
// If custom parse/format function was not passed in options and time-based display
// is enabled, use time-compatible parse/format functions
if (options.displayTime && !!!options.parse && !!!options.format) {
options.parse = this.parseDateTime;
}
$.extend(this, options);
this.$el.data('datepicker', this);
all.push(this);
this.init();
}
DatePicker.prototype = {
init: function() {
var $months = this.nav('months', 1);
var $years = this.nav('years', 12);
var $nav = $('<div>').addClass('nav').append($months, $years);
this.$month = $('.name', $months);
this.$year = $('.name', $years);
$calendar = $("<div>").addClass('calendar');
// Populate day of week headers, realigned by startOfWeek.
for (var i = 0; i < this.shortDayNames.length; i++) {
$calendar.append('<div class="dow">' + this.shortDayNames[(i + this.startOfWeek) % 7] + '</div>');
};
this.$days = $('<div>').addClass('days');
$calendar.append(this.$days);
this.$picker = $('<div>')
.addClass('datepicker')
.append($nav, $calendar)
.insertAfter(this.$el);
this.$picker.children()
.click(function(e) { e.stopPropagation() })
// Use this to prevent accidental text selection.
.mousedown(function(e) { e.preventDefault() });
if (this.displayTime) {
this.$time = $('<div class="time">')
.append('<label for="_time">Time</label>')
.append('<input id="_time" type="text" />')
.click(function(e) { e.stopPropagation(); });
this.$picker.append(this.$time);
}
this.$el
.focus(this.show)
.click(this.show)
.change($.proxy(function() { this.selectDate(); }, this));
this.selectDate();
this.hide();
}
, nav: function( c, months ) {
var $subnav = $('<div>' +
'<span class="prev button">&larr;</span>' +
'<span class="name"></span>' +
'<span class="next button">&rarr;</span>' +
'</div>').addClass(c)
$('.prev', $subnav).click($.proxy(function() { this.ahead(-months, 0) }, this));
$('.next', $subnav).click($.proxy(function() { this.ahead(months, 0) }, this));
return $subnav;
}
, updateName: function($area, s) {
// Update either the month or year field, with a background flash
// animation.
var cur = $area.find('.fg').text(),
$fg = $('<div>').addClass('fg').append(s);
$area.empty();
if(cur != s) {
var $bg = $('<div>').addClass('bg');
$area.append($bg, $fg);
$bg.fadeOut('slow', function() {
$(this).remove();
});
} else {
$area.append($fg);
}
}
, selectMonth: function(date) {
this.displayedDate = date;
var newMonth = new Date(date.getFullYear(), date.getMonth(), 1);
if (!this.curMonth || !(this.curMonth.getFullYear() == newMonth.getFullYear() &&
this.curMonth.getMonth() == newMonth.getMonth())) {
this.curMonth = newMonth;
var rangeStart = this.rangeStart(date), rangeEnd = this.rangeEnd(date);
var num_days = this.daysBetween(rangeStart, rangeEnd);
this.$days.empty();
for (var ii = 0; ii <= num_days; ii++) {
var thisDay = new Date(rangeStart.getFullYear(), rangeStart.getMonth(), rangeStart.getDate() + ii, 12, 00);
var $day = $('<div>').attr('date', this.format(thisDay));
$day.text(thisDay.getDate());
if (thisDay.getMonth() != date.getMonth()) {
$day.addClass('overlap');
};
this.$days.append($day);
};
this.updateName(this.$month, this.monthNames[date.getMonth()]);
this.updateName(this.$year, this.curMonth.getFullYear());
$('div', this.$days).click($.proxy(function(e) {
var $targ = $(e.target);
// The date= attribute is used here to provide relatively fast
// selectors for setting certain date cells.
this.update($targ.attr("date"));
// Don't consider this selection final if we're just going to an
// adjacent month.
if(!$targ.hasClass('overlap')) {
this.hide();
}
}, this));
$("[date='" + this.format(new Date()) + "']", this.$days).addClass('today');
};
$('.selected', this.$days).removeClass('selected');
$('[date="' + this.selectedDateStr + '"]', this.$days).addClass('selected');
}
, selectDate: function(date) {
if (typeof(date) == "undefined") {
date = this.parse(this.$el.val());
};
if (!date) {
date = new Date();
if (this.displayTime) {
date = new Date(date.getFullYear(), date.getMonth(), date.getDate());
}
}
this.selectedDate = date;
this.displayedDate = date;
this.selectedDateStr = this.format(this.selectedDate);
this.selectMonth(this.selectedDate);
if (this.displayTime) {
$('input', this.$time).val(this.formatTime(date));
}
}
, update: function(s) {
if (this.displayTime)
s = s + ' ' + this.getTimeString()
this.$el.val(s).change();
}
, show: function(e) {
e && e.stopPropagation();
// Hide all other datepickers.
clearDatePickers(this);
this.selectDate();
var offset = this.$el.offset();
this.$picker.css({
top: offset.top + this.$el.outerHeight() + 2,
left: offset.left
}).show();
$('html').on('keydown', this.keyHandler);
}
, hide: function() {
this.$picker.hide();
$('html').off('keydown', this.keyHandler);
}
, keyHandler: function(e) {
// Keyboard navigation shortcuts.
switch (e.keyCode)
{
case 9:
case 27:
// Tab or escape hides the datepicker. In this case, just return
// instead of breaking, so that the e doesn't get stopped.
this.hide(); return;
case 13:
// Enter selects the currently highlighted date.
this.update(this.selectedDateStr); this.hide(); break;
default:
return;
}
e.preventDefault();
}
, getTimeString: function(s) {
var time = $('input', this.$time).val();
return this.validateTime(time) ? time : '12:00:00';
}
, pad: function(s) {
if (s.length === 1)
s = '0' + s;
return s;
}
, parse: function(s) {
// Parse a partial RFC 3339 string into a Date.
var m;
if ((m = s.match(/^(\d{4,4})-(\d{2,2})-(\d{2,2})$/))) {
return new Date(m[1], m[2] - 1, m[3]);
} else {
return null;
}
}
, parseDateTime: function(s) {
// Parse a partial RFC 3339 string into a Date.
var m;
if ((m = s.match(/^(\d{4,4})-(\d{2,2})-(\d{2,2}) (\d{2,2}):(\d{2,2}):(\d{2,2})$/))) {
return new Date(m[1], m[2] - 1, m[3], m[4], m[5], m[6]);
} else {
return null;
}
}
, validateTime: function(s) {
return s.match(/^(\d{2,2}):(\d{2,2}):(\d{2,2})$/);
}
, format: function(date) {
// Format a Date into a string as specified by RFC 3339.
var month = this.pad((date.getMonth() + 1).toString()),
dom = this.pad(date.getDate().toString());
return date.getFullYear() + '-' + month + "-" + dom;
}
, formatTime: function(date) {
var hour = this.pad(date.getHours().toString()),
min = this.pad(date.getMinutes().toString()),
sec = this.pad(date.getSeconds().toString());
return hour + ':' + min + ':' + sec;
}
, ahead: function(months, days) {
// Move ahead ``months`` months and ``days`` days, both integers, can be
// negative.
this.selectMonth(new Date(this.displayedDate.getFullYear(),
this.displayedDate.getMonth() + months,
this.displayedDate.getDate() + days));
}
, proxy: function(meth) {
// Bind a method so that it always gets the datepicker instance for
// ``this``. Return ``this`` so chaining calls works.
this[meth] = $.proxy(this[meth], this);
return this;
}
, daysBetween: function(start, end) {
// Return number of days between ``start`` Date object and ``end``.
var start = Date.UTC(start.getFullYear(), start.getMonth(), start.getDate());
var end = Date.UTC(end.getFullYear(), end.getMonth(), end.getDate());
return (end - start) / 86400000;
}
, findClosest: function(dow, date, direction) {
// From a starting date, find the first day ahead of behind it that is
// a given day of the week.
var difference = direction * (Math.abs(date.getDay() - dow - (direction * 7)) % 7);
return new Date(date.getFullYear(), date.getMonth(), date.getDate() + difference);
}
, rangeStart: function(date) {
// Get the first day to show in the current calendar view.
return this.findClosest(this.startOfWeek,
new Date(date.getFullYear(), date.getMonth()),
-1);
}
, rangeEnd: function(date) {
// Get the last day to show in the current calendar view.
return this.findClosest((this.startOfWeek - 1) % 7,
new Date(date.getFullYear(), date.getMonth() + 1, 0),
1);
}
};
/* DATEPICKER PLUGIN DEFINITION
* ============================ */
$.fn.datepicker = function( options ) {
return this.each(function() { new DatePicker(this, options); });
};
$(function() {
$('html').click(clearDatePickers);
});
$.fn.datepicker.DatePicker = DatePicker;
$.fn.datepicker.defaults = {
monthNames: ["January", "February", "March", "April", "May", "June",
"July", "August", "September", "October", "November", "December"]
, shortDayNames: ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]
, startOfWeek: 1
, displayTime: false
};
}( window.jQuery || window.ender );
$(function() {
$('[data-role=chosen]').chosen();
$('[data-role=chosenblank]').chosen({allow_single_deselect: true});
$('[data-role=datepicker]').datepicker();
$('[data-role=datetimepicker]').datepicker({displayTime: true});
});
<html>
<head>
<link href="../bootstrap/css/bootstrap.css" rel="stylesheet">
<link href="../css/datepicker.css" rel="stylesheet">
</head>
<body>
<form action="">
<input data-datepicker="datepicker" type="text" value="2011-10-12" />
</form>
<script src="http://code.jquery.com/jquery-1.7.min.js"></script>
<script src="bootstrap-datepicker.js"></script>
</body>
</html>
\ No newline at end of file
......@@ -2,6 +2,7 @@
{% block head %}
<link href="{{ url_for('admin.static', filename='chosen/chosen.css') }}" rel="stylesheet">
<link href="{{ url_for('admin.static', filename='css/datepicker.css') }}" rel="stylesheet">
{% endblock %}
{% block body %}
......@@ -37,7 +38,6 @@
{% endblock %}
{% block tail %}
<script>
$("select").chosen({allow_single_deselect: true});
</script>
<script src="{{ url_for('admin.static', filename='js/bootstrap-datepicker.js') }}"></script>
<script src="{{ url_for('admin.static', filename='js/form.js') }}"></script>
{% endblock %}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment