diff --git a/tailbone/forms/validators.py b/tailbone/forms/validators.py index 013a70d1..e99bc37f 100644 --- a/tailbone/forms/validators.py +++ b/tailbone/forms/validators.py @@ -48,7 +48,18 @@ class ModelValidator(fe.validators.FancyValidator): obj = Session.query(self.model_class).get(value) if obj: return obj - raise formencode.Invalid("{} not found".format(self.model_name), value, state) + raise fe.Invalid("{} not found".format(self.model_name), value, state) + + def _from_python(self, value, state): + obj = value + if not obj: + return '' + return obj.uuid + + def validate_python(self, value, state): + obj = value + if obj is not None and not isinstance(obj, self.model_class): + raise fe.Invalid("Value must be a valid {} object".format(self.model_name), value, state) class ValidStore(ModelValidator): diff --git a/tailbone/static/css/timesheet.css b/tailbone/static/css/timesheet.css index 547f2138..3ce4ed9f 100644 --- a/tailbone/static/css/timesheet.css +++ b/tailbone/static/css/timesheet.css @@ -3,24 +3,50 @@ * styles for time sheets / schedules **********************************************************************/ +/****************************** + * header table + ******************************/ + .timesheet-header { - overflow: auto; - position: relative; + width: 100%; } -.timesheet-header .week-picker { - bottom: 0.5em; - position: absolute; - right: 0; +.timesheet-header td.filters { + vertical-align: bottom; + width: 100%; +} + +.timesheet-header td.filters .field { + width: auto; +} + +.timesheet-header td.menu { + padding: 0.5em; + vertical-align: top; + white-space: nowrap; +} + +.timesheet-header td.tools { + margin: 0; + padding: 0; + text-align: right; + vertical-align: bottom; + white-space: nowrap; } .timesheet-header .week-picker label { margin-left: 1em; } +/****************************** + * timesheet table + ******************************/ + .timesheet { border-bottom: 1px solid black; border-right: 1px solid black; + clear: both; + margin-top: 0.3em; width: 100%; } diff --git a/tailbone/templates/shifts/base.mako b/tailbone/templates/shifts/base.mako index 7b662f63..9a9a652c 100644 --- a/tailbone/templates/shifts/base.mako +++ b/tailbone/templates/shifts/base.mako @@ -8,124 +8,148 @@ $(function() { - $('.timesheet-header select').selectmenu(); + $('.timesheet-wrapper form').submit(function() { + $('.timesheet-header').mask("Fetching data"); + }); + + $('.timesheet-header select').selectmenu({ + change: function(event, ui) { + $(ui.item.element).parents('form').submit(); + } + }); + + $('.timesheet-header a.goto').click(function() { + $('.timesheet-header').mask("Fetching data"); + }); + + $('.week-picker button.nav').click(function() { + $('.week-picker #date').val($(this).data('date')); + }); $('.week-picker #date').datepicker({ - dateFormat: 'yy-mm-dd', + dateFormat: 'mm/dd/yy', changeYear: true, changeMonth: true, showButtonPanel: true, onSelect: function(dateText, inst) { - $(this).focus().select(); + $(this).parents('form').submit(); } }); - $('.week-picker form').submit(function() { - location.href = '?date=' + $('.week-picker #date').val(); - return false; - }); - }); -<%def name="timesheet(employees, employee_column=True)"> +<%def name="context_menu()"> + +<%def name="timesheet(employee_column=True)"> -
-##
-## -##
-## ${employee} -##
-##
+
-
+ ${form.begin()} -
- -
- ${form.select('store', store_options, selected_value=store.uuid if store else None)} -
-
+ + + -
- -
- ${form.select('department', department_options, selected_value=department.uuid if department else None)} -
-
+ + + % for day in weekdays: + + % endfor + + + % else: + + % for day in employee.weekdays: + + % endfor + + + % endif + +
-
- -
- ${week_of} -
-
+ ##
+ ## + ##
+ ## ${employee} + ##
+ ##
- + ${form.field_div('store', h.select('store', store.uuid if store else None, store_options))} -
- ${h.form(request.current_route_url())} - ${h.link_to(u"« Previous", '?date=' + prev_sunday.strftime('%Y-%m-%d'), class_='button')} - ${h.link_to(u"Next »", '?date=' + next_sunday.strftime('%Y-%m-%d'), class_='button')} - - ${h.text('date', value=sunday.strftime('%Y-%m-%d'))} - ${h.submit('go', "Go")} - ${h.end_form()} -
+ ${form.field_div('department', h.select('department', department.uuid if department else None, department_options))} - +
+ +
+ ${week_of} +
+
- - - - % if employee_column: - - % endif - % for day in weekdays: - + + + + + + + + + + +
Employee${day.strftime('%A')}
${day.strftime('%b %d')}
+
+
+ + + + ${form.text('date', value=sunday.strftime('%m/%d/%Y'))} +
+
+
+ + ${form.end()} + + + + + % if employee_column: + + % endif + % for day in weekdays: + + % endfor + + + + + % for employee in sorted(employees, key=unicode): + + % if employee_column: + + % endif + % for day in employee.weekdays: + + % endfor + + % endfor - - - - - % for employee in sorted(employees, key=unicode): - - % if employee_column: - - % endif - % for day in employee.weekdays: - - % endfor - - - % endfor - % if employee_column: - - - % for day in weekdays: - - % endfor - - - % else: - - % for day in employee.weekdays: - - % endfor - - - % endif - -
Employee${day.strftime('%A')}
${day.strftime('%b %d')}
Total
Hours
${employee} + % for shift in day['shifts']: +

${shift.get_display(request.rattail_config)}

+ % endfor +
${employee.hours_display}
Total
Hours
${employee} - % for shift in day['shifts']: -

${shift.get_display(request.rattail_config)}

- % endfor -
${employee.hours_display}
${len(employees)} employees
${day['hours_display']}${employee.hours_display}
+ % if employee_column: +
${len(employees)} employees
${day['hours_display']}${employee.hours_display}
+
diff --git a/tailbone/templates/shifts/schedule.mako b/tailbone/templates/shifts/schedule.mako index 82be920f..bd55474d 100644 --- a/tailbone/templates/shifts/schedule.mako +++ b/tailbone/templates/shifts/schedule.mako @@ -1,11 +1,14 @@ ## -*- coding: utf-8 -*- <%inherit file="/shifts/base.mako" /> -<%def name="title()">Schedule: ${sunday} +<%def name="title()">Full Schedule - +<%def name="context_menu()"> + % if request.has_perm('timesheet.view'): +
  • ${h.link_to("View this Time Sheet", url('schedule.goto.timesheet'), class_='goto')}
  • + % endif +##
  • ${h.link_to("Print this Schedule", '#')}
  • +##
  • ${h.link_to("Edit this Schedule", '#')}
  • + -${self.timesheet(employees)} +${self.timesheet()} diff --git a/tailbone/templates/shifts/timesheet.mako b/tailbone/templates/shifts/timesheet.mako index 7f6b1ea6..f7730064 100644 --- a/tailbone/templates/shifts/timesheet.mako +++ b/tailbone/templates/shifts/timesheet.mako @@ -1,6 +1,12 @@ ## -*- coding: utf-8 -*- <%inherit file="/shifts/base.mako" /> -<%def name="title()">Time Sheet: ${sunday} +<%def name="title()">Full Time Sheet -${self.timesheet(employees, employee_column=False)} +<%def name="context_menu()"> + % if request.has_perm('schedule.view'): +
  • ${h.link_to("View this Schedule", url('timesheet.goto.schedule'), class_='goto')}
  • + % endif + + +${self.timesheet()} diff --git a/tailbone/views/core.py b/tailbone/views/core.py index c27a0141..a293a576 100644 --- a/tailbone/views/core.py +++ b/tailbone/views/core.py @@ -28,6 +28,8 @@ from __future__ import unicode_literals from rattail.db import model +from pyramid import httpexceptions + from tailbone.db import Session @@ -56,6 +58,12 @@ class View(object): if uuid: return Session.query(model.User).get(uuid) + def redirect(self, url): + """ + Convenience method to return a HTTP 302 response. + """ + return httpexceptions.HTTPFound(location=url) + def fake_error(request): """ diff --git a/tailbone/views/master.py b/tailbone/views/master.py index 2eb7e778..9f7112cd 100644 --- a/tailbone/views/master.py +++ b/tailbone/views/master.py @@ -396,12 +396,6 @@ class MasterView(View): """ return kwargs - def redirect(self, url): - """ - Convenience method to return a HTTP 302 response. - """ - return httpexceptions.HTTPFound(location=url) - ############################## # Grid Stuff ############################## diff --git a/tailbone/views/shifts/core.py b/tailbone/views/shifts/core.py index 9a1fd75f..2023e871 100644 --- a/tailbone/views/shifts/core.py +++ b/tailbone/views/shifts/core.py @@ -44,11 +44,11 @@ class ShiftLengthField(formalchemy.Field): super(ShiftLengthField, self).__init__(name, **kwargs) def shift_length(self, shift): - if not shift.punch_in or not shift.punch_out: + if not shift.start_time or not shift.end_time: return - if shift.punch_out < shift.punch_in: + if shift.end_time < shift.start_time: return "??" - return humanize.naturaldelta(shift.punch_out - shift.punch_in) + return humanize.naturaldelta(shift.end_time - shift.start_time) class ScheduledShiftsView(MasterView): @@ -61,22 +61,26 @@ class ScheduledShiftsView(MasterView): def configure_grid(self, g): g.default_sortkey = 'start_time' g.default_sortdir = 'desc' + g.append(ShiftLengthField('length')) g.configure( include=[ g.employee, g.store, g.start_time, g.end_time, + g.length, ], readonly=True) def configure_fieldset(self, fs): + fs.append(ShiftLengthField('length')) fs.configure( include=[ fs.employee, fs.store, fs.start_time, fs.end_time, + fs.length, ]) @@ -88,15 +92,18 @@ class WorkedShiftsView(MasterView): url_prefix = '/shifts/worked' def configure_grid(self, g): - g.default_sortkey = 'punch_in' + # TODO: these sorters should be automatic once we fix the schema + g.sorters['start_time'] = g.make_sorter(model.WorkedShift.punch_in) + g.sorters['end_time'] = g.make_sorter(model.WorkedShift.punch_out) + g.default_sortkey = 'start_time' g.default_sortdir = 'desc' g.append(ShiftLengthField('length')) g.configure( include=[ g.employee, g.store, - g.punch_in, - g.punch_out, + g.start_time, + g.end_time, g.length, ], readonly=True) @@ -107,8 +114,8 @@ class WorkedShiftsView(MasterView): include=[ fs.employee, fs.store, - fs.punch_in, - fs.punch_out, + fs.start_time, + fs.end_time, fs.length, ]) diff --git a/tailbone/views/shifts/lib.py b/tailbone/views/shifts/lib.py index e8e8bf29..8f12ea9a 100644 --- a/tailbone/views/shifts/lib.py +++ b/tailbone/views/shifts/lib.py @@ -45,38 +45,66 @@ class ShiftFilter(fe.Schema): filter_extra_fields = True store = forms.validators.ValidStore() department = forms.validators.ValidDepartment() + date = fe.validators.DateConverter() class TimeSheetView(View): """ Base view for time sheets. """ + key = None + title = None model_class = None # Set this to False to avoid the default behavior of auto-filtering by # current store. default_filter_store = True - def __call__(self): - date = self.get_date() + @classmethod + def get_title(cls): + return cls.title or cls.key.capitalize() + + def full(self): + date = None store = None department = None employees = Session.query(model.Employee)\ .filter(model.Employee.status == enum.EMPLOYEE_STATUS_CURRENT) form = Form(self.request, schema=ShiftFilter) - if form.validate(): - store = form.data['store'] - department = form.data['department'] + 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 + return self.redirect(self.request.current_route_url()) - elif self.request.method != 'POST' and self.default_filter_store: - store = self.rattail_config.get('rattail', 'store') - if store: - store = api.get_store(Session(), store) + else: + store_key = 'timesheet.{}.store'.format(self.key) + department_key = 'timesheet.{}.department'.format(self.key) + date_key = 'timesheet.{}.date'.format(self.key) + if store_key in self.request.session or department_key in self.request.session or date_key in self.request.session: + store_uuid = self.request.session.get(store_key) + if store_uuid: + store = Session.query(model.Store).get(store_uuid) if store_uuid else None + department_uuid = self.request.session.get(department_key) + if department_uuid: + department = Session.query(model.Department).get(department_uuid) + date_value = self.request.session.get(date_key) + if date_value: + try: + date = datetime.datetime.strptime(date_value, '%m/%d/%Y').date() + except ValueError: + pass - # TODO: - # store = Session.query(model.Store).filter_by(id='003').one() - # department = Session.query(model.Department).filter_by(number=6).one() + else: # nothing stored in session + if self.default_filter_store: + store = self.rattail_config.get('rattail', 'store') + if store: + store = api.get_store(Session(), store) if store: employees = employees.join(model.EmployeeStore)\ @@ -86,25 +114,49 @@ class TimeSheetView(View): employees = employees.join(model.EmployeeDepartment)\ .filter(model.EmployeeDepartment.department == department) - return self.render(date, employees.all(), store=store, department=department, form=form) - - def get_date(self): - date = None - if 'date' in self.request.params: - try: - date = datetime.datetime.strptime(self.request.params['date'], '%Y-%m-%d').date() - except ValueError: - self.request.session.flash("The specified date is not valid: {}".format(self.request.params['date']), 'error') if not date: date = localtime(self.rattail_config).date() - return date + + return self.render(date, employees.all(), store=store, department=department, form=form) + + def crossview(self): + """ + Update session storage to so 'other' view reflects current view + filters, then redirect to other view. + """ + other_key = 'timesheet' if self.key == 'schedule' else 'schedule' + self.session_put('store', self.session_get('store'), mainkey=other_key) + self.session_put('department', self.session_get('department'), mainkey=other_key) + self.session_put('date', self.session_get('date'), mainkey=other_key) + return self.redirect(self.request.route_url(other_key)) + + # def session_has(self, key, mainkey=None): + # if mainkey is None: + # mainkey = self.key + # return 'timesheet.{}.{}'.format(mainkey, key) in self.request.session + + # def session_has_any(self, *keys, **kwargs): + # for key in keys: + # if self.session_has(key, **kwargs): + # return True + # return False + + def session_get(self, key, mainkey=None): + if mainkey is None: + mainkey = self.key + return self.request.session.get('timesheet.{}.{}'.format(mainkey, key)) + + def session_put(self, key, value, mainkey=None): + if mainkey is None: + mainkey = self.key + self.request.session['timesheet.{}.{}'.format(mainkey, key)] = value def get_stores(self): return Session.query(model.Store).order_by(model.Store.id).all() def get_store_options(self, stores): options = [(s.uuid, "{} - {}".format(s.id, s.name)) for s in stores] - options.insert(0, (None, "(all)")) + options.insert(0, ('', "(all)")) return options def get_departments(self): @@ -112,7 +164,7 @@ class TimeSheetView(View): def get_department_options(self, departments): options = [(d.uuid, d.name) for d in departments] - options.insert(0, (None, "(all)")) + options.insert(0, ('', "(all)")) return options def render(self, date, employees, store=None, department=None, form=None): @@ -184,8 +236,10 @@ class TimeSheetView(View): break elif shift.get_date(self.rattail_config) == day: empday['shifts'].append(shift) - empday['hours'] += shift.length - employee.hours += shift.length + length = shift.length + if length is not None: + empday['hours'] += shift.length + employee.hours += shift.length del employee_shifts[0] else: break @@ -198,3 +252,38 @@ class TimeSheetView(View): if employee.hours: minutes = (employee.hours.days * 1440) + (employee.hours.seconds / 60) employee.hours_display = '{}:{:02d}'.format(minutes // 60, minutes % 60) + + @classmethod + def defaults(cls, config): + """ + Provide default configuration for a time sheet view. + """ + cls._defaults(config) + + @classmethod + def _defaults(cls, config): + """ + Provide default configuration for a time sheet view. + """ + title = cls.get_title() + config.add_tailbone_permission_group(cls.key, title) + # config.add_tailbone_permission(cls.key, '{}.view'.format(cls.key), "View personal {}".format(title)) + config.add_tailbone_permission(cls.key, '{}.viewall'.format(cls.key), "View full {}".format(title)) + + # full time sheet + config.add_route(cls.key, '/{}/'.format(cls.key)) + config.add_view(cls, attr='full', route_name=cls.key, + renderer='/shifts/{}.mako'.format(cls.key), + permission='{}.viewall'.format(cls.key)) + + # # single employee time sheet + # config.add_route('{}.employee'.format(cls.key), '/{}/employee/'.format(cls.key)) + # config.add_view(cls, attr='employee', route_name='{}.employee'.format(cls.key), + # renderer='/shifts/{}.mako'.format(cls.key), + # permission='{}.view'.format(cls.key)) + + # goto cross-view (view 'timesheet' as 'schedule' or vice-versa) + other_key = 'timesheet' if cls.key == 'schedule' else 'schedule' + config.add_route('{}.goto.{}'.format(cls.key, other_key), '/{}/goto-{}'.format(cls.key, other_key)) + config.add_view(cls, attr='crossview', route_name='{}.goto.{}'.format(cls.key, other_key), + permission='{}.view'.format(other_key)) diff --git a/tailbone/views/shifts/schedule.py b/tailbone/views/shifts/schedule.py index 233fe351..6229fbd2 100644 --- a/tailbone/views/shifts/schedule.py +++ b/tailbone/views/shifts/schedule.py @@ -26,10 +26,8 @@ Views for employee schedules from __future__ import unicode_literals, absolute_import -from rattail import enum from rattail.db import model -from tailbone.db import Session from tailbone.views.shifts.lib import TimeSheetView @@ -37,14 +35,9 @@ class ScheduleView(TimeSheetView): """ Simple view for current user's schedule. """ + key = 'schedule' model_class = model.ScheduledShift def includeme(config): - - config.add_tailbone_permission('schedule', 'schedule.view', "View Schedule") - - # current user's schedule - config.add_route('schedule', '/schedule/') - config.add_view(ScheduleView, route_name='schedule', - renderer='/shifts/schedule.mako', permission='schedule.view') + ScheduleView.defaults(config) diff --git a/tailbone/views/shifts/timesheet.py b/tailbone/views/shifts/timesheet.py index 4349f788..c2b0efcd 100644 --- a/tailbone/views/shifts/timesheet.py +++ b/tailbone/views/shifts/timesheet.py @@ -35,20 +35,21 @@ class TimeSheetView(TimeSheetView): """ Simple view for current user's time sheet. """ + key = 'timesheet' + title = "Time Sheet" model_class = model.WorkedShift - def __call__(self): - date = self.get_date() - employee = self.request.user.employee - assert employee - return self.render(date, [employee]) + # def __call__(self): + # date = self.get_date() + # employee = self.request.user.employee + # assert employee + # return self.render(date, [employee]) def includeme(config): - - config.add_tailbone_permission('timesheet', 'timesheet.view', "View Time Sheet") + TimeSheetView.defaults(config) # current user's time sheet - config.add_route('timesheet', '/timesheet/') - config.add_view(TimeSheetView, route_name='timesheet', - renderer='/shifts/timesheet.mako', permission='timesheet.view') + # config.add_route('timesheet', '/timesheet/') + # config.add_view(TimeSheetView, route_name='timesheet', + # renderer='/shifts/timesheet.mako', permission='timesheet.view')