Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add billable hours indicator to timecard. #955

Merged
merged 15 commits into from
Feb 20, 2020
Merged
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
2 changes: 1 addition & 1 deletion tock/employees/fixtures/user_data.json
Original file line number Diff line number Diff line change
@@ -1 +1 @@
[{"model": "employees.userdata", "pk": 1, "fields": {"user": 1, "start_date": "2013-06-17", "end_date": "2015-05-29", "current_employee": true, "is_18f_employee": true, "is_billable": true, "unit": null}}]
[{"model": "employees.userdata", "pk": 1, "fields": {"user": 1, "start_date": "2013-06-17", "end_date": "2015-05-29", "current_employee": true, "is_18f_employee": true, "is_billable": true, "unit": null, "billable_expectation": 0.80}}]
19 changes: 19 additions & 0 deletions tock/employees/migrations/0025_userdata_billable_expectation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# Generated by Django 2.2.7 on 2019-12-19 14:25

import django.core.validators
from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('employees', '0024_auto_20171229_1156'),
]

operations = [
migrations.AddField(
model_name='userdata',
name='billable_expectation',
field=models.DecimalField(decimal_places=2, default=0.8, max_digits=3, validators=[django.core.validators.MaxValueValidator(limit_value=1)], verbose_name='Percentage of hours which are expected to be billable each week'),
),
]
11 changes: 6 additions & 5 deletions tock/employees/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,12 @@

from django.apps import apps
from django.contrib.auth import get_user_model
from django.db import models, IntegrityError
from django.core.validators import MaxValueValidator
from django.db import IntegrityError, models
from django.db.models import Q

from rest_framework.authtoken.models import Token

from organizations.models import Organization
from projects.models import ProfitLossAccount

from rest_framework.authtoken.models import Token

User = get_user_model()

Expand Down Expand Up @@ -108,6 +106,9 @@ class UserData(models.Model):
is_18f_employee = models.BooleanField(default=True, verbose_name='Is 18F Employee')
is_billable = models.BooleanField(default=True, verbose_name="Is 18F Billable Employee")
unit = models.IntegerField(null=True, choices=UNIT_CHOICES, verbose_name="Select 18F unit", blank=True)
billable_expectation = models.DecimalField(validators=[MaxValueValidator(limit_value=1)],
default=0.80, decimal_places=2, max_digits=3,
verbose_name="Percentage of hours (expressed as a decimal) expected to be billable each week")
profit_loss_account = models.ForeignKey(
ProfitLossAccount,
on_delete=models.CASCADE,
Expand Down
3 changes: 2 additions & 1 deletion tock/employees/tests/test_admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,8 @@ def test_user_data_form(self):
'is_18f_employee': '',
'is_billable': '',
'unit': '',
'profit_loss_account': ProfitLossAccount.objects.first().id
'profit_loss_account': ProfitLossAccount.objects.first().id,
'billable_expectation': 0.80
}
form = UserDataForm(data=form_data)
self.assertTrue(form.is_valid())
Expand Down
2 changes: 1 addition & 1 deletion tock/hours/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ class TimecardForm(forms.ModelForm):

class Meta:
model = Timecard
exclude = ['time_spent', 'reporting_period', 'user']
exclude = ['time_spent', 'reporting_period', 'user', 'billable_expectation']


class SelectWithData(forms.widgets.Select):
Expand Down
20 changes: 20 additions & 0 deletions tock/hours/migrations/0048_auto_20191219_0925.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# Generated by Django 2.2.7 on 2019-12-19 14:25

import django.core.validators
from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('projects', '0024_project_exclude_from_billability'),
('hours', '0047_auto_20191204_0940'),
]

operations = [
migrations.AddField(
model_name='timecard',
name='billable_expectation',
field=models.DecimalField(decimal_places=2, default=0.8, max_digits=3, validators=[django.core.validators.MaxValueValidator(limit_value=1)], verbose_name='Percentage of hours which are expected to be billable this week'),
),
]
14 changes: 12 additions & 2 deletions tock/hours/models.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import datetime

from django.contrib.auth.models import User
from django.core.validators import MaxValueValidator
from django.db import models
from django.db.models import Q

from employees.models import EmployeeGrade, UserData
from projects.models import ProfitLossAccount, Project

Expand Down Expand Up @@ -218,6 +218,9 @@ class Timecard(models.Model):
submitted = models.BooleanField(default=False)
created = models.DateTimeField(auto_now_add=True)
modified = models.DateTimeField(auto_now=True)
billable_expectation = models.DecimalField(validators=[MaxValueValidator(limit_value=1)],
default=0.80, decimal_places=2, max_digits=3,
verbose_name="Percentage of hours which are expected to be billable this week")

class Meta:
unique_together = ('user', 'reporting_period')
Expand All @@ -226,6 +229,14 @@ class Meta:
def __str__(self):
return "%s - %s" % (self.user, self.reporting_period.start_date)

def save(self, *args, **kwargs):
"""
If this is a new timecard,
Set weekly billing expectation from user.user_data
"""
if not self.id and self.user:
self.billable_expectation = self.user.user_data.billable_expectation
super().save(*args, **kwargs)

class TimecardNoteManager(models.Manager):
def enabled(self):
Expand Down Expand Up @@ -313,7 +324,6 @@ def save(self, *args, **kwargs):
self.rendered_body = render_markdown(self.body)
super(TimecardNote, self).save(*args, **kwargs)


class TimecardObject(models.Model):
timecard = models.ForeignKey(Timecard, related_name='timecardobjects', on_delete=models.CASCADE)
project = models.ForeignKey(Project, on_delete=models.CASCADE)
Expand Down
3 changes: 2 additions & 1 deletion tock/hours/tests/test_forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,8 @@ def test_timecard_inline_formset_modify_saved(self):

class TimecardInlineFormSetTests(TestCase):
fixtures = [
'projects/fixtures/projects.json', 'tock/fixtures/prod_user.json']
'projects/fixtures/projects.json', 'tock/fixtures/prod_user.json',
'employees/fixtures/user_data.json']

setUp = time_card_inlineformset_setup

Expand Down
4 changes: 4 additions & 0 deletions tock/hours/views.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import csv
import datetime as dt
import io
import json
from itertools import chain
from operator import attrgetter

Expand Down Expand Up @@ -504,6 +505,9 @@ def get_context_data(self, **kwargs):
'timecard_notes': TimecardNote.objects.enabled(),
'unsubmitted': not self.object.submitted,
'reporting_period': reporting_period,
'excluded_from_billability': json.dumps(list(Project.objects.excluded_from_billability().values_list('id', flat=True))),
'billable_expectation': json.dumps(str(self.object.billable_expectation)),
'non_billable_projects': json.dumps(list(Project.objects.non_billable().values_list('id', flat=True)))
})

return context
Expand Down
1 change: 1 addition & 0 deletions tock/projects/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ class ProjectAdmin(admin.ModelAdmin):
'start_date',
'end_date',
'active',
'exclude_from_billability',
'agreement_URL',
'description',
'alerts',
Expand Down
18 changes: 18 additions & 0 deletions tock/projects/migrations/0024_project_exclude_from_billability.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Generated by Django 2.2.7 on 2019-12-19 14:25

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('projects', '0023_auto_20171229_1156'),
]

operations = [
migrations.AddField(
model_name='project',
name='exclude_from_billability',
field=models.BooleanField(default=False, help_text='Check if this project should be excluded from calculations of billable hours, e.g. Out of Office'),
),
]
17 changes: 16 additions & 1 deletion tock/projects/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,18 @@ def active(self):
def inactive(self):
return self.get_queryset().filter(active=False)

# Get the projects that are categorized as excluded from billability, i.e. out of office, which means
# the hours tocked in these projects will not be used in the total hours calculation when tryng to figure
# out how many hours should be billable in a reporting period.
def excluded_from_billability(self):
return self.get_queryset().filter(exclude_from_billability=True)

# Get the projects that are categorized as nonbillable, which means the hours tocked in these projects
# will be used in the total hourse calcuation when trying to figure out how many hours should be billable
# in a reporting period.
def non_billable(self):
return self.get_queryset().filter(exclude_from_billability=False, accounting_code__billable=False)


class Project(models.Model):
"""
Expand Down Expand Up @@ -230,7 +242,10 @@ class Project(models.Model):
)
project_lead = models.ForeignKey(User, null=True, on_delete=models.CASCADE)
organization = models.ForeignKey(Organization, blank=True, null=True, on_delete=models.CASCADE)

exclude_from_billability = models.BooleanField(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I want to clarify this a little more. I think anything non-billable should be excluded. @saraheckert does that seem right to you?

If that's the case, then I'm not sure I understand the need =for the new field and check.

@amymok can you help explain it?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this should probably read, check if this project should be excluded from calculations of total hours. Because this check is really to make sure we accurately calculate the number of nonbillable hours for a reporting period.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

From the original #940, I think this explains what "excluded" was intended to mean:

The target billable hours for the week as:

(total_hours_billed - hours_billed_to_excluded_projects) * billable_expectation)

So "excluded" hours is the category to which Out of Office lives in.

default=False,
help_text='Check if this project should be excluded from calculations of billable hours, e.g. Out of Office'
)
objects = ProjectManager()

class Meta:
Expand Down
77 changes: 50 additions & 27 deletions tock/tock/static/sass/_entries.scss
Original file line number Diff line number Diff line change
Expand Up @@ -154,26 +154,58 @@
padding-top: 1.5rem;
}

.entries-total-reported-amount {
display:block;
border: 1px solid #5b616b;
border-radius: 0;
box-sizing: border-box;
color: #1F2E4A;
display: block;
font-size: 1.7rem;
height: 4.4rem;
line-height: 1.3;
margin: 0.2em 0;
max-width: 46rem;
outline: none;
padding: 1rem 0.7em;
width: 65%;
background-color: $color-green-lightest;
.entries-total {
.text-label {
display: inline-block;
margin-bottom: 1rem;
}
}

.entries-total-reported-amount, .entries-total-billable-amount {
&, .number-label, .circular-graph {
width: 60px;
height: 60px;
}

.number-label {
position: relative;
display: table-cell;
vertical-align: middle;
text-align: center;
font-weight: bold;
}

.circular-graph {
transform: rotate(-90deg);
position: absolute;
left: 0;

.background {
fill: none;
stroke: $color-primary-alt-lightest;
stroke-width: 3;
transition: stroke-dasharray 0.3s ease-out;
}

.fill {
fill: none;
stroke: currentColor;
stroke-width: 3;
transition: stroke-dasharray 0.3s ease-out;
}
}

&.invalid {
background-color: $color-secondary-dark;
color: #fff;
color: $color-secondary-dark;
}

&.valid {
color: $color-green;
}

display: block;
position: relative;
color: $color-primary-alt-lightest;
}

.entries-total {
Expand All @@ -197,15 +229,6 @@ background-color: $color-green-lightest;
margin-top: 0.5em;
}
}
.entries-total-billable-amount {
color: green;
}
.entries-total-non-billable-amount {
color: #1188ff;
}
.entries-total-reported-wrapper {
font-weight:bold;
}
}

.form-error,
Expand Down
Loading