From 4191e504568e242439d1d3aa13c14acba36946f9 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sun, 11 Feb 2018 15:58:06 -0600 Subject: [PATCH] Refactor time sheet, schedule filter forms to use colander/deform also add "print employee schedule" feature, didn't realize that was missing --- tailbone/forms2/core.py | 9 ++ tailbone/forms2/types.py | 46 +++++-- tailbone/forms2/widgets.py | 14 ++- tailbone/templates/deform/date_jquery.pt | 10 ++ tailbone/templates/shifts/base.mako | 64 +++++----- tailbone/templates/shifts/schedule.mako | 6 +- .../shifts/schedule_print_employee.mako | 20 +++ tailbone/views/shifts/lib.py | 118 ++++++++++++------ tailbone/views/shifts/schedule.py | 25 ++-- tailbone/views/shifts/timesheet.py | 13 +- 10 files changed, 225 insertions(+), 100 deletions(-) create mode 100644 tailbone/templates/shifts/schedule_print_employee.mako diff --git a/tailbone/forms2/core.py b/tailbone/forms2/core.py index 2f53572c..d429fda0 100644 --- a/tailbone/forms2/core.py +++ b/tailbone/forms2/core.py @@ -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: diff --git a/tailbone/forms2/types.py b/tailbone/forms2/types.py index e9fcdb59..d45b957e 100644 --- a/tailbone/forms2/types.py +++ b/tailbone/forms2/types.py @@ -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. """ diff --git a/tailbone/forms2/widgets.py b/tailbone/forms2/widgets.py index d99853b7..666d9f41 100644 --- a/tailbone/forms2/widgets.py +++ b/tailbone/forms2/widgets.py @@ -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) diff --git a/tailbone/templates/deform/date_jquery.pt b/tailbone/templates/deform/date_jquery.pt index c33e8332..d8fe1668 100644 --- a/tailbone/templates/deform/date_jquery.pt +++ b/tailbone/templates/deform/date_jquery.pt @@ -20,4 +20,14 @@ } ); + + + diff --git a/tailbone/templates/shifts/base.mako b/tailbone/templates/shifts/base.mako index fd608d86..a76f8c36 100644 --- a/tailbone/templates/shifts/base.mako +++ b/tailbone/templates/shifts/base.mako @@ -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)">
- ${form.begin(id='filter-form')} - ${form.csrf_token()} + ${h.form(request.current_route_url(_query=False), id='filter-form')} + ${h.csrf_token(request)} @@ -147,26 +146,31 @@
- % 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}
% endif % if store_options is not Undefined: - ${form.field_div('store', h.select('store', store.uuid if store else None, store_options))} +
+
+ +
+ ${dform['store'].serialize()|n} +
+
+
% endif % if department_options is not Undefined: - ${form.field_div('department', h.select('department', department.uuid if department else None, department_options))} +
+
+ +
+ ${dform['department'].serialize()|n} +
+
+
% endif
@@ -190,11 +194,11 @@
@@ -203,7 +207,7 @@
-
- - +
+ + - ${form.text('date', value=sunday.strftime('%m/%d/%Y'))} + ${dform['date'].serialize(extra_options={'showButtonPanel': True}, selected_callback='date_selected')|n}
- ${form.end()} + ${h.end_form()} % if with_edit_form: ${self.edit_form()} diff --git a/tailbone/templates/shifts/schedule.mako b/tailbone/templates/shifts/schedule.mako index 61a69c32..ec2a136c 100644 --- a/tailbone/templates/shifts/schedule.mako +++ b/tailbone/templates/shifts/schedule.mako @@ -6,7 +6,11 @@
  • ${h.link_to("Edit Schedule", url('schedule.edit'))}
  • % endif % if request.has_perm('schedule.print'): -
  • ${h.link_to("Print Schedule", url('schedule.print'), target='_blank')}
  • + % if employee is Undefined: +
  • ${h.link_to("Print Schedule", url('schedule.print'), target='_blank')}
  • + % else: +
  • ${h.link_to("Print this Schedule", url('schedule.employee.print'), target='_blank')}
  • + % endif % endif % if request.has_perm('timesheet.view'):
  • ${h.link_to("View this Time Sheet", url('schedule.goto.timesheet'), class_='goto')}
  • diff --git a/tailbone/templates/shifts/schedule_print_employee.mako b/tailbone/templates/shifts/schedule_print_employee.mako new file mode 100644 index 00000000..0ceddd88 --- /dev/null +++ b/tailbone/templates/shifts/schedule_print_employee.mako @@ -0,0 +1,20 @@ +## -*- coding: utf-8; -*- +<%namespace file="/shifts/base.mako" import="timesheet" /> +<%namespace file="/shifts/schedule.mako" import="render_day" /> + + + ## 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')} + + +

    + ${employee} - + ${week_of} +

    + ${timesheet(render_day=render_day)} + + diff --git a/tailbone/views/shifts/lib.py b/tailbone/views/shifts/lib.py index 253abd05..a8c9dfae 100644 --- a/tailbone/views/shifts/lib.py +++ b/tailbone/views/shifts/lib.py @@ -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, diff --git a/tailbone/views/shifts/schedule.py b/tailbone/views/shifts/schedule.py index a8113f9f..393acf8d 100644 --- a/tailbone/views/shifts/schedule.py +++ b/tailbone/views/shifts/schedule.py @@ -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): diff --git a/tailbone/views/shifts/timesheet.py b/tailbone/views/shifts/timesheet.py index b62b9bd4..5e8f9f51 100644 --- a/tailbone/views/shifts/timesheet.py +++ b/tailbone/views/shifts/timesheet.py @@ -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':