Refactor time sheet, schedule filter forms to use colander/deform

also add "print employee schedule" feature, didn't realize that was missing
This commit is contained in:
Lance Edgar 2018-02-11 15:58:06 -06:00
parent d30e5e2b02
commit 4191e50456
10 changed files with 225 additions and 100 deletions

View file

@ -502,6 +502,8 @@ class Form(object):
if key in schema:
schema[key].widget = widget
# TODO: we are now doing this when making deform.Form, in which
# case, do we still need to do it here?
# apply any default values
for key, default in self.defaults.items():
if key in schema:
@ -656,6 +658,13 @@ class Form(object):
schema = self.make_schema()
# TODO: we are still also doing this when making the schema, but
# seems like this should be the right place instead?
# apply any default values
for key, default in self.defaults.items():
if key in schema:
schema[key].default = default
# get initial form values from model instance
kwargs = {}
if self.model_instance:

View file

@ -26,6 +26,8 @@ Form Schema Types
from __future__ import unicode_literals, absolute_import
import six
from rattail.db import model
import colander
@ -58,20 +60,28 @@ class JQueryTime(colander.Time):
return colander.timeparse(cstruct, formats[0])
class ObjectType(colander.SchemaType):
class ModelType(colander.SchemaType):
"""
Custom schema type for scalar ORM relationship fields.
"""
model_class = None
session = None
def __init__(self, model_class=None, session=None):
if model_class:
self.model_class = model_class
if session:
self.session = session
else:
self.session = self.make_session()
def make_session(self):
return Session()
@property
def model_title(self):
self.model_class.get_model_title()
@property
def session(self):
return Session()
def serialize(self, node, appstruct):
if appstruct is colander.null:
return colander.null
@ -86,28 +96,46 @@ class ObjectType(colander.SchemaType):
return obj
class StoreType(ObjectType):
# TODO: deprecate / remove this
ObjectType = ModelType
class StoreType(ModelType):
"""
Custom schema type for store field.
"""
model_class = model.Store
class CustomerType(ObjectType):
class CustomerType(ModelType):
"""
Custom schema type for customer field.
"""
model_class = model.Customer
class ProductType(ObjectType):
class DepartmentType(ModelType):
"""
Custom schema type for department field.
"""
model_class = model.Department
class EmployeeType(ModelType):
"""
Custom schema type for employee field.
"""
model_class = model.Employee
class ProductType(ModelType):
"""
Custom schema type for product relationship field.
"""
model_class = model.Product
class UserType(ObjectType):
class UserType(ModelType):
"""
Custom schema type for user field.
"""

View file

@ -2,7 +2,7 @@
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2017 Lance Edgar
# Copyright © 2010-2018 Lance Edgar
#
# This file is part of Rattail.
#
@ -44,7 +44,9 @@ class ReadonlyWidget(dfwidget.HiddenWidget):
if cstruct in (colander.null, None):
cstruct = ''
# TODO: is this hacky?
text = field.parent.tailbone_form.render_field_value(field.name)
text = kw.get('text')
if not text:
text = field.parent.tailbone_form.render_field_value(field.name)
return HTML.tag('span', text) + tags.hidden(field.name, value=cstruct, id=field.oid)
@ -66,7 +68,9 @@ class JQueryDateWidget(dfwidget.DateInputWidget):
requirements = None
default_options = (
('changeMonth', True),
('changeYear', True),
('dateFormat', 'yy-mm-dd'),
)
def serialize(self, field, cstruct, **kw):
@ -76,8 +80,8 @@ class JQueryDateWidget(dfwidget.DateInputWidget):
template = readonly and self.readonly_template or self.template
options = dict(
kw.get('options') or self.options or self.default_options
)
options['dateFormat'] = 'yy-mm-dd'
)
options.update(kw.get('extra_options', {}))
kw.setdefault('options_json', json.dumps(options))
values = self.get_template_values(field, cstruct, kw)
return field.renderer(template, **values)
@ -132,7 +136,7 @@ class JQueryAutocompleteWidget(dfwidget.AutocompleteInputWidget):
kw['options'] = json.dumps(options)
kw['field_display'] = self.field_display
kw['cleared_callback'] = self.cleared_callback
kw['selected_callback'] = self.selected_callback
kw.setdefault('selected_callback', self.selected_callback)
tmpl_values = self.get_template_values(field, cstruct, kw)
template = readonly and self.readonly_template or self.template
return field.renderer(template, **tmpl_values)

View file

@ -20,4 +20,14 @@
}
);
</script>
<script tal:condition="selected_callback" type="text/javascript">
deform.addCallback(
'${oid}',
function (oid) {
$('#' + oid).datepicker('option', 'onSelect', ${selected_callback});
}
);
</script>
</div>

View file

@ -41,6 +41,15 @@
return true;
}
function date_selected(dateText, inst) {
if (confirm_leave()) {
$('#filter-form').submit();
} else {
// revert date value
$('.week-picker input[name="date"]').val($('.week-picker').data('week'));
}
}
$(function() {
$('#filter-form').submit(function() {
@ -69,17 +78,7 @@
$('.week-picker button.nav').click(function() {
if (confirm_leave()) {
$('.week-picker #date').val($(this).data('date'));
$('#filter-form').submit();
}
});
$('.week-picker #date').datepicker({
dateFormat: 'mm/dd/yy',
changeYear: true,
changeMonth: true,
showButtonPanel: true,
onSelect: function(dateText, inst) {
$('.week-picker input[name="date"]').val($(this).data('date'));
$('#filter-form').submit();
}
});
@ -134,8 +133,8 @@
<%def name="timesheet_wrapper(with_edit_form=False, change_employee=None)">
<div class="timesheet-wrapper">
${form.begin(id='filter-form')}
${form.csrf_token()}
${h.form(request.current_route_url(_query=False), id='filter-form')}
${h.csrf_token(request)}
<table class="timesheet-header">
<tbody>
@ -147,26 +146,31 @@
<div class="field-wrapper employee">
<label>Employee</label>
<div class="field">
% if request.has_perm('{}.viewall'.format(permission_prefix)):
${autocomplete('employee', url('employees.autocomplete'),
field_value=employee.uuid if employee else None,
field_display=unicode(employee or ''),
selected='employee_selected',
change_clicked=change_employee)}
% else:
${form.hidden('employee', value=employee.uuid)}
${employee}
% endif
${dform['employee'].serialize(text=six.text_type(employee), selected_callback='employee_selected')|n}
</div>
</div>
% endif
% if store_options is not Undefined:
${form.field_div('store', h.select('store', store.uuid if store else None, store_options))}
<div class="field-wrapper store">
<div class="field-row">
<label for="store">Store</label>
<div class="field">
${dform['store'].serialize()|n}
</div>
</div>
</div>
% endif
% if department_options is not Undefined:
${form.field_div('department', h.select('department', department.uuid if department else None, department_options))}
<div class="field-wrapper department">
<div class="field-row">
<label for="department">Department</label>
<div class="field">
${dform['department'].serialize()|n}
</div>
</div>
</div>
% endif
<div class="field-wrapper week">
@ -190,11 +194,11 @@
<tr>
<td class="tools">
<div class="grid-tools">
<div class="week-picker">
<button type="button" class="nav" data-date="${prev_sunday.strftime('%m/%d/%Y')}">&laquo; Previous</button>
<button type="button" class="nav" data-date="${next_sunday.strftime('%m/%d/%Y')}">Next &raquo;</button>
<div class="week-picker" data-week="${sunday.strftime('%Y-%m-%d')}">
<button type="button" class="nav" data-date="${prev_sunday.strftime('%Y-%m-%d')}">&laquo; Previous</button>
<button type="button" class="nav" data-date="${next_sunday.strftime('%Y-%m-%d')}">Next &raquo;</button>
<label>Jump to week:</label>
${form.text('date', value=sunday.strftime('%m/%d/%Y'))}
${dform['date'].serialize(extra_options={'showButtonPanel': True}, selected_callback='date_selected')|n}
</div>
</div><!-- grid-tools -->
</td><!-- tools -->
@ -203,7 +207,7 @@
</tbody>
</table><!-- timesheet-header -->
${form.end()}
${h.end_form()}
% if with_edit_form:
${self.edit_form()}

View file

@ -6,7 +6,11 @@
<li>${h.link_to("Edit Schedule", url('schedule.edit'))}</li>
% endif
% if request.has_perm('schedule.print'):
<li>${h.link_to("Print Schedule", url('schedule.print'), target='_blank')}</li>
% if employee is Undefined:
<li>${h.link_to("Print Schedule", url('schedule.print'), target='_blank')}</li>
% else:
<li>${h.link_to("Print this Schedule", url('schedule.employee.print'), target='_blank')}</li>
% endif
% endif
% if request.has_perm('timesheet.view'):
<li>${h.link_to("View this Time Sheet", url('schedule.goto.timesheet'), class_='goto')}</li>

View file

@ -0,0 +1,20 @@
## -*- coding: utf-8; -*-
<%namespace file="/shifts/base.mako" import="timesheet" />
<%namespace file="/shifts/schedule.mako" import="render_day" />
<html>
<head>
## TODO: this seems a little hacky..?
${h.stylesheet_link(request.static_url('tailbone:static/css/normalize.css'), media='all')}
${h.stylesheet_link(request.static_url('tailbone:static/css/base.css'), media='all')}
${h.stylesheet_link(request.static_url('tailbone:static/css/grids.css'), media='all')}
${h.stylesheet_link(request.static_url('tailbone:static/css/timesheet.css'), media='all')}
${h.stylesheet_link(request.static_url('tailbone:static/css/schedule_print.css'), media='print')}
</head>
<body>
<h1>
${employee} -
${week_of}
</h1>
${timesheet(render_day=render_day)}
</body>
</html>

View file

@ -2,7 +2,7 @@
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2017 Lance Edgar
# Copyright © 2010-2018 Lance Edgar
#
# This file is part of Rattail.
#
@ -36,28 +36,29 @@ from rattail.db import model, api
from rattail.time import localtime, make_utc, get_sunday
from rattail.util import pretty_hours, hours_as_decimal
import formencode as fe
from pyramid_simpleform import Form
import colander
from deform import widget as dfwidget
from webhelpers2.html import tags, HTML
from tailbone import forms
from tailbone import forms2 as forms
from tailbone.db import Session
from tailbone.views import View
class ShiftFilter(fe.Schema):
allow_extra_fields = True
filter_extra_fields = True
store = forms.validators.ValidStore()
department = forms.validators.ValidDepartment()
date = fe.validators.DateConverter()
class ShiftFilter(colander.Schema):
store = colander.SchemaNode(forms.types.StoreType())
department = colander.SchemaNode(forms.types.DepartmentType())
date = colander.SchemaNode(colander.Date())
class EmployeeShiftFilter(fe.Schema):
allow_extra_fields = True
filter_extra_fields = True
employee = forms.validators.ValidEmployee()
date = fe.validators.DateConverter()
class EmployeeShiftFilter(colander.Schema):
employee = colander.SchemaNode(forms.types.EmployeeType())
date = colander.SchemaNode(colander.Date())
class TimeSheetView(View):
@ -166,47 +167,88 @@ class TimeSheetView(View):
Process a "shift filter" form if one was in fact POST'ed. If it was
then we store new context in session and redirect to display as normal.
"""
if self.request.method == 'POST':
if form.validate():
store = form.data['store']
self.request.session['timesheet.{}.store'.format(self.key)] = store.uuid if store else None
department = form.data['department']
self.request.session['timesheet.{}.department'.format(self.key)] = department.uuid if department else None
date = form.data['date']
self.request.session['timesheet.{}.date'.format(self.key)] = date.strftime('%m/%d/%Y') if date else None
raise self.redirect(self.request.current_route_url())
if form.validate(newstyle=True):
store = form.validated['store']
self.request.session['timesheet.{}.store'.format(self.key)] = store.uuid if store else None
department = form.validated['department']
self.request.session['timesheet.{}.department'.format(self.key)] = department.uuid if department else None
date = form.validated['date']
self.request.session['timesheet.{}.date'.format(self.key)] = date.strftime('%m/%d/%Y') if date else None
raise self.redirect(self.request.current_route_url())
def process_employee_filter_form(self, form):
"""
Process an "employee shift filter" form if one was in fact POST'ed. If it
was then we store new context in session and redirect to display as normal.
"""
if self.request.method == 'POST':
if form.validate():
employee = form.data['employee']
self.request.session['timesheet.{}.employee'.format(self.key)] = employee.uuid if employee else None
date = form.data['date']
self.request.session['timesheet.{}.employee.date'.format(self.key)] = date.strftime('%m/%d/%Y') if date else None
raise self.redirect(self.request.current_route_url())
if form.validate(newstyle=True):
employee = form.validated['employee']
self.request.session['timesheet.{}.employee'.format(self.key)] = employee.uuid if employee else None
date = form.validated['date']
self.request.session['timesheet.{}.employee.date'.format(self.key)] = date.strftime('%m/%d/%Y') if date else None
raise self.redirect(self.request.current_route_url())
def make_full_filter_form(self, context):
form = forms.Form(schema=ShiftFilter(), request=self.request)
stores = self.get_stores()
store_values = [(s.uuid, "{} - {}".format(s.id, s.name)) for s in stores]
store_values.insert(0, ('', "(all)"))
form.set_widget('store', forms.widgets.PlainSelectWidget(values=store_values))
if context['store']:
form.set_default('store', context['store'].uuid)
departments = self.get_departments()
department_values = [(d.uuid, d.name) for d in departments]
department_values.insert(0, ('', "(all)"))
form.set_widget('department', forms.widgets.PlainSelectWidget(values=department_values))
if context['department']:
form.set_default('department', context['department'].uuid)
form.set_type('date', 'date_jquery')
form.set_default('date', get_sunday(context['date']))
return form
def full(self):
"""
View a "full" timesheet/schedule, i.e. all employees but filterable by
store and/or department.
"""
form = Form(self.request, schema=ShiftFilter)
self.process_filter_form(form)
context = self.get_timesheet_context()
form = self.make_full_filter_form(context)
self.process_filter_form(form)
context['form'] = form
return self.render_full(**context)
def make_employee_filter_form(self, context):
"""
View time sheet for single employee.
"""
permission_prefix = self.key
form = forms.Form(schema=EmployeeShiftFilter(), request=self.request)
if self.request.has_perm('{}.viewall'.format(permission_prefix)):
employee_display = six.text_type(context['employee'] or '')
employees_url = self.request.route_url('employees.autocomplete')
form.set_widget('employee', forms.widgets.JQueryAutocompleteWidget(
field_display=employee_display, service_url=employees_url))
if context['employee']:
form.set_default('employee', context['employee'].uuid)
else:
form.set_widget('employee', forms.widgets.ReadonlyWidget())
form.set_default('employee', context['employee'].uuid)
form.set_type('date', 'date_jquery')
form.set_default('date', get_sunday(context['date']))
return form
def employee(self):
"""
View time sheet for single employee.
"""
form = Form(self.request, schema=EmployeeShiftFilter)
self.process_employee_filter_form(form)
context = self.get_employee_context()
form = self.make_employee_filter_form(context)
self.process_employee_filter_form(form)
context['form'] = form
return self.render_single(**context)
@ -280,7 +322,8 @@ class TimeSheetView(View):
context = {
'page_title': self.get_title_full(),
'form': forms.FormRenderer(form) if form else None,
'form': form,
'dform': form.make_deform_form() if form else None,
'employees': employees,
'stores': stores,
'store_options': store_options,
@ -326,7 +369,8 @@ class TimeSheetView(View):
context = {
'single': True,
'page_title': "Employee {}".format(self.get_title()),
'form': forms.FormRenderer(form) if form else None,
'form': form,
'dform': form.make_deform_form() if form else None,
'employee': employee,
'employees': [employee],
'week_of': week_of,

View file

@ -1,8 +1,8 @@
# -*- coding: utf-8 -*-
# -*- coding: utf-8; -*-
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2017 Lance Edgar
# Copyright © 2010-2018 Lance Edgar
#
# This file is part of Rattail.
#
@ -31,10 +31,8 @@ import datetime
from rattail.db import model
from rattail.time import localtime, make_utc, get_sunday
from pyramid_simpleform import Form
from tailbone.db import Session
from tailbone.views.shifts.lib import TimeSheetView, ShiftFilter
from tailbone.views.shifts.lib import TimeSheetView
class ScheduleView(TimeSheetView):
@ -61,10 +59,9 @@ class ScheduleView(TimeSheetView):
return self.redirect(self.request.route_url('schedule.edit'))
# okay then, process filters; redirect if any were received
form = Form(self.request, schema=ShiftFilter)
self.process_filter_form(form)
context = self.get_timesheet_context()
form = self.make_full_filter_form(context)
self.process_filter_form(form)
# okay then, maybe process saved shift data
if self.request.method == 'POST':
@ -199,12 +196,20 @@ class ScheduleView(TimeSheetView):
permission='schedule.edit')
config.add_tailbone_permission('schedule', 'schedule.edit', "Edit full schedule")
# print schedule
# printing "any" schedule requires this permission
config.add_tailbone_permission('schedule', 'schedule.print', "Print schedule")
# print full schedule
config.add_route('schedule.print', '/schedule/print')
config.add_view(cls, attr='full', route_name='schedule.print',
renderer='/shifts/schedule_print.mako',
permission='schedule.print')
config.add_tailbone_permission('schedule', 'schedule.print', "Print schedule")
# print employee schedule
config.add_route('schedule.employee.print', '/schedule/employee/print')
config.add_view(cls, attr='employee', route_name='schedule.employee.print',
renderer='/shifts/schedule_print_employee.mako',
permission='schedule.print')
def includeme(config):

View file

@ -1,8 +1,8 @@
# -*- coding: utf-8 -*-
# -*- coding: utf-8; -*-
################################################################################
#
# Rattail -- Retail Software Framework
# Copyright © 2010-2017 Lance Edgar
# Copyright © 2010-2018 Lance Edgar
#
# This file is part of Rattail.
#
@ -31,10 +31,8 @@ import datetime
from rattail.db import model
from rattail.time import make_utc, localtime
from pyramid_simpleform import Form
from tailbone.db import Session
from tailbone.views.shifts.lib import TimeSheetView as BaseTimeSheetView, EmployeeShiftFilter
from tailbone.views.shifts.lib import TimeSheetView as BaseTimeSheetView
class TimeSheetView(BaseTimeSheetView):
@ -50,10 +48,9 @@ class TimeSheetView(BaseTimeSheetView):
View for editing single employee's timesheet
"""
# process filters; redirect if any were received
form = Form(self.request, schema=EmployeeShiftFilter)
self.process_employee_filter_form(form)
context = self.get_employee_context()
form = self.make_employee_filter_form(context)
self.process_employee_filter_form(form)
# okay then, maybe process saved shift data
if self.request.method == 'POST':