From fb554296a61147cc63a500213172402fe23d6cdf Mon Sep 17 00:00:00 2001 From: Michael Gecht Date: Tue, 8 May 2018 10:15:25 +0200 Subject: [PATCH 01/17] Update .gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index f242594..b83da07 100755 --- a/.gitignore +++ b/.gitignore @@ -37,6 +37,7 @@ webpack-stats-prod.json # Cache .cache/ +.pytest_cache/ # OSX .DS_Store From d66222caf61a84a5b7ebf865cc2637865832486e Mon Sep 17 00:00:00 2001 From: Michael Gecht Date: Tue, 8 May 2018 10:21:19 +0200 Subject: [PATCH 02/17] Add DRF --- Pipfile | 1 + Pipfile.lock | 72 ++++++++++++++++++++++++++++------------------------ 2 files changed, 40 insertions(+), 33 deletions(-) diff --git a/Pipfile b/Pipfile index 853e9fc..9d56ab7 100644 --- a/Pipfile +++ b/Pipfile @@ -25,6 +25,7 @@ django-taggit = "*" python-dateutil = "*" "psycopg2" = "*" "psycopg2-binary" = "*" +djangorestframework = "*" [dev-packages] coverage = "*" diff --git a/Pipfile.lock b/Pipfile.lock index 9665001..09e1d9d 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "8be0d2bb76c1723a1cc2bb21044f188642616e0255d69a4a89256c6ec357c244" + "sha256": "167e74df13423665b6cec80c84d2156978fa696ddc27e7a7d24c075ffb33d245" }, "pipfile-spec": 6, "requires": {}, @@ -37,18 +37,18 @@ }, "django": { "hashes": [ - "sha256:2d8b9eed8815f172a8e898678ae4289a5e9176bc08295676eff4228dd638ea61", - "sha256:d81a1652963c81488e709729a80b510394050e312f386037f26b54912a3a10d0" + "sha256:26b34f4417aa38d895b6b5307177b51bc3f4d53179d8696a5c19dcb50582523c", + "sha256:71d1a584bb4ad2b4f933d07d02c716755c1394feaac1ce61ce37843ac5401092" ], "index": "pypi", - "version": "==2.0.4" + "version": "==2.0.5" }, "django-allauth": { "hashes": [ - "sha256:7b31526cccd1c46f9f09acf0703068e8a9669337d29eb065f7e8143c2d897339" + "sha256:7d9646e3560279d6294ebb4c361fef829708d106da697658cf158bf2ca57b474" ], "index": "pypi", - "version": "==0.35.0" + "version": "==0.36.0" }, "django-anymail": { "hashes": [ @@ -69,10 +69,10 @@ }, "django-bootstrap3": { "hashes": [ - "sha256:9f56fa30a2c32142a2785148816cc4e14e7efc8699c5f87a8cb5be162a81658d" + "sha256:1a5cc3583a208f423b8a081d3fb432957a3e82416512cbafc4a3b10f27cc884a" ], "index": "pypi", - "version": "==9.1.0" + "version": "==10.0.1" }, "django-braces": { "hashes": [ @@ -122,6 +122,14 @@ "index": "pypi", "version": "==0.6.0" }, + "djangorestframework": { + "hashes": [ + "sha256:b6714c3e4b0f8d524f193c91ecf5f5450092c2145439ac2769711f7eba89a9d9", + "sha256:c375e4f95a3a64fccac412e36fb42ba36881e52313ec021ef410b40f67cddca4" + ], + "index": "pypi", + "version": "==3.8.2" + }, "gevent": { "hashes": [ "sha256:0901975628790e8a57fc92bb7062e5b856edea48c8de9caf36cfda14eae07329", @@ -181,11 +189,11 @@ }, "gunicorn": { "hashes": [ - "sha256:eb8d8924b117a609fae9f8cd85df0cad3535dd613fdbcdbba3ee88d5459f1d4f", - "sha256:f5ca088d029fe3cea166c59bb43b7ccc9c850fe25af3da61350fe712c5cc5aa2" + "sha256:7ef2b828b335ed58e3b64ffa84caceb0a7dd7c5ca12f217241350dec36a1d5dc", + "sha256:bc59005979efb6d2dd7d5ba72d99f8a8422862ad17ff3a16e900684630dd2a10" ], "index": "pypi", - "version": "==19.8.0" + "version": "==19.8.1" }, "idna": { "hashes": [ @@ -463,10 +471,10 @@ }, "attrs": { "hashes": [ - "sha256:1c7960ccfd6a005cd9f7ba884e6316b5e430a3f1a6c37c5f87d8b43f83b54ec9", - "sha256:a17a9573a6f475c99b551c0e0a812707ddda1ec9653bed04c13841404ed6f450" + "sha256:4b90b09eeeb9b88c35bc642cbac057e45a5fd85367b985bd2809c62b7b939265", + "sha256:e0d0eb91441a3b53dab4d9b743eafc1ac44476296a2053b6ca3af0b139faf87b" ], - "version": "==17.4.0" + "version": "==18.1.0" }, "backcall": { "hashes": [ @@ -582,11 +590,11 @@ }, "django": { "hashes": [ - "sha256:2d8b9eed8815f172a8e898678ae4289a5e9176bc08295676eff4228dd638ea61", - "sha256:d81a1652963c81488e709729a80b510394050e312f386037f26b54912a3a10d0" + "sha256:26b34f4417aa38d895b6b5307177b51bc3f4d53179d8696a5c19dcb50582523c", + "sha256:71d1a584bb4ad2b4f933d07d02c716755c1394feaac1ce61ce37843ac5401092" ], "index": "pypi", - "version": "==2.0.4" + "version": "==2.0.5" }, "django-coverage-plugin": { "hashes": [ @@ -643,11 +651,11 @@ }, "factory-boy": { "hashes": [ - "sha256:bd5a096d0f102d79b6c78cef1c8c0b650f2e1a3ecba351c735c6d2df8dabd29c", - "sha256:be2abc8092294e4097935a29b4e37f5b9ed3e4205e2e32df215c0315b625995e" + "sha256:6f25cc4761ac109efd503f096e2ad99421b1159f01a29dbb917359dcd68e08ca", + "sha256:d552cb872b310ae78bd7429bf318e42e1e903b1a109e899a523293dfa762ea4f" ], "index": "pypi", - "version": "==2.10.0" + "version": "==2.11.1" }, "faker": { "hashes": [ @@ -665,10 +673,10 @@ }, "flask": { "hashes": [ - "sha256:7fab1062d11dd0038434e790d18c5b9133fd9e6b7257d707c4578ccc1e38b67c", - "sha256:b1883637bbee4dc7bc98d900792d0a304d609fce0f5bd9ca91d1b6457e5918dd" + "sha256:2271c0070dbcb5275fad4a82e29f23ab92682dc45f9dfbc22c02ba9b9322ce48", + "sha256:a080b744b7e345ccfcbc77954861cb05b3c63786e93f2b3875e0913d44b43f05" ], - "version": "==1.0" + "version": "==1.0.2" }, "flask-assets": { "hashes": [ @@ -877,10 +885,10 @@ }, "maildump": { "hashes": [ - "sha256:7b994bf28c7179b60f005978214faa1046d9489a983a70711f64f873c5b9db08" + "sha256:1b546fa056046f909eb634ee7c84592b31f39dc00f9df29fbb14b52af915fc43" ], "index": "pypi", - "version": "==0.5.4" + "version": "==0.5.6" }, "markupsafe": { "hashes": [ @@ -977,8 +985,6 @@ }, "pycodestyle": { "hashes": [ - "sha256:1ec08a51c901dfe44921576ed6e4c1f5b7ecbad403f871397feedb5eb8e4fa14", - "sha256:5ff2fbcbab997895ba9ead77e1b38b3ebc2e5c3b8a6194ef918666e4c790a00e", "sha256:682256a5b318149ca0d2a9185d365d8864a768a28db66a84a2ea946bcc426766", "sha256:6c4245ade1edfad79c3446fadfc96b0de2759662dc29d07d80a6f27ad1ca6ba9" ], @@ -1057,19 +1063,19 @@ }, "pytest-flake8": { "hashes": [ - "sha256:3996de65a1219697596acac755090c70c47ec901edc438ea88ce1aa6098fb905", - "sha256:43598dfa211242525897866ff35fa553ebd88177383783079099057c7ad04331" + "sha256:e5cdc4f459c9436ac6c649e428a014bb5988605858549397374ec29a776cae68", + "sha256:ec248d4a215d6c7cd9d3ca48f365ece0e3892b46d626c22a95ccc80188ff35ed" ], "index": "pypi", - "version": "==1.0.0" + "version": "==1.0.1" }, "pytest-isort": { "hashes": [ - "sha256:e92798127e21d22513c62070989f0fb3b712650e48a4db13e5b8e8034d367cfe", - "sha256:fc315e3ceac576f2b7000c4716a55063c45ed60c169ba2ede5a6b4aef97ce13e" + "sha256:ce36b62ca1108e16a8b8fc0d5a82302ba0796a3c352b093e13ccbb18e772edc6", + "sha256:d4d195ecfe33d81e258d251b2679b32216bad84131fb41984da22d9d0328a6fe" ], "index": "pypi", - "version": "==0.1.0" + "version": "==0.2.0" }, "pytest-sugar": { "hashes": [ From 5752f4b533289f088cbad2846ba20d0c0c51cba8 Mon Sep 17 00:00:00 2001 From: Michael Gecht Date: Thu, 10 May 2018 11:38:29 +0200 Subject: [PATCH 03/17] Add first simple API views --- clock/contracts/api/__init__.py | 0 clock/contracts/api/serializers.py | 9 +++++++++ clock/contracts/api/urls.py | 11 +++++++++++ clock/contracts/api/views.py | 15 +++++++++++++++ clock/shifts/api/__init__.py | 0 clock/shifts/api/serializers.py | 9 +++++++++ clock/shifts/api/urls.py | 9 +++++++++ clock/shifts/api/views.py | 9 +++++++++ config/api_urls.py | 6 ++++++ config/settings/common.py | 1 + config/urls.py | 23 +++++++++++++++-------- 11 files changed, 84 insertions(+), 8 deletions(-) create mode 100644 clock/contracts/api/__init__.py create mode 100644 clock/contracts/api/serializers.py create mode 100644 clock/contracts/api/urls.py create mode 100644 clock/contracts/api/views.py create mode 100644 clock/shifts/api/__init__.py create mode 100644 clock/shifts/api/serializers.py create mode 100644 clock/shifts/api/urls.py create mode 100644 clock/shifts/api/views.py create mode 100644 config/api_urls.py diff --git a/clock/contracts/api/__init__.py b/clock/contracts/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/clock/contracts/api/serializers.py b/clock/contracts/api/serializers.py new file mode 100644 index 0000000..b93099c --- /dev/null +++ b/clock/contracts/api/serializers.py @@ -0,0 +1,9 @@ +from rest_framework import serializers + +from clock.contracts.models import Contract + + +class ContractEndDateSerializer(serializers.ModelSerializer): + class Meta: + model = Contract + fields = ('end_date', ) diff --git a/clock/contracts/api/urls.py b/clock/contracts/api/urls.py new file mode 100644 index 0000000..ad6fec6 --- /dev/null +++ b/clock/contracts/api/urls.py @@ -0,0 +1,11 @@ +from django.urls import include, path +from rest_framework import routers + +from clock.contracts.api import views + +router = routers.DefaultRouter() +router.register( + 'contracts', views.ContractEndDateViewSet, base_name='end_date' +) + +urlpatterns = [path('', include(router.urls))] diff --git a/clock/contracts/api/views.py b/clock/contracts/api/views.py new file mode 100644 index 0000000..8fd6fd4 --- /dev/null +++ b/clock/contracts/api/views.py @@ -0,0 +1,15 @@ +from django.shortcuts import get_object_or_404 +from rest_framework import permissions, viewsets +from rest_framework.response import Response + +from clock.contracts.api.serializers import ContractEndDateSerializer +from clock.contracts.models import Contract + + +class ContractEndDateViewSet(viewsets.ViewSet): + permission_classes = (permissions.IsAuthenticated, ) + + def retrieve(self, request, pk=None): + contract = get_object_or_404(Contract, pk=pk, employee=request.user) + serializer = ContractEndDateSerializer(contract) + return Response(serializer.data) diff --git a/clock/shifts/api/__init__.py b/clock/shifts/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/clock/shifts/api/serializers.py b/clock/shifts/api/serializers.py new file mode 100644 index 0000000..6380ee0 --- /dev/null +++ b/clock/shifts/api/serializers.py @@ -0,0 +1,9 @@ +from rest_framework import serializers + +from clock.shifts.models import Shift + + +class ShiftSerializer(serializers.ModelSerializer): + class Meta: + model = Shift + fields = ('started', 'finished', 'employee', 'contract') diff --git a/clock/shifts/api/urls.py b/clock/shifts/api/urls.py new file mode 100644 index 0000000..9a1a171 --- /dev/null +++ b/clock/shifts/api/urls.py @@ -0,0 +1,9 @@ +from django.urls import include, path +from rest_framework import routers + +from clock.shifts.api import views + +router = routers.DefaultRouter() +router.register('shifts', views.ShiftViewSet) + +urlpatterns = [path('', include(router.urls))] diff --git a/clock/shifts/api/views.py b/clock/shifts/api/views.py new file mode 100644 index 0000000..5bc0ff8 --- /dev/null +++ b/clock/shifts/api/views.py @@ -0,0 +1,9 @@ +from rest_framework import viewsets + +from clock.shifts.api.serializers import ShiftSerializer +from clock.shifts.models import Shift + + +class ShiftViewSet(viewsets.ModelViewSet): + queryset = Shift.objects.all().order_by('-started') + serializer_class = ShiftSerializer diff --git a/config/api_urls.py b/config/api_urls.py new file mode 100644 index 0000000..983ecf0 --- /dev/null +++ b/config/api_urls.py @@ -0,0 +1,6 @@ +from django.urls import include, path + +urlpatterns = [ + path('shifts/', include('clock.shifts.api.urls')), + path('contracts/', include('clock.contracts.api.urls')) +] diff --git a/config/settings/common.py b/config/settings/common.py index ac0549f..8beefce 100755 --- a/config/settings/common.py +++ b/config/settings/common.py @@ -46,6 +46,7 @@ # 'bootstrap3_datetime', 'captcha', 'taggit', + 'rest_framework' ) # Apps specific for this project go here. diff --git a/config/urls.py b/config/urls.py index 78f47be..e857508 100755 --- a/config/urls.py +++ b/config/urls.py @@ -1,19 +1,20 @@ # -*- coding: utf-8 -*- - # from config.favicon_urls import favicon_urlpatters from django.conf import settings -from django.conf.urls import include, url +from django.conf.urls import url from django.conf.urls.static import static from django.contrib import admin from django.contrib.flatpages import views from django.http import HttpResponse +from django.urls import include, path from django.views import defaults as default_views import clock.profiles.views urlpatterns = [ url(r'^', include("clock.pages.urls"), name='pages'), + path('api/', include('config.api_urls'), name='api'), url(r'^about/$', views.flatpage, {'url': '/about/'}, name='about'), url(r'^impressum/$', views.flatpage, {'url': '/impressum/'}, name='imprint'), @@ -48,15 +49,21 @@ # This allows the error pages to be debugged during development, just visit # these url in browser to see how these error pages look like. urlpatterns += [ - url(r'^400/$', + url( + r'^400/$', default_views.bad_request, - kwargs={'exception': Exception("Bad Request!")}), - url(r'^403/$', + kwargs={'exception': Exception("Bad Request!")} + ), + url( + r'^403/$', default_views.permission_denied, - kwargs={'exception': Exception("Permission Denied")}), - url(r'^404/$', + kwargs={'exception': Exception("Permission Denied")} + ), + url( + r'^404/$', default_views.page_not_found, - kwargs={'exception': Exception("Page not Found")}), + kwargs={'exception': Exception("Page not Found")} + ), url(r'^500/$', default_views.server_error), ] if 'debug_toolbar' in settings.INSTALLED_APPS: From 762a083001282025e0dd3a569ab616043df134b9 Mon Sep 17 00:00:00 2001 From: Michael Gecht Date: Thu, 10 May 2018 11:39:20 +0200 Subject: [PATCH 04/17] Add first simple API2 --- clock/contracts/api/urls.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/clock/contracts/api/urls.py b/clock/contracts/api/urls.py index ad6fec6..219b283 100644 --- a/clock/contracts/api/urls.py +++ b/clock/contracts/api/urls.py @@ -4,8 +4,6 @@ from clock.contracts.api import views router = routers.DefaultRouter() -router.register( - 'contracts', views.ContractEndDateViewSet, base_name='end_date' -) +router.register('end_date', views.ContractEndDateViewSet, base_name='end_date') urlpatterns = [path('', include(router.urls))] From c40239f1dcbe1bc9551c230a7b88f470e07160e5 Mon Sep 17 00:00:00 2001 From: Michael Gecht Date: Thu, 10 May 2018 11:42:42 +0200 Subject: [PATCH 05/17] Add first simple3 --- config/api_urls.py | 1 + 1 file changed, 1 insertion(+) diff --git a/config/api_urls.py b/config/api_urls.py index 983ecf0..8f91844 100644 --- a/config/api_urls.py +++ b/config/api_urls.py @@ -1,5 +1,6 @@ from django.urls import include, path +app_name = 'api' urlpatterns = [ path('shifts/', include('clock.shifts.api.urls')), path('contracts/', include('clock.contracts.api.urls')) From 431e79ae8664b8f99ef90f1a7b0cc251e2d9456c Mon Sep 17 00:00:00 2001 From: Michael Gecht Date: Thu, 10 May 2018 12:03:06 +0200 Subject: [PATCH 06/17] Update template url --- clock/templates/shift/edit.html | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/clock/templates/shift/edit.html b/clock/templates/shift/edit.html index 84aedbd..626987c 100755 --- a/clock/templates/shift/edit.html +++ b/clock/templates/shift/edit.html @@ -45,7 +45,6 @@

{% trans 'New shift' %}

} } }); - const url = `{% url 'shift:get_contract_id' %}`; const contract_select = $("#id_contract"); const end_date_div = $("#div_id_end_date"); const end_date_input = $("#id_end_date"); @@ -65,8 +64,10 @@

{% trans 'New shift' %}

} const grabContractEndDate = function (contractID) { if (reoccuring.val() !== 'ONCE' && contract_select.val() !== '' && user_id.val() !== '') { + let url = `{% url 'api:end_date-detail' '0' %}`; + url = url.substring(0, url.length - 2) + contractID + '/'; $.ajax({ - type: 'POST', + type: 'GET', url: url, data: { 'contract': contractID, From 4250adc58f28f46f01f0a8dc06d546f41c6f04d4 Mon Sep 17 00:00:00 2001 From: Michael Gecht Date: Sun, 13 May 2018 16:57:44 +0200 Subject: [PATCH 07/17] Add modal to show shifts to be created --- clock/shifts/api/urls.py | 21 ++++- clock/shifts/api/views.py | 69 +++++++++++++- clock/shifts/forms.py | 8 +- clock/shifts/tests/test_utils.py | 155 ++++++++++++++++++++++++++++++- clock/shifts/urls.py | 1 - clock/shifts/utils.py | 81 +++++++++++++++- clock/templates/shift/edit.html | 146 ++++++++++++++++++++++++++++- 7 files changed, 463 insertions(+), 18 deletions(-) diff --git a/clock/shifts/api/urls.py b/clock/shifts/api/urls.py index 9a1a171..54b84b1 100644 --- a/clock/shifts/api/urls.py +++ b/clock/shifts/api/urls.py @@ -1,9 +1,20 @@ -from django.urls import include, path -from rest_framework import routers +# from django.urls import include, path +# from rest_framework import routers +from django.urls import path +from rest_framework.urlpatterns import format_suffix_patterns from clock.shifts.api import views -router = routers.DefaultRouter() -router.register('shifts', views.ShiftViewSet) +# router = routers.DefaultRouter() +# router.register('shifts', views.ShiftOverlapView, base_name='test') -urlpatterns = [path('', include(router.urls))] +urlpatterns = [ + path('', views.api_root), + path( + 'overlap//////', + views.ShiftOverlapView.as_view(), + name='overlap-list' + ) +] + +urlpatterns = format_suffix_patterns(urlpatterns) diff --git a/clock/shifts/api/views.py b/clock/shifts/api/views.py index 5bc0ff8..76d23f3 100644 --- a/clock/shifts/api/views.py +++ b/clock/shifts/api/views.py @@ -1,9 +1,70 @@ -from rest_framework import viewsets +from dateutil import parser +from django.http import Http404 +from rest_framework import permissions, viewsets +from rest_framework.decorators import action, api_view +from rest_framework.response import Response +from rest_framework.reverse import reverse +from rest_framework.views import APIView +from clock.contracts.models import Contract from clock.shifts.api.serializers import ShiftSerializer from clock.shifts.models import Shift +from clock.shifts.utils import ( + FREQUENCIES, + get_shifts_to_check_for_overlaps, + sort_overlapping_shifts, +) -class ShiftViewSet(viewsets.ModelViewSet): - queryset = Shift.objects.all().order_by('-started') - serializer_class = ShiftSerializer +@api_view(['GET']) +def api_root(request, format=None): + return Response( + { + 'overlap': + reverse('api:overlap-list', request=request, format=format) + } + ) + + +class ShiftOverlapView(APIView): + permission_classes = (permissions.IsAuthenticated, ) + + def get_object(self, started, finished, contract, reoccuring, pk=None): + if started >= finished: + raise Http404 + + shifts = get_shifts_to_check_for_overlaps( + started, + finished, + self.request.user, + contract.pk, + reoccuring=reoccuring, + exclude_shift=pk + ) + if not shifts: + raise Http404 + + return shifts + + def get(self, request, started, finished, contract, reoccuring, pk): + if reoccuring not in FREQUENCIES: + raise Http404 + + started = parser.parse(started, fuzzy=True) + finished = parser.parse(finished, fuzzy=True) + contract = Contract.objects.get(pk=contract) + + shifts = self.get_object( + started.date(), finished.date(), contract, reoccuring, pk + ) + + good_shifts, bad_shifts = sort_overlapping_shifts( + started, finished, self.request.user, contract, shifts + ) + + serialized_data = { + 'without_overlap': ShiftSerializer(good_shifts, many=True).data, + 'with_overlap': ShiftSerializer(bad_shifts, many=True).data, + } + + return Response(serialized_data) diff --git a/clock/shifts/forms.py b/clock/shifts/forms.py index 0d8f2be..8c0a953 100755 --- a/clock/shifts/forms.py +++ b/clock/shifts/forms.py @@ -341,7 +341,7 @@ def clean(self): reoccuring = self.cleaned_data.get('reoccuring') if reoccuring != 'ONCE' and self.cleaned_data.get( 'contract' - ).end_date and ( + ) and self.cleaned_data.get('contract').end_date and ( self.cleaned_data.get('end_date') > self.cleaned_data.get('contract').end_date ): @@ -366,10 +366,14 @@ def save(self, commit=True): if reoccuring != 'ONCE': # Populate a dictionary with all values that we need to create new # Shifts. + contract = self.cleaned_data.get('contract', None) + if contract is not None: + contract = contract.pk + data = {} for field in ['key', 'note', 'tags', 'end_date']: data[field] = self.cleaned_data.get(field) - data['contract'] = self.cleaned_data.get('contract').pk + data['contract'] = contract data['duration'] = self.instance.duration data['reoccuring'] = 'ONCE' started = self.cleaned_data.get('started') diff --git a/clock/shifts/tests/test_utils.py b/clock/shifts/tests/test_utils.py index f6e7f00..b6d96b1 100644 --- a/clock/shifts/tests/test_utils.py +++ b/clock/shifts/tests/test_utils.py @@ -1,10 +1,163 @@ """Tests for the shift utilities.""" +import pytest +import pytz +from django.contrib.auth import get_user_model +from django.utils import timezone from test_plus import TestCase from clock.contracts.models import Contract from clock.shifts.factories import ShiftFactory, UserFactory +from clock.shifts.forms import ShiftForm from clock.shifts.models import Shift -from clock.shifts.utils import get_current_shift, get_last_shifts +from clock.shifts.utils import ( + get_current_shift, + get_last_shifts, + get_shifts_to_check_for_overlaps, + sort_overlapping_shifts, +) + + +@pytest.fixture +@pytest.mark.django_db +def setup_shifts(): + user = UserFactory() + contract = Contract.objects.create( + employee=user, department='Test department', hours='50' + ) + + started = timezone.datetime(2018, 4, 1, 8, 30) + finished = timezone.datetime(2018, 4, 1, 17, 0) + end_date = timezone.datetime(2018, 6, 30) + form = ShiftForm( + data={ + 'started': started, + 'finished': finished, + 'reoccuring': 'DAILY', + 'end_date': end_date, + 'employee': user, + 'contract': contract.pk + }, + **{ + 'user': user, + 'view': None + } + ) + form.save() + + +@pytest.fixture +def get_user(): + user = get_user_model() + return user.objects.all()[0] + + +@pytest.fixture +def get_contract(): + return Contract.objects.all()[0] + + +@pytest.mark.django_db +def test_sort_overlapping_shifts(setup_shifts, get_user, get_contract): + """Test that we sort the shifts into the overlapping ones.""" + started = timezone.make_aware( + timezone.datetime(2018, 4, 1, 7), timezone=pytz.utc + ) + finished = timezone.make_aware( + timezone.datetime(2018, 4, 1, 10), timezone=pytz.utc + ) + shifts = Shift.objects.filter( + employee=get_user, + contract=get_contract, + started__lte=finished, + finished__gte=started + ).order_by('-created_at') + + good_shifts, bad_shifts = sort_overlapping_shifts( + started, finished, get_user, get_contract, shifts + ) + assert len(bad_shifts) == 1 + assert not good_shifts + + +@pytest.mark.django_db +def test_sort_overlapping_shifts_without_overlaps( + setup_shifts, get_user, get_contract +): + """Test that we correctly sort shifts into the non-overlapping ones.""" + started = timezone.make_aware(timezone.datetime(2018, 4, 1, 2)) + finished = timezone.make_aware(timezone.datetime(2018, 4, 1, 4)) + shifts = Shift.objects.filter( + employee=get_user, + contract=get_contract, + started__lte=timezone.make_aware(timezone.datetime(2018, 4, 30)), + finished__gte=timezone.make_aware(timezone.datetime(2018, 4, 1)) + ).order_by('-created_at') + + good_shifts, bad_shifts = sort_overlapping_shifts( + started, finished, get_user, get_contract, shifts + ) + assert not bad_shifts + assert len(good_shifts) == 29 + + for shift in good_shifts: + assert shift.started.time() == started.astimezone(pytz.utc).time() + assert shift.finished.time() == finished.astimezone(pytz.utc).time() + + +@pytest.mark.django_db +@pytest.mark.parametrize( + 'start_date, end_date, count', [ + ( + timezone.datetime(2018, 4, 5, 6, 0), + timezone.datetime(2018, 4, 5, 7, 55), 1 + ), (timezone.datetime(2018, 4, 5), timezone.datetime(2018, 4, 6), 2), + (timezone.datetime(2018, 4, 5), timezone.datetime(2018, 4, 9), 5) + ] +) +def test_retrieval_of_shifts_to_check_for_overlaps( + setup_shifts, get_user, get_contract, start_date, end_date, count +): + """Test that we retrieve the correct shifts to check for overlaps.""" + started = timezone.make_aware(start_date) + finished = timezone.make_aware(end_date) + reoccuring = 'DAILY' + shifts = get_shifts_to_check_for_overlaps( + started, finished, get_user, get_contract.pk, reoccuring + ) + assert shifts.count() == count + + +@pytest.mark.django_db +def test_overlap_excludes_shift(setup_shifts, get_user, get_contract): + """Test that we can exclude a shift from the overlap check.""" + started = timezone.datetime(2018, 4, 2, 6, 0) + finished = timezone.datetime(2018, 4, 2, 8, 0) + form = ShiftForm( + data={ + 'started': started, + 'finished': finished, + 'reoccuring': 'ONCE', + 'employee': get_user, + 'contract': get_contract.pk + }, + **{ + 'user': get_user, + 'view': None + } + ) + form.save() + start_date = timezone.make_aware(timezone.datetime(2018, 4, 2)) + end_date = timezone.make_aware(timezone.datetime(2018, 4, 3)) + reoccuring = 'DAILY' + shifts = get_shifts_to_check_for_overlaps( + start_date, + end_date, + get_user, + get_contract.pk, + reoccuring, + exclude_shift=form.instance.pk + ) + assert shifts.count() == 2 class TestUtils(TestCase): diff --git a/clock/shifts/urls.py b/clock/shifts/urls.py index a5e9ce8..953abda 100755 --- a/clock/shifts/urls.py +++ b/clock/shifts/urls.py @@ -7,7 +7,6 @@ ShiftManualEdit, ShiftMonthContractView, ShiftYearView, - get_contract_end_date, shift_action, ) diff --git a/clock/shifts/utils.py b/clock/shifts/utils.py index d0c0519..3c50f1a 100755 --- a/clock/shifts/utils.py +++ b/clock/shifts/utils.py @@ -1,10 +1,84 @@ # -*- coding: utf-8 -*- from datetime import datetime +import pytz +from dateutil.rrule import DAILY, MONTHLY, WEEKLY, rrule from django.urls import reverse_lazy +from django.utils import timezone +# from clock.shifts.forms import FREQUENCIES from clock.shifts.models import Shift +FREQUENCIES = {'DAILY': DAILY, 'WEEKLY': WEEKLY, 'MONTHLY': MONTHLY} + + +def get_shifts_to_check_for_overlaps( + start_date, end_date, user, contract, reoccuring, exclude_shift=None +): + # Grab all dates that are between `start_date` and `end_date` + dates = list( + rrule( + freq=FREQUENCIES[reoccuring], dtstart=start_date, until=end_date + ) + ) + first_reoccuring_date = dates[0] + last_reoccuring_date = dates[-1] + + # Convert all dates into datetimes + min_time = datetime.min.time() + max_time = datetime.max.time() + first_reoccuring_date = timezone.make_aware( + datetime.combine(first_reoccuring_date, min_time) + ) + last_reoccuring_date = timezone.make_aware( + datetime.combine(last_reoccuring_date, max_time) + ) + + # Grabs all shifts that are within `first_reoccuring_date` and + # `last_reoccuring_date` + shifts = Shift.objects.filter( + employee=user.pk, + started__lte=last_reoccuring_date, + finished__gte=first_reoccuring_date, + contract__pk=contract + ) + + if exclude_shift: + shifts = shifts.exclude(pk=exclude_shift) + + # Filter shifts to those on the day of reoccurence + dates = [datetime.date() for datetime in dates] + shifts = shifts.filter(started__date__in=dates) + + return shifts + + +def sort_overlapping_shifts(started, finished, user, contract, shifts): + """Sort all shifts by non-overlapping and overlapping ones. + + Returns two sorted QuerySets. + """ + good_shifts = [] + bad_shifts = [] + + for shift in shifts: + # Set start and finish datetime for the to-be-created Shift + start = datetime.combine(shift.started.date(), + started.time()).astimezone(pytz.utc) + finish = datetime.combine(shift.started.date(), + finished.time()).astimezone(pytz.utc) + s = Shift( + employee=user, started=start, finished=finish, contract=contract + ) + + if (shift.started <= finish.astimezone(pytz.utc) + ) and (shift.finished >= start.astimezone(pytz.utc)): + bad_shifts.append(s) + else: + good_shifts.append(s) + + return good_shifts, bad_shifts + def get_return_url(request, default_success): """Checks whether the user should be returned to the default_success view or to @@ -47,8 +121,8 @@ def get_return_url(request, default_success): } try: - return_kwargs['contract'] = request.session['last_kwargs'][ - 'contract'] + return_kwargs['contract'] = request.session['last_kwargs' + ]['contract'] except KeyError: return_kwargs['contract'] = '00' @@ -137,7 +211,8 @@ def get_last_shifts(user, count=5): :return: Shift objects or None """ finished_shifts = Shift.objects.filter( - employee=user, finished__isnull=False)[:count] + employee=user, finished__isnull=False + )[:count] if not finished_shifts: return None diff --git a/clock/templates/shift/edit.html b/clock/templates/shift/edit.html index 626987c..8d4dc7e 100755 --- a/clock/templates/shift/edit.html +++ b/clock/templates/shift/edit.html @@ -15,6 +15,21 @@ {% endblock breadcrumbs %} {% block container %} +
{% if shift %} @@ -32,6 +47,110 @@

{% trans 'New shift' %}

{% block extra_js %}