diff --git a/Dockerfile b/Dockerfile index 65bda76f..fd152afc 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM python:3.6 +FROM python:3.8 WORKDIR /app diff --git a/README.md b/README.md index 92b2a407..d364e391 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,14 @@ You can now access the API at http://localhost:8000/api/v1 and the admin interfa For end user interface have a look at our [Timed Frontend](https://github.com/adfinis-sygroup/timed-frontend) project. +## Development + +To get the application working locally for development, make sure to create a file `.env` with the following content: + +``` +ENV=dev +``` + ## Configuration Following options can be set as environment variables to configure Timed backend in documented [format](https://github.com/joke2k/django-environ#supported-types) diff --git a/requirements-dev.txt b/requirements-dev.txt index a824a68b..0883cbc3 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,21 +1,22 @@ -r requirements.txt -black==19.3b0 -coverage==4.5.3 +black==19.10b0 +coverage==5.0.3 factory-boy==2.12.0 -flake8==3.7.7 +flake8==3.7.9 flake8-blind-except==0.1.1 -flake8-debugger==3.1.0 +flake8-debugger==3.2.1 flake8-deprecated==1.3 -flake8-docstrings==1.3.0 -flake8-isort==2.7.0 +flake8-docstrings==1.5.0 +flake8-isort==2.8.0 flake8-string-format==0.2.3 -ipdb==0.12 -isort==4.3.20 -mockldap==0.3.0 -pytest==4.5.0 -pytest-cov==2.7.1 -pytest-django==3.4.8 +ipdb==0.12.3 +isort==4.3.21 +mockldap==0.3.0.post1 +pdbpp==0.10.2 +pytest==5.3.5 +pytest-cov==2.8.1 +pytest-django==3.8.0 pytest-env==0.6.2 -pytest-freezegun==0.3.0.post1 -pytest-mock==1.10.4 -pytest-randomly==3.0.0 +pytest-freezegun==0.4.1 +pytest-mock==2.0.0 +pytest-randomly==3.2.1 diff --git a/requirements.txt b/requirements.txt index 1fb59427..220940e4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,20 +1,20 @@ -python-dateutil==2.8.0 -django==1.11.22 # pyup: >=1.11,<1.12 -django-auth-ldap==1.7.0 -django-filter==2.1.0 -django-multiselectfield==0.1.8 -djangorestframework==3.9.4 -djangorestframework-jwt==1.11.0 -djangorestframework-jsonapi==2.7.0 -psycopg2-binary==2.8.2 -pytz==2019.1 +python-dateutil==2.8.1 +django==2.2.10 # pyup: <3.0 +django-auth-ldap==2.1.0 +django-filter==2.2.0 +django-multiselectfield==0.1.11 +djangorestframework==3.11.0 +djangorestframework-simplejwt==4.4.0 +djangorestframework-jsonapi==3.0.0 +psycopg2-binary==2.8.4 +pytz==2019.3 pyexcel-webio==0.1.4 -pyexcel-io==0.5.17 +pyexcel-io==0.5.20 django-excel==0.0.10 pyexcel-ods3==0.5.3 -pyexcel-xlsx==0.5.7 +pyexcel-xlsx==0.5.8 pyexcel-ezodf==0.3.4 django-environ==0.4.5 -django-money==0.14.4 +django-money==1.0 python-redmine==2.2.1 uwsgi==2.0.18 diff --git a/timed/projects/views.py b/timed/projects/views.py index 9eb1d9b9..7f9d5c40 100644 --- a/timed/projects/views.py +++ b/timed/projects/views.py @@ -1,7 +1,7 @@ """Viewsets for the projects app.""" from rest_framework.viewsets import ReadOnlyModelViewSet -from rest_framework_json_api.views import PrefetchForIncludesHelperMixin +from rest_framework_json_api.views import PreloadIncludesMixin from timed.projects import filters, models, serializers @@ -38,7 +38,7 @@ def get_queryset(self): return models.CostCenter.objects.all() -class ProjectViewSet(PrefetchForIncludesHelperMixin, ReadOnlyModelViewSet): +class ProjectViewSet(PreloadIncludesMixin, ReadOnlyModelViewSet): """Project view set.""" serializer_class = serializers.ProjectSerializer diff --git a/timed/redmine/management/commands/redmine_report.py b/timed/redmine/management/commands/redmine_report.py index 3ec19980..4257a430 100644 --- a/timed/redmine/management/commands/redmine_report.py +++ b/timed/redmine/management/commands/redmine_report.py @@ -53,13 +53,17 @@ def handle(self, *args, **options): .values("id") ) # calculate total hours - projects = Project.objects.filter(id__in=affected_projects).annotate( - total_hours=Sum("tasks__reports__duration") + projects = ( + Project.objects.filter(id__in=affected_projects) + .order_by("name") + .annotate(total_hours=Sum("tasks__reports__duration")) ) for project in projects: estimated_hours = ( - project.estimated_time and project.estimated_time.total_seconds() / 3600 + project.estimated_time.total_seconds() / 3600 + if project.estimated_time + else 0.0 ) total_hours = project.total_hours.total_seconds() / 3600 try: diff --git a/timed/reports/tests/test_month_statistic.py b/timed/reports/tests/test_month_statistic.py index 28fc5311..9643af6c 100644 --- a/timed/reports/tests/test_month_statistic.py +++ b/timed/reports/tests/test_month_statistic.py @@ -18,12 +18,12 @@ def test_month_statistic_list(auth_client): expected_json = [ { "type": "month-statistics", - "id": "2015-12", + "id": "201512", "attributes": {"year": 2015, "month": 12, "duration": "03:00:00"}, }, { "type": "month-statistics", - "id": "2016-1", + "id": "201601", "attributes": {"year": 2016, "month": 1, "duration": "01:00:00"}, }, ] diff --git a/timed/reports/tests/test_notify_supervisors_shorttime.py b/timed/reports/tests/test_notify_supervisors_shorttime.py index be64eca2..e7693d58 100644 --- a/timed/reports/tests/test_notify_supervisors_shorttime.py +++ b/timed/reports/tests/test_notify_supervisors_shorttime.py @@ -39,7 +39,7 @@ def test_notify_supervisors(db, mailoutbox): mail = mailoutbox[0] assert mail.to == [supervisor.email] body = mail.body - assert "Time range: 17.07.2017 - 23.07.2017\nRatio: 0.9" in body + assert "Time range: July 17, 2017 - July 23, 2017\nRatio: 0.9" in body expected = ("{0} 35.0/42.5 (Ratio 0.82 Delta -7.5 Balance -9.0)").format( supervisee.get_full_name() ) diff --git a/timed/reports/views.py b/timed/reports/views.py index 603edf9d..e90b09ba 100644 --- a/timed/reports/views.py +++ b/timed/reports/views.py @@ -5,8 +5,8 @@ from zipfile import ZipFile from django.conf import settings -from django.db.models import F, Sum, Value -from django.db.models.functions import Concat, ExtractMonth, ExtractYear +from django.db.models import F, Sum +from django.db.models.functions import ExtractMonth, ExtractYear from django.http import HttpResponse, HttpResponseBadRequest from ezodf import Cell, opendoc from rest_framework.viewsets import GenericViewSet, ReadOnlyModelViewSet @@ -49,7 +49,7 @@ def get_queryset(self): ) queryset = queryset.values("year", "month") queryset = queryset.annotate(duration=Sum("duration")) - queryset = queryset.annotate(pk=Concat("year", Value("-"), "month")) + queryset = queryset.annotate(pk=F("year") * 100 + F("month")) return queryset diff --git a/timed/settings.py b/timed/settings.py index 3bf154ea..c2af1a5c 100644 --- a/timed/settings.py +++ b/timed/settings.py @@ -153,8 +153,7 @@ def default(default_dev=env.NOTSET, default_prod=env.NOTSET): "DEFAULT_PARSER_CLASSES": ("rest_framework_json_api.parsers.JSONParser",), "DEFAULT_PERMISSION_CLASSES": ("rest_framework.permissions.IsAuthenticated",), "DEFAULT_AUTHENTICATION_CLASSES": ( - "rest_framework_jwt.authentication.JSONWebTokenAuthentication", - "rest_framework.authentication.SessionAuthentication", + "rest_framework_simplejwt.authentication.JWTAuthentication", ), "DEFAULT_METADATA_CLASS": "rest_framework_json_api.metadata.JSONAPIMetadata", "EXCEPTION_HANDLER": "rest_framework_json_api.exceptions.exception_handler", @@ -187,11 +186,11 @@ def default(default_dev=env.NOTSET, default_prod=env.NOTSET): AUTH_USER_MODEL = "employment.User" -JWT_AUTH = { - "JWT_EXPIRATION_DELTA": datetime.timedelta(days=2), - "JWT_ALLOW_REFRESH": True, - "JWT_REFRESH_EXPIRATION_DELTA": datetime.timedelta(days=7), - "JWT_AUTH_HEADER_PREFIX": "Bearer", +SIMPLE_AUTH = { + "ACCESS_TOKEN_LIFETIME": datetime.timedelta(days=2), + "REFRESH_TOKEN_LIFETIME": datetime.timedelta(days=7), + # TODO check if this is ROTATE_REFRESH_TOKENS + # "JWT_ALLOW_REFRESH": True, } AUTH_PASSWORD_VALIDATORS = [ diff --git a/timed/tests/client.py b/timed/tests/client.py index 9c0bcc2a..eedcf483 100644 --- a/timed/tests/client.py +++ b/timed/tests/client.py @@ -5,7 +5,6 @@ from django.urls import reverse from rest_framework import exceptions, status from rest_framework.test import APIClient -from rest_framework_jwt.settings import api_settings class JSONAPIClient(APIClient): @@ -40,7 +39,7 @@ def post(self, path, data=None, **kwargs): path=path, data=self._parse_data(data), content_type=self._content_type, - **kwargs + **kwargs, ) def delete(self, path, data=None, **kwargs): @@ -53,7 +52,7 @@ def delete(self, path, data=None, **kwargs): path=path, data=self._parse_data(data), content_type=self._content_type, - **kwargs + **kwargs, ) def patch(self, path, data=None, **kwargs): @@ -66,7 +65,7 @@ def patch(self, path, data=None, **kwargs): path=path, data=self._parse_data(data), content_type=self._content_type, - **kwargs + **kwargs, ) def login(self, username, password): @@ -79,7 +78,7 @@ def login(self, username, password): data = { "data": { "attributes": {"username": username, "password": password}, - "type": "obtain-json-web-tokens", + "type": "token-obtain-pair-views", } } @@ -88,8 +87,4 @@ def login(self, username, password): if response.status_code != status.HTTP_200_OK: raise exceptions.AuthenticationFailed() - self.credentials( - HTTP_AUTHORIZATION="{0} {1}".format( - api_settings.JWT_AUTH_HEADER_PREFIX, response.data["token"] - ) - ) + self.credentials(HTTP_AUTHORIZATION=f"Bearer {response.data['access']}") diff --git a/timed/urls.py b/timed/urls.py index 4277f1b8..bc0f76d9 100644 --- a/timed/urls.py +++ b/timed/urls.py @@ -2,12 +2,12 @@ from django.conf.urls import include, url from django.contrib import admin -from rest_framework_jwt.views import obtain_jwt_token, refresh_jwt_token +from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView urlpatterns = [ url(r"^admin/", admin.site.urls), - url(r"^api/v1/auth/login", obtain_jwt_token, name="login"), - url(r"^api/v1/auth/refresh", refresh_jwt_token, name="refresh"), + url(r"^api/v1/auth/login", TokenObtainPairView.as_view(), name="login"), + url(r"^api/v1/auth/refresh", TokenRefreshView.as_view(), name="refresh"), url(r"^api/v1/", include("timed.employment.urls")), url(r"^api/v1/", include("timed.projects.urls")), url(r"^api/v1/", include("timed.tracking.urls")),