Skip to content

Commit

Permalink
Merge pull request #18 from moshthepitt/better-leave-day-calculation
Browse files Browse the repository at this point in the history
Better leave day calculation
  • Loading branch information
moshthepitt authored Nov 15, 2018
2 parents 197ee3e + caba74f commit e2a73d5
Show file tree
Hide file tree
Showing 12 changed files with 510 additions and 194 deletions.
2 changes: 1 addition & 1 deletion Pipfile
Original file line number Diff line number Diff line change
Expand Up @@ -19,5 +19,5 @@ ipdb = "*"
model-mommy = "*"
tblib = "*"
pylint-django = {git = "https://github.com/PyCQA/pylint-django.git", ref = "4316c3d90f4ac6cbbeddfc8d431f5d4e031d5cf1"}
"pep8" = "*"
isort = "*"
"pep8" = "*"
234 changes: 112 additions & 122 deletions Pipfile.lock

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion small_small_hr/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
"""
Main init file for small_small_hr
"""
VERSION = (0, 1, 1)
VERSION = (0, 1, 2)
__version__ = '.'.join(str(v) for v in VERSION)
7 changes: 7 additions & 0 deletions small_small_hr/apps.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,10 @@ class SmallSmallHrConfig(AppConfig):
def ready(self):
# pylint: disable=unused-variable
import small_small_hr.signals # noqa

# set up app settings
from django.conf import settings
import small_small_hr.settings as defaults
for name in dir(defaults):
if name.isupper() and not hasattr(settings, name):
setattr(settings, name, getattr(defaults, name))
2 changes: 1 addition & 1 deletion small_small_hr/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -315,7 +315,7 @@ def clean(self):
self.add_error('end', msg)

# end must be later than start
if end <= start:
if end < start:
self.add_error('end', _("end must be greater than start"))

# staff profile must have sufficient sick days
Expand Down
28 changes: 28 additions & 0 deletions small_small_hr/migrations/0005_auto_20181115_2112.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# Generated by Django 2.1.2 on 2018-11-15 18:12

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('small_small_hr', '0004_auto_20180725_2127'),
]

operations = [
migrations.AlterField(
model_name='staffdocument',
name='public',
field=models.BooleanField(
blank=True,
default=False,
help_text='If public, it will be available to everyone.',
verbose_name='Public'),
),
migrations.AlterField(
model_name='staffprofile',
name='overtime_allowed',
field=models.BooleanField(
blank=True, default=False, verbose_name='Overtime allowed'),
),
]
86 changes: 55 additions & 31 deletions small_small_hr/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,8 @@
from django.conf import settings
from django.contrib.postgres.fields import JSONField
from django.db import models
from django.db.models import Sum
from django.db.models import Value as V
from django.db.models.functions import Coalesce
from django.db.models import Q
from django.utils import timezone
from django.utils.translation import ugettext as _

from phonenumber_field.modelfields import PhoneNumberField
Expand Down Expand Up @@ -134,30 +133,25 @@ def get_approved_leave_days(self, year: int = datetime.today().year):
Get approved leave days in the current year
"""
# pylint: disable=no-member
queryset = self.leave_set.filter(
return get_taken_leave_days(
staffprofile=self,
status=Leave.APPROVED,
leave_type=Leave.REGULAR,
start__year=year,
end__year=year).annotate(
duration=models.F('end')-models.F('start'))
return queryset.aggregate(
leave=Coalesce(Sum('duration'),
V(timedelta(days=0))))['leave']
start_year=year,
end_year=year
)

def get_approved_sick_days(self, year: int = datetime.today().year):
"""
Get approved leave days in the current year
"""
# pylint: disable=no-member
queryset = self.leave_set.filter(
return get_taken_leave_days(
staffprofile=self,
status=Leave.APPROVED,
leave_type=Leave.SICK,
start__year=year,
end__year=year).annotate(
duration=models.F('end')-models.F('start'))
return queryset.aggregate(
leave=Coalesce(Sum('duration'),
V(timedelta(days=0))))['leave']
start_year=year,
end_year=year
)

def get_available_leave_days(self, year: int = datetime.today().year):
"""
Expand Down Expand Up @@ -372,20 +366,13 @@ def get_cumulative_leave_taken(self):
Returns a timedelta
"""
# we add one day to make end and start inclusive
leave_queryset = Leave.objects.filter(
staff=self.staff,
return get_taken_leave_days(
staffprofile=self.staff,
status=Leave.APPROVED,
leave_type=self.leave_type,
start__year=self.year,
end__year=self.year).annotate(
duration=models.ExpressionWrapper(
models.F('end') - models.F('start') + timedelta(days=1),
output_field=models.DurationField()))

return leave_queryset.aggregate(
leave=Coalesce(Sum('duration'),
V(timedelta(days=0))))['leave']
start_year=self.year,
end_year=self.year
)

def get_available_leave_days(self, month: int = 12):
"""
Expand All @@ -406,9 +393,46 @@ def get_available_leave_days(self, month: int = 12):
earned = Decimal(month) * per_month

# the days taken
taken = self.get_cumulative_leave_taken().days
taken = self.get_cumulative_leave_taken()

# the starting balance
starting_balance = self.carried_over_days

return Decimal(earned + starting_balance - taken)


def get_days(start: object, end: object):
"""
Yield the days between two datetime objects
"""
current_tz = timezone.get_current_timezone()
local_start = current_tz.normalize(start)
local_end = current_tz.normalize(end)
span = local_end.date() - local_start.date()
for i in range(span.days + 1):
yield local_start.date() + timedelta(days=i)


def get_taken_leave_days(
staffprofile: object,
status: str,
leave_type: str,
start_year: int,
end_year: int):
"""
Calculate the number of leave days actually taken,
taking into account weekends and weekend policy
"""
count = Decimal(0)
queryset = Leave.objects.filter(
staff=staffprofile,
status=status,
leave_type=leave_type).filter(
Q(start__year__gte=start_year) | Q(end__year__lte=end_year))
for leave_obj in queryset:
days = get_days(start=leave_obj.start, end=leave_obj.end)
for day in days:
if day.year >= start_year and day.year <= end_year:
day_value = settings.SSHR_DAY_LEAVE_VALUES[day.isoweekday()]
count = count + Decimal(day_value)
return count
13 changes: 13 additions & 0 deletions small_small_hr/settings.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
"""
Configurable options
"""
SSHR_MAX_CARRY_OVER = 10
SSHR_DAY_LEAVE_VALUES = {
1: 1, # Monday
2: 1, # Tuesday
3: 1, # Wednesday
4: 1, # Thursday
5: 1, # Friday
6: 0, # Saturday
7: 0, # Sunday
}
6 changes: 2 additions & 4 deletions small_small_hr/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,6 @@

from small_small_hr.models import AnnualLeave, Leave

MAX_CARRY_OVER = getattr(settings, 'SSHR_MAX_CARRY_OVER', 10)


def get_carry_over(staffprofile: object, year: int, leave_type: str):
"""
Expand All @@ -15,10 +13,10 @@ def get_carry_over(staffprofile: object, year: int, leave_type: str):
# pylint: disable=no-member
if leave_type == Leave.REGULAR:
previous_obj = AnnualLeave.objects.filter(
staff=staffprofile, year=year-1, leave_type=leave_type).first()
staff=staffprofile, year=year - 1, leave_type=leave_type).first()
if previous_obj:
remaining = previous_obj.get_available_leave_days()
max_carry_over = MAX_CARRY_OVER
max_carry_over = settings.SSHR_MAX_CARRY_OVER
if remaining > max_carry_over:
carry_over = max_carry_over
else:
Expand Down
47 changes: 47 additions & 0 deletions tests/test_forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,53 @@ def test_leaveform_apply(self, mock):
self.assertEqual('', leave.comments)
mock.assert_called_with(leave_obj=leave)

@override_settings(SSHR_DEFAULT_TIME=7)
@patch('small_small_hr.forms.leave_application_email')
def test_one_day_leave(self, mock):
"""
Test application for one day leave
"""
user = mommy.make('auth.User', first_name='Bob', last_name='Ndoe')
staffprofile = mommy.make('small_small_hr.StaffProfile', user=user)
staffprofile.leave_days = 21
staffprofile.sick_days = 10
staffprofile.save()

request = self.factory.get('/')
request.session = {}
request.user = AnonymousUser()

# 6 days of leave
start = datetime(
2017, 6, 5, 7, 0, 0, tzinfo=pytz.timezone(settings.TIME_ZONE))
end = datetime(
2017, 6, 5, 7, 0, 0, tzinfo=pytz.timezone(settings.TIME_ZONE))

mommy.make('small_small_hr.AnnualLeave', staff=staffprofile, year=2017,
leave_type=Leave.REGULAR, carried_over_days=12)

data = {
'staff': staffprofile.id,
'leave_type': Leave.REGULAR,
'start': start,
'end': end,
'reason': 'Need a break',
}

form = ApplyLeaveForm(data=data)
self.assertTrue(form.is_valid())
leave = form.save()
self.assertEqual(staffprofile, leave.staff)
self.assertEqual(Leave.REGULAR, leave.leave_type)
self.assertEqual(start, leave.start)
self.assertEqual(end, leave.end)
self.assertEqual(
timedelta(days=0).days, (leave.end - leave.start).days)
self.assertEqual('Need a break', leave.reason)
self.assertEqual(Leave.PENDING, leave.status)
self.assertEqual('', leave.comments)
mock.assert_called_with(leave_obj=leave)

@override_settings(SSHR_DEFAULT_TIME=7)
def test_leaveform_no_overlap(self):
"""
Expand Down
Loading

0 comments on commit e2a73d5

Please sign in to comment.