Skip to content

Commit

Permalink
Merge branch 'main' into snyk-fix-c8dfad6d7aadbfa28c8dd55f578d7917
Browse files Browse the repository at this point in the history
  • Loading branch information
kfoley-18F committed Nov 16, 2021
2 parents 5139ca3 + 6d7079d commit 72739d9
Show file tree
Hide file tree
Showing 17 changed files with 367 additions and 218 deletions.
218 changes: 24 additions & 194 deletions package-lock.json

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
"chosen-js": "^1.8.7",
"jquery": "^3.6.0",
"sass": "^1.35.1",
"uswds": "^2.10.0"
"uswds": "^2.12.2"
},
"devDependencies": {
"jest": "^27.0.6",
Expand Down
84 changes: 84 additions & 0 deletions tock/api/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,90 @@ def test_get_unsubmitted_timecards(self):
)
self.assertEqual(len(queryset), 2)

"""
Adding data to the timecards.json fixture results in failing tests since many tests
assert on the length of a list returned. You can add tests here by creating mock data
inside of setUp() and not worry about breaking existing tests that rely on the timecard
fixture
"""
class FixturelessTimecardsAPITests(WebTest):
def setUp(self):
super(FixturelessTimecardsAPITests, self).setUp()
self.user = UserFactory()
self.userdata = UserData.objects.create(user=self.user)
self.billable_code = AccountingCodeFactory(billable=True)
self.weekly_billed_project = ProjectFactory(accounting_code=self.billable_code,is_weekly_bill=True)
self.period1 = ReportingPeriodFactory(start_date=datetime.datetime(2021, 9, 1))
self.period2 = ReportingPeriodFactory(start_date=datetime.datetime(2021, 9, 8))
self.period3 = ReportingPeriodFactory(start_date=datetime.datetime(2021, 9, 14))
self.period4 = ReportingPeriodFactory(start_date=datetime.datetime(2021, 9, 21))
self.period5 = ReportingPeriodFactory(start_date=datetime.datetime(2021, 9, 29))
self.full_allocation_timecard = TimecardFactory(user=self.user, reporting_period=self.period1)
self.three_quarter_allocation_timecard = TimecardFactory(user=self.user, reporting_period=self.period2)
self.half_allocation_timecard = TimecardFactory(user=self.user, reporting_period=self.period3)
self.one_quarter_allocation_timecard = TimecardFactory(user=self.user, reporting_period=self.period4)
self.one_eighth_allocation_timecard = TimecardFactory(user=self.user, reporting_period=self.period5)
self.full_allocation_timecard_objects = [
TimecardObjectFactory(
timecard=self.full_allocation_timecard,
project=self.weekly_billed_project,
hours_spent=0,
project_allocation=1.000
)
]
self.three_quarter_allocation_timecard_objects = [
TimecardObjectFactory(
timecard=self.three_quarter_allocation_timecard,
project=self.weekly_billed_project,
hours_spent=0,
project_allocation=0.750
)
]
self.half_allocation_timecard_objects = [
TimecardObjectFactory(
timecard=self.half_allocation_timecard,
project=self.weekly_billed_project,
hours_spent=0,
project_allocation=0.500
)
]
self.one_quarter_allocation_timecard_objects = [
TimecardObjectFactory(
timecard=self.one_quarter_allocation_timecard,
project=self.weekly_billed_project,
hours_spent=0,
project_allocation=0.250
)
]
self.one_eighth_allocation_timecard_objects = [
TimecardObjectFactory(
timecard=self.one_eighth_allocation_timecard,
project=self.weekly_billed_project,
hours_spent=0,
project_allocation=0.125
)
]

def test_project_allocation_scale_precision(self):
"""
project_allocation allows a scale of 6 digits and a precision of 3 digits
Test to make sure that the API, which relies on TimecardSerializer, follows this convention
"""
all_timecards = client().get(
reverse('TimecardList'),
kwargs={'date': '2021-09-01'}).data

full_allocation_timecard = all_timecards[0]
three_quarter_allocation_timecard = all_timecards[1]
half_allocation_timecard = all_timecards[2]
one_quarter_allocation_timecard = all_timecards[3]
one_eighth_allocation_timecard = all_timecards[4]

self.assertEqual(full_allocation_timecard['project_allocation'], "1.000")
self.assertEqual(three_quarter_allocation_timecard['project_allocation'], "0.750")
self.assertEqual(half_allocation_timecard['project_allocation'], "0.500")
self.assertEqual(one_quarter_allocation_timecard['project_allocation'], "0.250")
self.assertEqual(one_eighth_allocation_timecard['project_allocation'], "0.125")

class TestAggregates(WebTest):

Expand Down
2 changes: 1 addition & 1 deletion tock/api/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ class TimecardSerializer(serializers.Serializer):
allow_null=True
)
hours_spent = serializers.DecimalField(max_digits=5, decimal_places=2)
project_allocation = serializers.DecimalField(max_digits=5, decimal_places=2)
project_allocation = serializers.DecimalField(max_digits=6, decimal_places=3)
start_date = serializers.DateField(
source='timecard.reporting_period.start_date'
)
Expand Down
18 changes: 18 additions & 0 deletions tock/employees/migrations/0037_small_allocation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Generated by Django 3.2.6 on 2021-09-29 19:37

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('employees', '0036_alter_userdata_expected_project_allocation'),
]

operations = [
migrations.AlterField(
model_name='userdata',
name='expected_project_allocation',
field=models.DecimalField(blank=True, decimal_places=3, default=1.0, help_text='Enter in Decimal Format (ex. 1.00 = 100%, 0.50 = 50%)', max_digits=6, null=True, verbose_name='Expected Project Allocation'),
),
]
4 changes: 2 additions & 2 deletions tock/employees/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,8 +85,8 @@ class UserData(models.Model):
default=settings.DEFAULT_EXPECTED_BILLABLE_HOURS,
help_text="Number of hours expected to be billable in a 40 hour work week")
expected_project_allocation = models.DecimalField(
decimal_places=2,
max_digits=5,
decimal_places=3,
max_digits=6,
blank=True,
null=True,
default=settings.DEFAULT_EXPECTED_PROJECT_ALLOCATION,
Expand Down
84 changes: 82 additions & 2 deletions tock/hours/admin.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
from decimal import Decimal
from math import isclose

from django.conf import settings
from django.contrib import admin
from django.core.exceptions import ValidationError
from django.forms import DecimalField, ModelForm
from django.forms.widgets import Select
from django.forms.models import BaseInlineFormSet
from employees.models import UserData

Expand All @@ -24,8 +26,79 @@ def queryset(self, request, queryset):
return queryset


def safe_float(value):
"""Convert a string to a float with no exceptions.
Return NaN if the conversion fails.
"""
try:
return float(value)
except ValueError:
return float("NaN")


class DecimalChoiceWidget(Select):

"""A choice widget for decimal typed data.
The Select widget when used with a form's TypedChoiceField that has an
underlying `DecimalField` in the model is very sensitive to formatting because
it uses string comparison (as is natural for a Select widget on a web page that
deals in strings).
This modifies the method in a `Select` widget where equality is checked so that
it uses numerical equality. This should make it much better behaved with the
numeric data type.
"""

def optgroups(self, name, value, attrs=None):
"""Change the baseclass's method to use numerical comparison."""
groups = []
has_selected = False

for index, (option_value, option_label) in enumerate(self.choices):
if option_value is None:
option_value = ''

subgroup = []
if isinstance(option_label, (list, tuple)):
group_name = option_value
subindex = 0
choices = option_label
else:
group_name = None
subindex = None
choices = [(option_value, option_label)]
groups.append((group_name, subgroup, index))

for subvalue, sublabel in choices:
selected = (
# instead of string comparison, use numerical comparison here
any(isclose(safe_float(subvalue), safe_float(v)) for v in value)
and (not has_selected or self.allow_multiple_selected)
)
has_selected |= selected
subgroup.append(self.create_option(
name, subvalue, sublabel, selected, index,
subindex=subindex, attrs=attrs,
))
if subindex is not None:
subindex += 1
return groups


class TimecardObjectForm(ModelForm):

class Meta:
model = TimecardObject
fields = "__all__"
widgets = {"project_allocation": DecimalChoiceWidget}


class TimecardObjectFormset(BaseInlineFormSet):

form = TimecardObjectForm

def clean(self):
"""
Check to ensure the proper number of hours are entered.
Expand All @@ -37,14 +110,20 @@ def clean(self):
return

hours = Decimal(0.0)
weekly_billing_found = False
aws_eligible = UserData.objects.get(
user__id=self.instance.user_id).is_aws_eligible
min_working_hours = self.instance.reporting_period.min_working_hours
max_working_hours = self.instance.reporting_period.max_working_hours

for unit in self.cleaned_data:
try:
hours = hours + unit['hours_spent']
if unit['hours_spent']:
hours = hours + unit['hours_spent']
else:
if (unit['project_allocation'] and unit['project_allocation'] > 0):
weekly_billing_found = True
break
except KeyError:
pass

Expand All @@ -53,7 +132,7 @@ def clean(self):
'You have entered more than %s hours' % max_working_hours
)

if hours < min_working_hours and not aws_eligible:
if hours < min_working_hours and not aws_eligible and not weekly_billing_found:
raise ValidationError(
'You have entered fewer than %s hours' % min_working_hours
)
Expand All @@ -67,6 +146,7 @@ class ReportingPeriodAdmin(admin.ModelAdmin):

class TimecardObjectInline(admin.TabularInline):
formset = TimecardObjectFormset
form = TimecardObjectForm
model = TimecardObject
readonly_fields = [
'grade',
Expand Down
3 changes: 2 additions & 1 deletion tock/hours/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from projects.models import AccountingCode, Project

from .models import ReportingPeriod, Timecard, TimecardObject
from .admin import DecimalChoiceWidget


class ReportingPeriodForm(forms.ModelForm):
Expand Down Expand Up @@ -143,7 +144,7 @@ class TimecardObjectForm(forms.ModelForm):
project_allocation = forms.ChoiceField(
choices=settings.PROJECT_ALLOCATION_CHOICES,
required=False,
widget=forms.Select(attrs={'onchange' : "populateHourTotals();"})
widget=DecimalChoiceWidget(attrs={'onchange' : "populateHourTotals();"})
)
hours_spent = forms.DecimalField(
min_value=0,
Expand Down
18 changes: 18 additions & 0 deletions tock/hours/migrations/0063_small_allocation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Generated by Django 3.2.6 on 2021-09-29 19:37

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('hours', '0062_timecardobject_project_allocation'),
]

operations = [
migrations.AlterField(
model_name='timecardobject',
name='project_allocation',
field=models.DecimalField(choices=[(0, '---'), (1.0, '100%'), (0.5, '50%'), (0.25, '25%'), (0.125, '12.5%')], decimal_places=2, default=0, max_digits=5),
),
]
18 changes: 18 additions & 0 deletions tock/hours/migrations/0064_small_allocation2.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Generated by Django 3.2.6 on 2021-09-30 17:06

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('hours', '0063_small_allocation'),
]

operations = [
migrations.AlterField(
model_name='timecardobject',
name='project_allocation',
field=models.DecimalField(choices=[(0, '---'), (1.0, '100%'), (0.5, '50%'), (0.25, '25%'), (0.125, '12.5%')], decimal_places=3, default=0, max_digits=6),
),
]
4 changes: 2 additions & 2 deletions tock/hours/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -476,8 +476,8 @@ class TimecardObject(models.Model):
created = models.DateTimeField(auto_now_add=True)
modified = models.DateTimeField(auto_now=True)
grade = models.ForeignKey(EmployeeGrade, blank=True, null=True, on_delete=models.CASCADE)
project_allocation = models.DecimalField(choices=settings.PROJECT_ALLOCATION_CHOICES, default=0, decimal_places=2,
max_digits=5)
project_allocation = models.DecimalField(choices=settings.PROJECT_ALLOCATION_CHOICES, default=0, decimal_places=3,
max_digits=6)
# The notes field is where the user records notes about time spent on
# certain projects (for example, time spent on general projects). It may
# only be display and required when certain projects are selected.
Expand Down
7 changes: 7 additions & 0 deletions tock/hours/tests/test_forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -365,3 +365,10 @@ def test_one_project_with_notes_and_one_without_notes_is_valid(self):
form_data['timecardobjects-0-notes'] = 'Did some work.'
formset = TimecardFormSet(form_data, instance=self.timecard)
self.assertTrue(formset.is_valid())

def test_smallest_project_allocation(self):
"""Should be able to make a timecard with 12.5% project allocation"""
form_data = self.form_data()
form_data['timecardobjects-0-project_allocation'] = '0.125'
formset = TimecardFormSet(form_data, instance=self.timecard)
self.assertTrue(formset.is_valid())
Loading

0 comments on commit 72739d9

Please sign in to comment.