From 418cd33ee46b4f6971d30366309402e2870e71d1 Mon Sep 17 00:00:00 2001 From: Maciej Date: Fri, 21 Dec 2018 17:32:05 +0100 Subject: [PATCH 01/10] Add custom decorator for loading enums in templates After using this decorator, enum values can be easily accessed from templates. --- utils/decorators.py | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 utils/decorators.py diff --git a/utils/decorators.py b/utils/decorators.py new file mode 100644 index 000000000..ec89da0d1 --- /dev/null +++ b/utils/decorators.py @@ -0,0 +1,3 @@ +def notCallable(cls): + cls.do_not_call_in_templates = True + return cls From 4a571f14366e88b70f0c2aa3c9015988e7a1976e Mon Sep 17 00:00:00 2001 From: Maciej Date: Fri, 21 Dec 2018 17:33:18 +0100 Subject: [PATCH 02/10] Add custom views' UI text in string constants --- employees/common/strings.py | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/employees/common/strings.py b/employees/common/strings.py index 3796ab070..17a1598fb 100644 --- a/employees/common/strings.py +++ b/employees/common/strings.py @@ -1,4 +1,31 @@ +from enum import Enum + from django.utils.translation import ugettext_lazy +from utils.decorators import notCallable + MAX_DECIMAL_VALUE_VALIDATOR_MESSAGE = ugettext_lazy('Ensure this value is less than or equal to ') + + +@notCallable +class ReportListStrings(Enum): + PAGE_TITLE = ugettext_lazy("Reports") + CREATE_REPORT_BUTTON = ugettext_lazy("Create") + DATE_COLUMN_HEADER = ugettext_lazy("Date") + PROJECT_COLUMN_HEADER = ugettext_lazy("Project") + WORK_HOURS_COLUMN_HEADER = ugettext_lazy("Work hours") + DESCRIPTION_COLUMN_HEADER = ugettext_lazy("Description") + EDIT_REPORT_BUTTON = ugettext_lazy("Edit") + + +@notCallable +class ReportDetailStrings(Enum): + PAGE_TITLE = ugettext_lazy("Report - ") + UPDATE_REPORT_BUTTON = ugettext_lazy("Update") + DISCARD_CHANGES_BUTTON = ugettext_lazy("Discard") + DELETE_REPORT_BUTTON = ugettext_lazy("Delete") + DELETE_POPUP_MESSAGE = ugettext_lazy("Are you sure you want to delete this report?") + DELETE_POPUP_TITLE = ugettext_lazy("Delete report") + DELETE_POPUP_YES = ugettext_lazy("Yes") + DELETE_POPUP_NO = ugettext_lazy("No") From b010b1cde69834c23c87c74567f6ff78852db9ce Mon Sep 17 00:00:00 2001 From: Maciej Date: Tue, 12 Feb 2019 13:28:30 +0100 Subject: [PATCH 03/10] Add style.css file in employees app --- employees/static/employees/style.css | 44 ++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 employees/static/employees/style.css diff --git a/employees/static/employees/style.css b/employees/static/employees/style.css new file mode 100644 index 000000000..02d3eeed3 --- /dev/null +++ b/employees/static/employees/style.css @@ -0,0 +1,44 @@ +#dialog { + display: none; + text-align: center; +} + +.modal-dialog { + margin-bottom: 0; +} + +.date-header { + width: 11.23%; +} + +.project-header { + width: 20.18%; +} + +.work-hours-header { + width: 8.16%; +} + +.edit-button-header { + width: 8.77%; +} + +.date-column { + text-align: left; +} + +.project-column { + text-align: left; +} + +.description-column { + text-align: left; +} + +.work-hours-column { + text-align: right; +} + +.edit-button-column { + text-align: right; +} From dc485b987bb0a8d606eb936e074c365e8126cd16 Mon Sep 17 00:00:00 2001 From: Maciej Date: Tue, 13 Nov 2018 16:45:12 +0100 Subject: [PATCH 04/10] Add template for custom Report list view --- .../templates/employees/report_list.html | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 employees/templates/employees/report_list.html diff --git a/employees/templates/employees/report_list.html b/employees/templates/employees/report_list.html new file mode 100644 index 000000000..8376bce7d --- /dev/null +++ b/employees/templates/employees/report_list.html @@ -0,0 +1,60 @@ +{% extends 'base.html' %} +{% load i18n %} +{% block content %} +{% load rest_framework %} +{% load static %} + + + +

{{ UI_text.PAGE_TITLE.value }}

+ + +
+
+
+ + + + + + + + + {% for date, reports in reports_dict.items %} + + + {% for report in reports %} + {% if forloop.counter > 1 %} + + {% endif %} + + + + + + {% endfor %} + {% endfor %} +
{{ UI_text.DATE_COLUMN_HEADER.value }}{{ UI_text.PROJECT_COLUMN_HEADER.value }}{{ UI_text.WORK_HOURS_COLUMN_HEADER.value }}{{ UI_text.DESCRIPTION_COLUMN_HEADER.value }}
+ {{ date }} +
+ {{ report.project }} + + {{ report.work_hours }} + + {{ report.description }} +
+
+
+{% endblock %} From 98893603f13e4005ea490025586d12bc0f15d79c Mon Sep 17 00:00:00 2001 From: Maciej Date: Mon, 11 Feb 2019 16:31:22 +0100 Subject: [PATCH 05/10] Add popup script in employees app --- .../employees/scripts/basic_popup_window.js | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 employees/static/employees/scripts/basic_popup_window.js diff --git a/employees/static/employees/scripts/basic_popup_window.js b/employees/static/employees/scripts/basic_popup_window.js new file mode 100644 index 000000000..429d58da4 --- /dev/null +++ b/employees/static/employees/scripts/basic_popup_window.js @@ -0,0 +1,24 @@ +$(function () { + $("#dialog").dialog ({ + modal: true, + autoOpen: false, + buttons : [ + { + text: discard_text, + click: function () { + $(this).dialog('close'); + } + }, + { + text: confirmation_text, + click: function () { + window.location.href = redirect_url; + } + } + ] + }).prev().find(".ui-dialog-titlebar-close").hide (); + + $("#opener").click(function () { + $('#dialog').dialog('open'); + }); +}); From 792ec4a41ade70dd9a755596a31e38da4c48f537 Mon Sep 17 00:00:00 2001 From: Maciej Date: Fri, 16 Nov 2018 14:12:43 +0100 Subject: [PATCH 06/10] Add template for custom Report detail view --- .../templates/employees/report_detail.html | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 employees/templates/employees/report_detail.html diff --git a/employees/templates/employees/report_detail.html b/employees/templates/employees/report_detail.html new file mode 100644 index 000000000..7337d9bee --- /dev/null +++ b/employees/templates/employees/report_detail.html @@ -0,0 +1,34 @@ +{% extends 'base.html' %} +{% load i18n %} +{% block content %} +{% load rest_framework %} +{% load static %} + + + + + + + + + + +

{{ UI_text.PAGE_TITLE.value }}{{ report.project }} ({{ report.date }})

+
+ +
+ {{ UI_text.DELETE_POPUP_MESSAGE.value }} +
+{% endblock %} From ae75e3004af3963344c038615a122497e1af4559 Mon Sep 17 00:00:00 2001 From: Maciej Date: Fri, 16 Nov 2018 14:13:49 +0100 Subject: [PATCH 07/10] Add custom views for Report model --- employees/views.py | 96 +++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 95 insertions(+), 1 deletion(-) diff --git a/employees/views.py b/employees/views.py index b7e163265..28e71640b 100644 --- a/employees/views.py +++ b/employees/views.py @@ -1,6 +1,13 @@ +from django.shortcuts import get_object_or_404 +from django.shortcuts import redirect from rest_framework import permissions +from rest_framework import renderers from rest_framework import viewsets +from rest_framework.response import Response +from rest_framework.views import APIView +from employees.common.strings import ReportDetailStrings +from employees.common.strings import ReportListStrings from employees.models import Report from employees.serializers import ReportSerializer @@ -9,10 +16,97 @@ class ReportViewSet(viewsets.ModelViewSet): serializer_class = ReportSerializer permission_classes = ( permissions.IsAuthenticated, - ) + ) def get_queryset(self): return Report.objects.filter(author=self.request.user).order_by('-date') def perform_create(self, serializer): serializer.save(author=self.request.user) + + +def query_as_dict(query_set): + dictionary = {} + for record in query_set: + key = record.date + dictionary.setdefault(key, []) + dictionary[key].append(record) + return dictionary + + +class ReportList(APIView): + renderer_classes = [renderers.TemplateHTMLRenderer] + template_name = 'employees/report_list.html' + permission_classes = ( + permissions.IsAuthenticated, + ) + + def get_queryset(self): + return Report.objects.filter(author=self.request.user).order_by('-date', 'project__name') + + def get(self, request): + reports_serializer = ReportSerializer(context={'request': request}) + reports_dict = query_as_dict(self.get_queryset()) + return Response({ + 'serializer': reports_serializer, + 'reports_dict': reports_dict, + 'UI_text': ReportListStrings, + }) + + def post(self, request): + reports_serializer = ReportSerializer(data=request.data, context={'request': request}) + if not reports_serializer.is_valid(): + return Response({ + 'serializer': reports_serializer, + 'reports_dict': query_as_dict(self.get_queryset()), + 'errors': reports_serializer.errors, + 'UI_text': ReportListStrings, + }) + reports_serializer.save(author=self.request.user) + reports_serializer = ReportSerializer(context={'request': request}) + return Response({ + 'serializer': reports_serializer, + 'reports_dict': query_as_dict(self.get_queryset()), + 'UI_text': ReportListStrings, + }, status=201) + + +class ReportDetail(APIView): + renderer_classes = [renderers.TemplateHTMLRenderer] + template_name = 'employees/report_detail.html' + permission_classes = ( + permissions.IsAuthenticated, + ) + + def get(self, request, pk): + report = get_object_or_404(Report, pk=pk) + serializer = ReportSerializer(report, context={'request': request}) + return Response({ + 'serializer': serializer, + 'report': report, + 'UI_text': ReportDetailStrings, + }) + + def post(self, request, pk): + if "discard" not in request.POST: + report = get_object_or_404(Report, pk=pk) + serializer = ReportSerializer( + report, + data=request.data, + context={'request': request} + ) + if not serializer.is_valid(): + return Response({ + 'serializer': serializer, + 'report': report, + 'errors': serializer.errors, + 'UI_text': ReportDetailStrings, + }) + serializer.save() + return redirect('custom-report-list') + + +def delete_report(_request, pk): + report = get_object_or_404(Report, pk=pk) + report.delete() + return redirect('custom-report-list') From ba56855d5f9279357cff019e69d20a9aeada4a73 Mon Sep 17 00:00:00 2001 From: Maciej Date: Fri, 16 Nov 2018 14:15:29 +0100 Subject: [PATCH 08/10] Link custom views in url config Also pushes existing DRF views to '*/api/' path --- employees/urls.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/employees/urls.py b/employees/urls.py index 8ff2b574c..458203c57 100644 --- a/employees/urls.py +++ b/employees/urls.py @@ -10,5 +10,8 @@ # The API URLs are now determined automatically by the router. urlpatterns = [ - url(r'^', include(router.urls)), + url(r'^api/', include(router.urls)), + url(r'^reports/$', views.ReportList.as_view(), name='custom-report-list'), + url(r'^reports/(?P[0-9]+)/$', views.ReportDetail.as_view(), name='custom-report-detail'), + url(r'^reports/(?P[0-9]+)/delete/$', views.delete_report, name='custom-report-delete'), ] From f028d64e71c1cf15a6933b2bd7b1f2b4d9212d2a Mon Sep 17 00:00:00 2001 From: Maciej Date: Thu, 13 Dec 2018 11:50:15 +0100 Subject: [PATCH 09/10] Add unit tests for custom Report views --- .../test_unit_report_view_helper_functions.py | 70 ++++ employees/tests/test_unit_report_viewset.py | 305 ++++++++++++++++++ 2 files changed, 375 insertions(+) create mode 100644 employees/tests/test_unit_report_view_helper_functions.py diff --git a/employees/tests/test_unit_report_view_helper_functions.py b/employees/tests/test_unit_report_view_helper_functions.py new file mode 100644 index 000000000..d1b56fa14 --- /dev/null +++ b/employees/tests/test_unit_report_view_helper_functions.py @@ -0,0 +1,70 @@ +import datetime +from decimal import Decimal +from django.test import TestCase + +from employees.models import Report +from employees.views import query_as_dict +from managers.models import Project +from users.models import CustomUser + + +class TestListHelpers(TestCase): + def setUp(self): + self.user = CustomUser( + email="testuser@codepoets.it", + password='newuserpasswd', + first_name='John', + last_name='Doe', + country='PL' + ) + self.user.full_clean() + self.user.save() + + self.project = Project( + name="Test Project", + start_date=datetime.datetime.now(), + ) + self.project.full_clean() + self.project.save() + + report = Report( + date=datetime.datetime.now().date(), + description='Some description', + author=self.user, + project=self.project, + work_hours=Decimal('8.00'), + ) + report.full_clean() + report.save() + + report = Report( + date=datetime.datetime.now().date(), + description='Some description', + author=self.user, + project=self.project, + work_hours=Decimal('8.00'), + ) + report.full_clean() + report.save() + + report = Report( + date=datetime.date(2001, 1, 1), + description='Some description', + author=self.user, + project=self.project, + work_hours=Decimal('8.00'), + ) + report.full_clean() + report.save() + + def test_queryset_as_dict_should_return_dictionary_where_keys_are_dates_and_values_are_lists_of_reports(self): + queryset = Report.objects.all() + dictionary = query_as_dict(queryset) + self.assertEqual(len(dictionary), 2) + self.assertEqual(len(list(dictionary.values())[0]), 2) + self.assertEqual(len(list(dictionary.values())[1]), 1) + self.assertIsInstance(list(dictionary.keys())[0], datetime.date) + self.assertIsInstance(list(dictionary.keys())[1], datetime.date) + self.assertIsInstance(list(dictionary.values())[0][0], Report) + self.assertIsInstance(list(dictionary.values())[0][1], Report) + self.assertIsInstance(list(dictionary.values())[1][0], Report) diff --git a/employees/tests/test_unit_report_viewset.py b/employees/tests/test_unit_report_viewset.py index 8365c0616..c3e1ee43f 100644 --- a/employees/tests/test_unit_report_viewset.py +++ b/employees/tests/test_unit_report_viewset.py @@ -7,6 +7,9 @@ from rest_framework.test import APIRequestFactory from employees.models import Report +from employees.views import delete_report +from employees.views import ReportDetail +from employees.views import ReportList from employees.views import ReportViewSet from managers.models import Project from users.models import CustomUser @@ -148,3 +151,305 @@ def test_report_detail_view_should_delete_report_on_delete(self): response = ReportViewSet.as_view({'delete': 'destroy'})(request, pk=self.report.pk) self.assertEqual(response.status_code, 204) self.assertEqual(Report.objects.all().count(), 0) + + +class ReportListTests(TestCase): + def setUp(self): + self.user = CustomUser( + email="testuser@codepoets.it", + password='newuserpasswd', + first_name='John', + last_name='Doe', + country='PL' + ) + self.user.full_clean() + self.user.save() + + self.project = Project( + name="Test Project", + start_date=datetime.datetime.now(), + ) + self.project.full_clean() + self.project.save() + + self.report = Report( + date=datetime.datetime.now().date(), + description='Some description', + author=self.user, + project=self.project, + work_hours=Decimal('8.00'), + ) + self.report.full_clean() + self.report.save() + self.url = reverse('custom-report-list') + + def test_custom_list_view_should_display_users_report_list_on_get(self): + request = APIRequestFactory().get(path=self.url) + request.user = self.user + response = ReportList.as_view()(request) + self.assertEqual(response.status_code, 200) + self.assertContains(response, self.report.description) + dictionary = response.data['reports_dict'] + reports = list(dictionary.values())[0] + self.assertTrue(self.report in reports) + + def test_custom_list_view_should_not_be_accessible_for_unauthenticated_user(self): + request = APIRequestFactory().get(path=self.url) + request.user = AnonymousUser() + response = ReportList.as_view()(request) + self.assertEqual(response.status_code, 403) + + def test_custom_list_view_should_not_display_other_users_reports(self): + other_user = CustomUser( + email="otheruser@codepoets.it", + password='otheruserpasswd', + first_name='Jane', + last_name='Doe', + country='PL', + ) + other_user.full_clean() + other_user.save() + + other_report = Report( + date=datetime.datetime.now().date(), + description='Some other description', + author=other_user, + project=self.project, + work_hours=Decimal('8.00'), + ) + other_report.full_clean() + other_report.save() + + request = APIRequestFactory().get(path=self.url) + request.user = self.user + response = ReportList.as_view()(request) + self.assertEqual(response.status_code, 200) + self.assertNotContains(response, other_report.description) + + def test_custom_report_list_view_should_add_new_report_on_post(self): + request = APIRequestFactory().post( + path=self.url, + data={ + 'date': datetime.datetime.now().date(), + 'description': 'Some description', + 'project': self.project, + 'work_hours': Decimal('8.00'), + } + ) + request.user = self.user + response = ReportList.as_view()(request) + self.assertEqual(response.status_code, 201) + self.assertEqual(Report.objects.all().count(), 2) + + def test_custom_report_list_view_should_not_add_new_report_on_post_if_form_is_invalid(self): + request = APIRequestFactory().post( + path=self.url, + data={ + 'description': 'Some description', + 'project': self.project, + 'work_hours': Decimal('8.00'), + } + ) + request.user = self.user + response = ReportList.as_view()(request) + self.assertEqual(response.status_code, 200) + self.assertEqual(Report.objects.all().count(), 1) + self.assertIsNotNone(response.data['errors']) + + def test_get_queryset_method_should_return_queryset_containing_all_of_current_users_reports(self): + other_user = CustomUser( + email="otheruser@codepoets.it", + password='otheruserpasswd', + first_name='Jane', + last_name='Doe', + country='PL', + ) + other_user.full_clean() + other_user.save() + + other_project = Project( + name="Project test", + start_date=datetime.datetime.now(), + ) + other_project.full_clean() + other_project.save() + + other_user_report = Report( + date=datetime.datetime.now().date(), + description='Some other description', + author=other_user, + project=self.project, + work_hours=Decimal('8.00'), + ) + other_user_report.full_clean() + other_user_report.save() + + other_report_1 = Report( + date=datetime.datetime.now().date(), + description='Some other description', + author=self.user, + project=other_project, + work_hours=Decimal('8.00'), + ) + other_report_1.full_clean() + other_report_1.save() + + other_report_2 = Report( + date=datetime.date(2001, 1, 1), + description='Some other description', + author=self.user, + project=self.project, + work_hours=Decimal('8.00'), + ) + other_report_2.full_clean() + other_report_2.save() + + request = APIRequestFactory().get(path=self.url) + request.user = self.user + view = ReportList() + view.request = request + queryset = view.get_queryset() + self.assertEqual(len(queryset), 3) + self.assertFalse(other_user_report in queryset) + self.assertEqual(queryset[0], other_report_1) + self.assertEqual(queryset[1], self.report) + self.assertEqual(queryset[2], other_report_2) + + +class ReportDetailTests(TestCase): + def setUp(self): + self.user = CustomUser( + email="testuser@codepoets.it", + password='newuserpasswd', + first_name='John', + last_name='Doe', + country='PL' + ) + self.user.full_clean() + self.user.save() + + self.project = Project( + name="Test Project", + start_date=datetime.datetime.now(), + ) + self.project.full_clean() + self.project.save() + + self.report = Report( + date=datetime.datetime.now().date(), + description='Some description', + author=self.user, + project=self.project, + work_hours=Decimal('8.00'), + ) + self.report.full_clean() + self.report.save() + + def test_custom_report_detail_view_should_display_report_details_on_get(self): + request = APIRequestFactory().get(path=reverse('custom-report-detail', args=(self.report.pk,))) + request.user = self.user + response = ReportDetail.as_view()(request, pk=self.report.pk) + self.assertEqual(response.status_code, 200) + self.assertContains(response, self.report.description) + self.assertEqual(response.data['serializer'].instance, self.report) + + def test_custom_report_list_view_should_not_be_accessible_for_unauthenticated_users(self): + request = APIRequestFactory().get(path=reverse('custom-report-detail', args=(self.report.pk,))) + request.user = AnonymousUser() + response = ReportDetail.as_view()(request, pk=self.report.pk) + self.assertEqual(response.status_code, 403) + + def test_custom_report_detail_view_should_not_render_non_existing_report(self): + request = APIRequestFactory().get(path=reverse('custom-report-detail', args=(999,))) + request.user = self.user + response = ReportDetail.as_view()(request, pk=999) + self.assertEqual(response.status_code, 404) + + def test_custom_report_detail_view_should_update_report_on_post(self): + new_description = 'Some other description' + request = APIRequestFactory().post( + path=reverse('custom-report-detail', args=(self.report.pk,)), + data={ + 'date': datetime.datetime.now().date(), + 'description': new_description, + 'project': self.project, + 'work_hours': Decimal('8.00'), + }, + ) + request.user = self.user + response = ReportDetail.as_view()(request, pk=self.report.pk) + self.report.refresh_from_db() + self.assertEqual(response.status_code, 302) + self.assertEqual(self.report.description, new_description) + + def test_custom_report_detail_view_should_not_update_report_on_discard(self): + new_description = 'Some other description' + request = APIRequestFactory().post( + path=reverse('custom-report-detail', args=(self.report.pk,)), + data={ + 'date': datetime.datetime.now().date(), + 'description': new_description, + 'project': self.project, + 'work_hours': Decimal('8.00'), + 'discard': "Discard" + }, + ) + request.user = self.user + response = ReportDetail.as_view()(request, pk=self.report.pk) + self.report.refresh_from_db() + self.assertEqual(response.status_code, 302) + self.assertNotEqual(self.report.description, new_description) + + def test_custom_report_detail_view_should_not_update_report_on_post_if_form_is_invalid(self): + new_description = 'Some other description' + request = APIRequestFactory().post( + path=reverse('custom-report-detail', args=(self.report.pk,)), + data={ + 'description': new_description, + 'project': self.project, + 'work_hours': Decimal('8.00'), + }, + ) + request.user = self.user + response = ReportDetail.as_view()(request, pk=self.report.pk) + self.report.refresh_from_db() + self.assertEqual(response.status_code, 200) + self.assertIsNotNone(response.data['errors']) + self.assertNotEqual(new_description, self.report.description) + + +class DeleteReportTests(TestCase): + def setUp(self): + self.user = CustomUser( + email="testuser@codepoets.it", + password='newuserpasswd', + first_name='John', + last_name='Doe', + country='PL' + ) + self.user.full_clean() + self.user.save() + + self.project = Project( + name="Test Project", + start_date=datetime.datetime.now(), + ) + self.project.full_clean() + self.project.save() + + self.report = Report( + date=datetime.datetime.now().date(), + description='Some description', + author=self.user, + project=self.project, + work_hours=Decimal('8.00'), + ) + self.report.full_clean() + self.report.save() + + def test_delete_report_view_should_delete_report_on_post(self): + request = APIRequestFactory().delete(path=reverse('custom-report-delete', args=(self.report.pk,))) + request.user = self.user + response = delete_report(request, pk=self.report.pk) + self.assertEqual(response.status_code, 302) + self.assertEqual(Report.objects.all().count(), 0) From b3ac00d0f2f33684bf8be2a5033b4a0ffa914373 Mon Sep 17 00:00:00 2001 From: Maciej Date: Wed, 9 Jan 2019 17:32:37 +0100 Subject: [PATCH 10/10] Link employees app in base template --- users/templates/base.html | 1 + 1 file changed, 1 insertion(+) diff --git a/users/templates/base.html b/users/templates/base.html index 55a03b428..7203d8d6b 100644 --- a/users/templates/base.html +++ b/users/templates/base.html @@ -28,6 +28,7 @@ {% if user.user_type == "ADMIN" %} {% trans 'Employees' %} {% endif %} + {% trans 'Reports' %} {% trans 'Logout' %}