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: if key in schema:
schema[key].widget = widget 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 # apply any default values
for key, default in self.defaults.items(): for key, default in self.defaults.items():
if key in schema: if key in schema:
@ -656,6 +658,13 @@ class Form(object):
schema = self.make_schema() 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 # get initial form values from model instance
kwargs = {} kwargs = {}
if self.model_instance: if self.model_instance:

View file

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

View file

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

View file

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

View file

@ -41,6 +41,15 @@
return true; 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() { $(function() {
$('#filter-form').submit(function() { $('#filter-form').submit(function() {
@ -69,17 +78,7 @@
$('.week-picker button.nav').click(function() { $('.week-picker button.nav').click(function() {
if (confirm_leave()) { if (confirm_leave()) {
$('.week-picker #date').val($(this).data('date')); $('.week-picker input[name="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) {
$('#filter-form').submit(); $('#filter-form').submit();
} }
}); });
@ -134,8 +133,8 @@
<%def name="timesheet_wrapper(with_edit_form=False, change_employee=None)"> <%def name="timesheet_wrapper(with_edit_form=False, change_employee=None)">
<div class="timesheet-wrapper"> <div class="timesheet-wrapper">
${form.begin(id='filter-form')} ${h.form(request.current_route_url(_query=False), id='filter-form')}
${form.csrf_token()} ${h.csrf_token(request)}
<table class="timesheet-header"> <table class="timesheet-header">
<tbody> <tbody>
@ -147,26 +146,31 @@
<div class="field-wrapper employee"> <div class="field-wrapper employee">
<label>Employee</label> <label>Employee</label>
<div class="field"> <div class="field">
% if request.has_perm('{}.viewall'.format(permission_prefix)): ${dform['employee'].serialize(text=six.text_type(employee), selected_callback='employee_selected')|n}
${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
</div> </div>
</div> </div>
% endif % endif
% if store_options is not Undefined: % 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 % endif
% if department_options is not Undefined: % 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 % endif
<div class="field-wrapper week"> <div class="field-wrapper week">
@ -190,11 +194,11 @@
<tr> <tr>
<td class="tools"> <td class="tools">
<div class="grid-tools"> <div class="grid-tools">
<div class="week-picker"> <div class="week-picker" data-week="${sunday.strftime('%Y-%m-%d')}">
<button type="button" class="nav" data-date="${prev_sunday.strftime('%m/%d/%Y')}">&laquo; Previous</button> <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('%m/%d/%Y')}">Next &raquo;</button> <button type="button" class="nav" data-date="${next_sunday.strftime('%Y-%m-%d')}">Next &raquo;</button>
<label>Jump to week:</label> <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>
</div><!-- grid-tools --> </div><!-- grid-tools -->
</td><!-- tools --> </td><!-- tools -->
@ -203,7 +207,7 @@
</tbody> </tbody>
</table><!-- timesheet-header --> </table><!-- timesheet-header -->
${form.end()} ${h.end_form()}
% if with_edit_form: % if with_edit_form:
${self.edit_form()} ${self.edit_form()}

View file

@ -6,7 +6,11 @@
<li>${h.link_to("Edit Schedule", url('schedule.edit'))}</li> <li>${h.link_to("Edit Schedule", url('schedule.edit'))}</li>
% endif % endif
% if request.has_perm('schedule.print'): % if request.has_perm('schedule.print'):
% if employee is Undefined:
<li>${h.link_to("Print Schedule", url('schedule.print'), target='_blank')}</li> <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 % endif
% if request.has_perm('timesheet.view'): % if request.has_perm('timesheet.view'):
<li>${h.link_to("View this Time Sheet", url('schedule.goto.timesheet'), class_='goto')}</li> <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 # Rattail -- Retail Software Framework
# Copyright © 2010-2017 Lance Edgar # Copyright © 2010-2018 Lance Edgar
# #
# This file is part of Rattail. # 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.time import localtime, make_utc, get_sunday
from rattail.util import pretty_hours, hours_as_decimal from rattail.util import pretty_hours, hours_as_decimal
import formencode as fe import colander
from pyramid_simpleform import Form from deform import widget as dfwidget
from webhelpers2.html import tags, HTML from webhelpers2.html import tags, HTML
from tailbone import forms from tailbone import forms2 as forms
from tailbone.db import Session from tailbone.db import Session
from tailbone.views import View from tailbone.views import View
class ShiftFilter(fe.Schema): class ShiftFilter(colander.Schema):
allow_extra_fields = True
filter_extra_fields = True store = colander.SchemaNode(forms.types.StoreType())
store = forms.validators.ValidStore()
department = forms.validators.ValidDepartment() department = colander.SchemaNode(forms.types.DepartmentType())
date = fe.validators.DateConverter()
date = colander.SchemaNode(colander.Date())
class EmployeeShiftFilter(fe.Schema): class EmployeeShiftFilter(colander.Schema):
allow_extra_fields = True
filter_extra_fields = True employee = colander.SchemaNode(forms.types.EmployeeType())
employee = forms.validators.ValidEmployee()
date = fe.validators.DateConverter() date = colander.SchemaNode(colander.Date())
class TimeSheetView(View): class TimeSheetView(View):
@ -166,13 +167,12 @@ class TimeSheetView(View):
Process a "shift filter" form if one was in fact POST'ed. If it was 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. then we store new context in session and redirect to display as normal.
""" """
if self.request.method == 'POST': if form.validate(newstyle=True):
if form.validate(): store = form.validated['store']
store = form.data['store']
self.request.session['timesheet.{}.store'.format(self.key)] = store.uuid if store else None self.request.session['timesheet.{}.store'.format(self.key)] = store.uuid if store else None
department = form.data['department'] department = form.validated['department']
self.request.session['timesheet.{}.department'.format(self.key)] = department.uuid if department else None self.request.session['timesheet.{}.department'.format(self.key)] = department.uuid if department else None
date = form.data['date'] date = form.validated['date']
self.request.session['timesheet.{}.date'.format(self.key)] = date.strftime('%m/%d/%Y') if date else None 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()) raise self.redirect(self.request.current_route_url())
@ -181,32 +181,74 @@ class TimeSheetView(View):
Process an "employee shift filter" form if one was in fact POST'ed. If it 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. was then we store new context in session and redirect to display as normal.
""" """
if self.request.method == 'POST': if form.validate(newstyle=True):
if form.validate(): employee = form.validated['employee']
employee = form.data['employee']
self.request.session['timesheet.{}.employee'.format(self.key)] = employee.uuid if employee else None self.request.session['timesheet.{}.employee'.format(self.key)] = employee.uuid if employee else None
date = form.data['date'] date = form.validated['date']
self.request.session['timesheet.{}.employee.date'.format(self.key)] = date.strftime('%m/%d/%Y') if date else None 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()) 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): def full(self):
""" """
View a "full" timesheet/schedule, i.e. all employees but filterable by View a "full" timesheet/schedule, i.e. all employees but filterable by
store and/or department. store and/or department.
""" """
form = Form(self.request, schema=ShiftFilter)
self.process_filter_form(form)
context = self.get_timesheet_context() context = self.get_timesheet_context()
form = self.make_full_filter_form(context)
self.process_filter_form(form)
context['form'] = form context['form'] = form
return self.render_full(**context) 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): def employee(self):
""" """
View time sheet for single employee. View time sheet for single employee.
""" """
form = Form(self.request, schema=EmployeeShiftFilter)
self.process_employee_filter_form(form)
context = self.get_employee_context() context = self.get_employee_context()
form = self.make_employee_filter_form(context)
self.process_employee_filter_form(form)
context['form'] = form context['form'] = form
return self.render_single(**context) return self.render_single(**context)
@ -280,7 +322,8 @@ class TimeSheetView(View):
context = { context = {
'page_title': self.get_title_full(), '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, 'employees': employees,
'stores': stores, 'stores': stores,
'store_options': store_options, 'store_options': store_options,
@ -326,7 +369,8 @@ class TimeSheetView(View):
context = { context = {
'single': True, 'single': True,
'page_title': "Employee {}".format(self.get_title()), '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, 'employee': employee,
'employees': [employee], 'employees': [employee],
'week_of': week_of, 'week_of': week_of,

View file

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

View file

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