Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions timepiece/reports/tests/test_payroll.py
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,29 @@ def testMonthlyPayrollTotals(self):

self.assertEquals(totals['grand_total'], Decimal('230.00'))

def testCSVExport(self):
self._setupMonthlyTotals()
response = self.client.get(self.url, dict(self.args, **{'export': True}))
rows = [row.split(',') for row in response.content.decode().strip().split('\r\n')]

# Well-formed CSV: all rows are same length
length = len(rows[0])
for row in rows:
self.assertEqual(length, len(row))

# Expected headers are present
labels = self.response.context['labels']
# minimum: "Name", "Total Worked Hours", "Grand Total"
headers_length = 3
# headers for each category of hours
if 'billable' in labels.keys():
headers_length += (len(labels['billable']) * 2) + 2
if 'nonbillable' in labels.keys():
headers_length += (len(labels['nonbillable']) * 2) + 2
if 'leave' in labels.keys():
headers_length += len(labels['leave']) + 1
self.assertEqual(length, headers_length)

def testNoPermission(self):
"""
Regular users shouldn't be able to retrieve the payroll report
Expand Down
2 changes: 1 addition & 1 deletion timepiece/reports/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
name='report_hourly'),

url(r'^reports/payroll/$',
views.report_payroll_summary,
views.ReportPayrollSummary.as_view(),
name='report_payroll_summary'),

url(r'^reports/billable_hours/$',
Expand Down
129 changes: 129 additions & 0 deletions timepiece/reports/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@

from timepiece import utils
from timepiece.utils.csv import CSVViewMixin, DecimalEncoder
from timepiece.utils.views import cbv_decorator

from timepiece.contracts.models import ProjectContract
from timepiece.entries.models import Entry, ProjectHours
Expand Down Expand Up @@ -382,6 +383,134 @@ def report_payroll_summary(request):
})


@cbv_decorator(permission_required('entries.view_payroll_summary'))
class ReportPayrollSummary(CSVViewMixin, TemplateView):
template_name = 'timepiece/reports/payroll_summary.html'

def get_context_data(self, **kwargs):
context = super(ReportPayrollSummary, self).get_context_data(**kwargs)
request = self.request
date = timezone.now() - relativedelta(months=1)
from_date = utils.get_month_start(date).date()
to_date = from_date + relativedelta(months=1)

year_month_form = PayrollSummaryReportForm(request.GET or None, initial={
'month': from_date.month,
'year': from_date.year,
})

if year_month_form.is_valid():
from_date, to_date = year_month_form.save()
last_billable = utils.get_last_billable_day(from_date)
projects = utils.get_setting('TIMEPIECE_PAID_LEAVE_PROJECTS')
weekQ = Q(end_time__gt=utils.get_week_start(from_date),
end_time__lt=last_billable + relativedelta(days=1))
monthQ = Q(end_time__gt=from_date, end_time__lt=to_date)
workQ = ~Q(project__in=projects.values())
statusQ = Q(status=Entry.INVOICED) | Q(status=Entry.APPROVED)
# Weekly totals
week_entries = Entry.objects.date_trunc('week').filter(
weekQ, statusQ, workQ
)
date_headers = generate_dates(from_date, last_billable, by='week')
weekly_totals = list(get_project_totals(week_entries, date_headers,
'total', overtime=True))
# Monthly totals
leave = Entry.objects.filter(monthQ, ~workQ)
leave = leave.values('user', 'hours', 'project__name')
extra_values = ('project__type__label',)
month_entries = Entry.objects.date_trunc('month', extra_values)
month_entries_valid = month_entries.filter(monthQ, statusQ, workQ)
labels, monthly_totals = get_payroll_totals(month_entries_valid, leave)
# Unapproved and unverified hours
entries = Entry.objects.filter(monthQ).order_by() # No ordering
user_values = ['user__pk', 'user__first_name', 'user__last_name']
unverified = entries.filter(status=Entry.UNVERIFIED, user__is_active=True) \
.values_list(*user_values).distinct()
unapproved = entries.filter(status=Entry.VERIFIED) \
.values_list(*user_values).distinct()
context.update({
'from_date': from_date,
'year_month_form': year_month_form,
'date_headers': date_headers,
'weekly_totals': weekly_totals,
'monthly_totals': monthly_totals,
'unverified': unverified,
'unapproved': unapproved,
'labels': labels,
})

return context

def convert_context_to_csv(self, context):
"""Convert the context dictionary into a CSV file."""
content = []
headers = ['Name']
labels = context['labels']
monthly_totals = context['monthly_totals']

# Header row setup
TYPES = ['hours', 'percent']
if labels.get('billable', None):
for label in labels.get('billable'):
for type in TYPES:
headers.append('Billable Projects: %s (%s)' % (label, type))
for type in TYPES:
headers.append('Total Billable Hours (%s)' % type)

if labels.get('nonbillable', None):
for label in labels.get('nonbillable'):
for type in TYPES:
headers.append('Non-Billable Projects: %s (%s)' % (label, type))
for type in TYPES:
headers.append('Total Non-Billable Hours (%s)' % type)

headers.append('Total Worked Hours')

if labels.get('leave', None):
for label in labels.get('leave'):
headers.append('Paid Leave: %s' % label)
headers.append('Total Leave Hours')

headers.append('Grand Total')

content.append(headers)

for row in monthly_totals:
data = []
data.append(row['name'])
if labels.get('billable', None):
for entry in row.get('billable'):
data.append(entry.get('hours',''))
data.append(entry.get('percent',''))
if labels.get('nonbillable', None):
for entry in row.get('nonbillable'):
data.append(entry.get('hours',''))
data.append(entry.get('percent',''))
data.append(row.get('work_total'))
if labels.get('leave', None):
for entry in row.get('leave'):
data.append(entry.get('hours'))
data.append(row.get('grand_total'))

content.append(data)

return content

def get(self, request, *args, **kwargs):
self.export = request.GET.get('export', False)
context = self.get_context_data()
kls = CSVViewMixin if self.export else TemplateView
return kls.render_to_response(self, context)

def get_filename(self, context):
request = self.request.GET.copy()
month = request.get('month', 'month')
year = request.get('year', 'year')
return 'hours_{0}_{1}.csv'.format(
month, year, )


@permission_required('entries.view_entry_summary')
def report_productivity(request):
report = []
Expand Down
1 change: 1 addition & 0 deletions timepiece/templates/timepiece/reports/payroll_summary.html
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
<form class="form-inline" method="get" action="" id="date-filter" accept-charset="utf-8">
{{ year_month_form|as_bootstrap:"inline" }}
<input class="btn btn-primary" type='submit' name='yearmonth' value="Update" id='yearmonth'/>
<input class="btn" type='submit' name='export' value="Export" id='export'/>
</form>
</div>
</div>
Expand Down