From 74dd0d23186be27456ffd05f0afa1610c7443877 Mon Sep 17 00:00:00 2001 From: Arthur Deierlein Date: Fri, 26 Apr 2024 10:07:17 +0200 Subject: [PATCH 1/4] chore(models): add type annotations --- timed/employment/models.py | 48 ++++++++++++++++++++------------------ timed/projects/models.py | 7 +++--- timed/tracking/models.py | 10 ++++++-- 3 files changed, 37 insertions(+), 28 deletions(-) diff --git a/timed/employment/models.py b/timed/employment/models.py index 704be41a..1aae9a81 100644 --- a/timed/employment/models.py +++ b/timed/employment/models.py @@ -1,6 +1,9 @@ """Models for the employment app.""" +from __future__ import annotations + from datetime import date, timedelta +from typing import TYPE_CHECKING from dateutil import rrule from django.conf import settings @@ -12,7 +15,10 @@ from timed.models import WeekdaysField from timed.projects.models import CustomerAssignee, ProjectAssignee, TaskAssignee -from timed.tracking.models import Absence +from timed.tracking.models import Absence, Report + +if TYPE_CHECKING: + from django.db.models import QuerySet class Location(models.Model): @@ -76,7 +82,7 @@ def __str__(self) -> str: """Represent the model as a string.""" return self.name - def calculate_credit(self, user, start, end): + def calculate_credit(self, user: User, start: date, end: date) -> int | None: """Calculate approved days of type for user in given time frame. For absence types which fill worktime this will be None. @@ -90,7 +96,7 @@ def calculate_credit(self, user, start, end): data = credits.aggregate(credit=Sum("days")) return data["credit"] or 0 - def calculate_used_days(self, user, start, end): + def calculate_used_days(self, user: User, start: date, end: date) -> int | None: """Calculate used days of type for user in given time frame. For absence types which fill worktime this will be None. @@ -157,7 +163,7 @@ def __str__(self) -> str: class EmploymentManager(models.Manager): """Custom manager for employments.""" - def get_at(self, user, date): + def get_at(self, user: User, date: date) -> Employment: """Get employment of user at given date. :param User user: The user of the searched employments @@ -170,7 +176,7 @@ def get_at(self, user, date): user=user, ) - def for_user(self, user, start, end): + def for_user(self, user: User, start: date, end: date) -> QuerySet[Employment]: """Get employments in given time frame for current user. This includes overlapping employments. @@ -228,7 +234,9 @@ def __str__(self) -> str: self.end_date.strftime("%d.%m.%Y") if self.end_date else "today", ) - def calculate_worktime(self, start, end): + def calculate_worktime( + self, start: date, end: date + ) -> tuple[timedelta, timedelta, timedelta]: """Calculate reported, expected and balance for employment. 1. It shortens the time frame so it is within given employment @@ -245,13 +253,8 @@ def calculate_worktime(self, start, end): 7. The balance is the reported time plus the absences plus the overtime credit minus the expected worktime - :param start: calculate worktime starting on given day. - :param end: calculate worktime till given day - :returns: tuple of 3 values reported, expected and delta in given - time frame + Return a tuple with 3 timedeltas of reported, expected and the delta. """ - from timed.tracking.models import Absence, Report - # shorten time frame to employment start = max(start, self.start_date) end = min(self.end_date or date.today(), end) @@ -303,13 +306,13 @@ def calculate_worktime(self, start, end): class UserManager(UserManager): - def all_supervisors(self): + def all_supervisors(self) -> QuerySet[User]: objects = self.model.objects.annotate( supervisees_count=models.Count("supervisees") ) return objects.filter(supervisees_count__gt=0) - def all_reviewers(self): + def all_reviewers(self) -> QuerySet[User]: return self.all().filter( models.Q( pk__in=TaskAssignee.objects.filter(is_reviewer=True).values("user") @@ -322,7 +325,7 @@ def all_reviewers(self): ) ) - def all_supervisees(self): + def all_supervisees(self) -> QuerySet[User]: objects = self.model.objects.annotate( supervisors_count=models.Count("supervisors") ) @@ -352,7 +355,7 @@ class User(AbstractUser): objects = UserManager() @property - def is_reviewer(self): + def is_reviewer(self) -> bool: return ( TaskAssignee.objects.filter(user=self, is_reviewer=True).exists() or ProjectAssignee.objects.filter(user=self, is_reviewer=True).exists() @@ -360,20 +363,19 @@ def is_reviewer(self): ) @property - def user_id(self): + def user_id(self) -> int: """Map to id to be able to use generic permissions.""" return self.id - def calculate_worktime(self, start, end): + def calculate_worktime( + self, start: date, end: date + ) -> tuple[timedelta, timedelta, timedelta]: """Calculate reported, expected and balance for user. This calculates summarizes worktime for all employments of users which are in given time frame. - :param start: calculate worktime starting on given day. - :param end: calculate worktime till given day - :returns: tuple of 3 values reported, expected and delta in given - time frame + Return a tuple with 3 timedeltas of reported, expected and balance. """ employments = Employment.objects.for_user(self, start, end).select_related( "location" @@ -389,7 +391,7 @@ def calculate_worktime(self, start, end): return (reported, expected, balance) - def get_active_employment(self): + def get_active_employment(self) -> Employment | None: """Get current employment of the user. Get current active employment of the user. diff --git a/timed/projects/models.py b/timed/projects/models.py index 92e3b920..84764349 100644 --- a/timed/projects/models.py +++ b/timed/projects/models.py @@ -36,7 +36,7 @@ class Meta: ordering = ("name",) - def __str__(self): + def __str__(self) -> str: """Represent the model as a string.""" return self.name @@ -50,7 +50,8 @@ class CostCenter(models.Model): class Meta: ordering = ("name",) - def __str__(self): + def __str__(self) -> str: + """Represent the model as a string.""" return self.name @@ -63,7 +64,7 @@ class BillingType(models.Model): class Meta: ordering = ("name",) - def __str__(self): + def __str__(self) -> str: """Represent the model as a string.""" return self.name diff --git a/timed/tracking/models.py b/timed/tracking/models.py index 7bb1cf13..329d41f9 100644 --- a/timed/tracking/models.py +++ b/timed/tracking/models.py @@ -1,10 +1,16 @@ """Models for the tracking app.""" +from __future__ import annotations + from datetime import timedelta +from typing import TYPE_CHECKING from django.conf import settings from django.db import models +if TYPE_CHECKING: + from timed.employment.models import Employment + class Activity(models.Model): """Activity model. @@ -104,7 +110,7 @@ def __str__(self) -> str: """Represent the model as a string.""" return f"{self.user}: {self.task}" - def save(self, *args, **kwargs): + def save(self, *args, **kwargs) -> None: # noqa: ANN002,ANN003 """Save the report with some custom functionality. This rounds the duration of the report to the nearest 15 minutes. @@ -149,7 +155,7 @@ def __str__(self) -> str: self.comment, ) - def calculate_duration(self, employment): + def calculate_duration(self, employment: Employment) -> timedelta: """Calculate duration of absence with given employment. For fullday absences duration is equal worktime per day of employment From 625ed986af0dca7146cd850a816f8deed03de208 Mon Sep 17 00:00:00 2001 From: Arthur Deierlein Date: Fri, 26 Apr 2024 11:43:57 +0200 Subject: [PATCH 2/4] chore(authentication): add type annotations --- timed/authentication.py | 35 +++++++++++++++++++++++++++-------- 1 file changed, 27 insertions(+), 8 deletions(-) diff --git a/timed/authentication.py b/timed/authentication.py index 491299ef..4a582847 100644 --- a/timed/authentication.py +++ b/timed/authentication.py @@ -1,6 +1,9 @@ +from __future__ import annotations + import base64 import functools import hashlib +from typing import TYPE_CHECKING import requests from django.conf import settings @@ -10,9 +13,18 @@ from mozilla_django_oidc.auth import LOGGER, OIDCAuthenticationBackend from rest_framework.exceptions import AuthenticationFailed +if TYPE_CHECKING: + from typing import Callable, Self + + from django.db.models import QuerySet + + from timed.employment.models import User + class TimedOIDCAuthenticationBackend(OIDCAuthenticationBackend): - def get_introspection(self, access_token, _id_token, _payload): + def get_introspection( + self, access_token: str, _id_token: str, _payload: dict + ) -> dict: """Return user details dictionary.""" basic = base64.b64encode( f"{settings.OIDC_RP_INTROSPECT_CLIENT_ID}:{settings.OIDC_RP_INTROSPECT_CLIENT_SECRET}".encode() @@ -31,7 +43,7 @@ def get_introspection(self, access_token, _id_token, _payload): response.raise_for_status() return response.json() - def get_userinfo_or_introspection(self, access_token): + def get_userinfo_or_introspection(self, access_token: str) -> dict: try: return self.cached_request(self.get_userinfo, access_token, "auth.userinfo") except requests.HTTPError as exc: @@ -57,7 +69,9 @@ def get_userinfo_or_introspection(self, access_token): return claims raise AuthenticationFailed from exc - def get_or_create_user(self, access_token, _id_token, _payload): + def get_or_create_user( + self, access_token: str, _id_token: str, _payload: dict + ) -> User | None: """Verify claims and return user, otherwise raise an Exception.""" claims = self.get_userinfo_or_introspection(access_token) @@ -76,17 +90,22 @@ def get_or_create_user(self, access_token, _id_token, _payload): ) return None - def update_user_from_claims(self, user, claims): + def update_user_from_claims(self, user: User, claims: dict[str, str]) -> None: user.email = claims.get(settings.OIDC_EMAIL_CLAIM, "") user.first_name = claims.get(settings.OIDC_FIRSTNAME_CLAIM, "") user.last_name = claims.get(settings.OIDC_LASTNAME_CLAIM, "") user.save() - def filter_users_by_claims(self, claims): + def filter_users_by_claims(self, claims: dict[str, str]) -> QuerySet[User]: username = self.get_username(claims) return self.UserModel.objects.filter(username__iexact=username) - def cached_request(self, method, token, cache_prefix): + def cached_request( + self, + method: Callable[[Self, str, None, None], dict], + token: str, + cache_prefix: str, + ) -> dict: token_hash = hashlib.sha256(force_bytes(token)).hexdigest() func = functools.partial(method, token, None, None) @@ -97,7 +116,7 @@ def cached_request(self, method, token, cache_prefix): timeout=settings.OIDC_BEARER_TOKEN_REVALIDATION_TIME, ) - def create_user(self, claims): + def create_user(self, claims: dict[str, str]) -> User: """Return object for a newly created user account.""" username = self.get_username(claims) email = claims.get(settings.OIDC_EMAIL_CLAIM, "") @@ -108,7 +127,7 @@ def create_user(self, claims): username=username, email=email, first_name=first_name, last_name=last_name ) - def get_username(self, claims): + def get_username(self, claims: dict[str, str]) -> str: try: return claims[settings.OIDC_USERNAME_CLAIM] except KeyError as exc: From b74c5b5a57b3d5c7fcd0a4d3cd1314ff03193864 Mon Sep 17 00:00:00 2001 From: Arthur Deierlein Date: Fri, 26 Apr 2024 14:41:40 +0200 Subject: [PATCH 3/4] chore(filters): added type annotations --- timed/employment/filters.py | 29 +++++++++++++++++++++----- timed/projects/filters.py | 23 ++++++++++++++------- timed/reports/filters.py | 23 +++++++++++++++++---- timed/subscription/filters.py | 11 +++++++++- timed/tracking/filters.py | 39 ++++++++++++++++++++++++----------- 5 files changed, 95 insertions(+), 30 deletions(-) diff --git a/timed/employment/filters.py b/timed/employment/filters.py index 30869cfe..39e2b907 100644 --- a/timed/employment/filters.py +++ b/timed/employment/filters.py @@ -1,3 +1,7 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + from django.db.models import Q from django_filters.constants import EMPTY_VALUES from django_filters.rest_framework import DateFilter, Filter, FilterSet, NumberFilter @@ -5,11 +9,18 @@ from timed.employment import models from timed.employment.models import User +if TYPE_CHECKING: + from typing import TypeVar + + from django.db.models import QuerySet + + T = TypeVar("T", QuerySet) + class YearFilter(Filter): """Filter to filter a queryset by year.""" - def filter(self, qs, value): + def filter(self, qs: T, value: int) -> T: if value in EMPTY_VALUES: return qs @@ -54,15 +65,21 @@ class UserFilterSet(FilterSet): is_accountant = NumberFilter(field_name="is_accountant") is_external = NumberFilter(method="filter_is_external") - def filter_is_external(self, queryset, _name, value): + def filter_is_external( + self, queryset: QuerySet[models.User], _name: str, value: int + ) -> QuerySet[models.User]: return queryset.filter(employments__is_external=value) - def filter_is_reviewer(self, queryset, _name, value): + def filter_is_reviewer( + self, queryset: QuerySet[models.User], _name: str, value: int + ) -> QuerySet[models.User]: if value: return queryset.filter(pk__in=User.objects.all_reviewers()) return queryset.exclude(pk__in=User.objects.all_reviewers()) - def filter_is_supervisor(self, queryset, _name, value): + def filter_is_supervisor( + self, queryset: QuerySet[models.User], _name: str, value: int + ) -> QuerySet[models.User]: if value: return queryset.filter(pk__in=User.objects.all_supervisors()) return queryset.exclude(pk__in=User.objects.all_supervisors()) @@ -81,7 +98,9 @@ class Meta: class EmploymentFilterSet(FilterSet): date = DateFilter(method="filter_date") - def filter_date(self, queryset, _name, value): + def filter_date( + self, queryset: QuerySet[models.Employment], _name: str, value: int + ) -> QuerySet[models.Employment]: return queryset.filter( Q(start_date__lte=value) & Q(Q(end_date__gte=value) | Q(end_date__isnull=True)) diff --git a/timed/projects/filters.py b/timed/projects/filters.py index 7c51c605..f1f4ee29 100644 --- a/timed/projects/filters.py +++ b/timed/projects/filters.py @@ -1,6 +1,9 @@ """Filters for filtering the data of the projects app endpoints.""" +from __future__ import annotations + from datetime import date, timedelta +from typing import TYPE_CHECKING from django.db.models import Count, Q from django_filters.constants import EMPTY_VALUES @@ -8,6 +11,9 @@ from timed.projects import models +if TYPE_CHECKING: + from django.db.models import QuerySet + class NumberInFilter(BaseInFilter, NumberFilter): pass @@ -36,7 +42,9 @@ class ProjectFilterSet(FilterSet): has_reviewer = NumberFilter(method="filter_has_reviewer") customer = NumberInFilter(field_name="customer") - def filter_has_manager(self, queryset, _name, value): + def filter_has_manager( + self, queryset: QuerySet[models.Project], _name: str, value: int + ) -> QuerySet[models.Project]: if not value: # pragma: no cover return queryset return queryset.filter( @@ -52,7 +60,9 @@ def filter_has_manager(self, queryset, _name, value): ) ) - def filter_has_reviewer(self, queryset, _name, value): + def filter_has_reviewer( + self, queryset: QuerySet[models.Project], _name: str, value: int + ) -> QuerySet[models.Project]: if not value: # pragma: no cover return queryset return queryset.filter( @@ -88,17 +98,14 @@ class MyMostFrequentTaskFilter(Filter): # would be more desirable to assign an ordering field frecency and to # limit by use paging. This is way harder to implement therefore on hold. - def filter(self, qs, value): + def filter( + self, qs: QuerySet[models.Task], value: int | str | tuple | list | None + ) -> QuerySet[models.Task]: """Filter for given most frequently used tasks. Most frequently used tasks are only counted within last few months as older tasks are not relevant anymore for today's usage. - - :param QuerySet qs: The queryset to filter - :param int value: number of most frequent items - :return: The filtered queryset - :rtype: QuerySet """ if value in EMPTY_VALUES: return qs diff --git a/timed/reports/filters.py b/timed/reports/filters.py index c5547c62..29d8784f 100644 --- a/timed/reports/filters.py +++ b/timed/reports/filters.py @@ -1,3 +1,7 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + from django.db.models import DurationField, F, Q, Sum, Value from django.db.models.functions import Coalesce from django_filters.rest_framework import ( @@ -9,9 +13,16 @@ from timed.projects.models import CustomerAssignee, ProjectAssignee, TaskAssignee +if TYPE_CHECKING: + from django.db.models import QuerySet + + from timed.tracking.models import Report + class StatisticFiltersetBase: - def filter_has_reviewer(self, queryset, _name, value): + def filter_has_reviewer( + self, queryset: QuerySet[Report], _name: str, value: int + ) -> QuerySet[Report]: if not value: # pragma: no cover return queryset @@ -37,7 +48,9 @@ def filter_has_reviewer(self, queryset, _name, value): ) return queryset.filter_aggregate(the_filter).filter_base(the_filter) - def filter_cost_center(self, queryset, _name, value): + def filter_cost_center( + self, queryset: QuerySet[Report], _name: str, value: int + ) -> QuerySet[Report]: """Filter report by cost center. The filter behaves slightly different depending on what the @@ -73,7 +86,7 @@ def filter_cost_center(self, queryset, _name, value): # Project or task: Filter both to get the correct result return queryset.filter_base(filter_q).filter_aggregate(filter_q) - def filter_queryset(self, queryset): + def filter_queryset(self, queryset: QuerySet[Report]) -> QuerySet[Report]: qs = super().filter_queryset(queryset) duration_ref = self._refs["reports_ref"] + "__duration" @@ -88,7 +101,9 @@ def filter_queryset(self, queryset): return full_qs.values() -def statistic_filterset_builder(name, reports_ref, project_ref, customer_ref, task_ref): +def statistic_filterset_builder( + name: str, reports_ref: str, project_ref: str, customer_ref: str, task_ref: str +) -> type[StatisticFiltersetBase, FilterSet]: reports_prefix = f"{reports_ref}__" if reports_ref else "" project_prefix = f"{project_ref}__" if project_ref else "" customer_prefix = f"{customer_ref}__" if customer_ref else "" diff --git a/timed/subscription/filters.py b/timed/subscription/filters.py index b2ccbe54..b2675ad1 100644 --- a/timed/subscription/filters.py +++ b/timed/subscription/filters.py @@ -1,14 +1,23 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + from django_filters import FilterSet, NumberFilter from timed.projects.models import Project from . import models +if TYPE_CHECKING: + from django.db.models import QuerySet + class PackageFilter(FilterSet): customer = NumberFilter(method="filter_customer") - def filter_customer(self, queryset, _name, value): + def filter_customer( + self, queryset: QuerySet[models.Package], _name: str, value: int + ) -> QuerySet[models.Package]: billing_types = Project.objects.filter(customer=value).values("billing_type") return queryset.filter(billing_type__in=billing_types) diff --git a/timed/tracking/filters.py b/timed/tracking/filters.py index a0af6d17..2deb8067 100644 --- a/timed/tracking/filters.py +++ b/timed/tracking/filters.py @@ -1,6 +1,9 @@ """Filters for filtering the data of the tracking app endpoints.""" +from __future__ import annotations + from functools import wraps +from typing import TYPE_CHECKING from django.db.models import Q from django_filters.constants import EMPTY_VALUES @@ -16,8 +19,16 @@ from timed.projects.models import CustomerAssignee, ProjectAssignee, TaskAssignee from timed.tracking import models +if TYPE_CHECKING: + from typing import Callable, TypeVar + + from django.db.models import QuerySet + + T = TypeVar("T") # used for self + G = TypeVar("G", QuerySet) # used for qs -def boolean_filter(func): + +def boolean_filter(func: Callable[[T, G, str], G]) -> Callable[[T, G, bool], G]: """Cast the passed query parameter into a boolean. :param function func: The function to decorate @@ -45,14 +56,12 @@ class ActivityActiveFilter(Filter): """ @boolean_filter - def filter(self, qs, _value): - """Filter the queryset. - - :param QuerySet qs: The queryset to filter - :param bool value: Whether the activities should be active - :return: The filtered queryset - :rtype: QuerySet - """ + def filter( + self, + qs: QuerySet[models.Activity], + _value: bool, # noqa: FBT001 + ) -> QuerySet[models.Activity]: + """Filter the queryset.""" return qs.filter(to_time__exact=None).distinct() @@ -104,7 +113,9 @@ class ReportFilterSet(FilterSet): cost_center = NumberFilter(method="filter_cost_center") rejected = NumberFilter(field_name="rejected") - def filter_has_reviewer(self, queryset, _name, value): + def filter_has_reviewer( + self, queryset: QuerySet[models.Report], _name: str, value: int + ) -> QuerySet[models.Report]: if not value: # pragma: no cover return queryset @@ -158,7 +169,9 @@ def filter_has_reviewer(self, queryset, _name, value): | reports_task_assignee_is_reviewer ) - def filter_editable(self, queryset, _name, value): + def filter_editable( + self, queryset: QuerySet[models.Report], _name: str, value: int + ) -> QuerySet[models.Report]: """Filter reports whether they are editable by current user. When set to `1` filter all results to what is editable by current @@ -203,7 +216,9 @@ def filter_editable(self, queryset, _name, value): return queryset.exclude(editable_filter) - def filter_cost_center(self, queryset, _name, value): + def filter_cost_center( + self, queryset: QuerySet[models.Report], _name: str, value: int + ) -> QuerySet[models.Report]: """Filter report by cost center. Cost center on task has higher priority over project cost From 329d5cfde546d5b42327a2d6a33ebd3c96db2db1 Mon Sep 17 00:00:00 2001 From: Arthur Deierlein Date: Fri, 26 Apr 2024 15:31:35 +0200 Subject: [PATCH 4/4] chore(views): added type annotations --- timed/employment/views.py | 25 ++++++++++++++----------- timed/projects/views.py | 28 ++++++++++++++++------------ timed/reports/views.py | 25 +++++++++++++++++++++---- timed/subscription/views.py | 13 ++++++++++--- timed/tracking/views.py | 26 ++++++++++++-------------- 5 files changed, 73 insertions(+), 44 deletions(-) diff --git a/timed/employment/views.py b/timed/employment/views.py index 9548aae6..c6b6ba48 100644 --- a/timed/employment/views.py +++ b/timed/employment/views.py @@ -1,6 +1,9 @@ """Viewsets for the employment app.""" +from __future__ import annotations + import datetime +from typing import TYPE_CHECKING from django.contrib.auth import get_user_model from django.db.models import CharField, DateField, IntegerField, Q, Value @@ -28,6 +31,9 @@ from timed.projects.models import CustomerAssignee, Task from timed.tracking.models import Absence, Report +if TYPE_CHECKING: + from django.db.models import QuerySet + class UserViewSet(ModelViewSet): """Expose user actions. @@ -57,7 +63,7 @@ class UserViewSet(ModelViewSet): "last_name", ) - def get_queryset(self): + def get_queryset(self) -> QuerySet[models.User]: user = self.request.user queryset = get_user_model().objects.prefetch_related( "employments", "supervisees", "supervisors" @@ -114,7 +120,7 @@ def transfer(self, _request, pk=None): # noqa: ARG002 It will skip any credits if a credit already exists on the first of the new year. """ - user = self.get_object() + user: models.User = self.get_object() year = datetime.date.today().year start_year = datetime.date(year, 1, 1) @@ -325,7 +331,7 @@ class EmploymentViewSet(ModelViewSet): ), ) - def get_queryset(self): + def get_queryset(self) -> QuerySet[models.Employment]: """Get queryset of employments. Following rules apply: @@ -350,7 +356,7 @@ class LocationViewSet(ReadOnlyModelViewSet): serializer_class = serializers.LocationSerializer ordering = ("name",) - def get_queryset(self): + def get_queryset(self) -> QuerySet[models.Location]: """Don't show locations to customers.""" user = self.request.user @@ -368,13 +374,10 @@ class PublicHolidayViewSet(ReadOnlyModelViewSet): filterset_class = filters.PublicHolidayFilterSet ordering = ("date",) - def get_queryset(self): + def get_queryset(self) -> QuerySet[models.PublicHoliday]: """Prefetch the related data. Don't show public holidays to customers. - - :return: The public holidays - :rtype: QuerySet """ user = self.request.user @@ -393,7 +396,7 @@ class AbsenceTypeViewSet(ReadOnlyModelViewSet): filterset_class = filters.AbsenceTypeFilterSet ordering = ("name",) - def get_queryset(self): + def get_queryset(self) -> QuerySet[models.AbsenceType]: """Don't show absence types to customers.""" user = self.request.user @@ -418,7 +421,7 @@ class AbsenceCreditViewSet(ModelViewSet): ), ) - def get_queryset(self): + def get_queryset(self) -> QuerySet[models.AbsenceCredit]: """Get queryset of absence credits. Following rules apply: @@ -450,7 +453,7 @@ class OvertimeCreditViewSet(ModelViewSet): ), ) - def get_queryset(self): + def get_queryset(self) -> QuerySet[models.OvertimeCredit]: """Get queryset of overtime credits. Following rules apply: diff --git a/timed/projects/views.py b/timed/projects/views.py index 99c4b950..ef2f7129 100644 --- a/timed/projects/views.py +++ b/timed/projects/views.py @@ -1,5 +1,9 @@ """Viewsets for the projects app.""" +from __future__ import annotations + +from typing import TYPE_CHECKING + from django.db.models import Q from rest_framework.viewsets import ModelViewSet, ReadOnlyModelViewSet @@ -15,6 +19,9 @@ ) from timed.projects import filters, models, serializers +if TYPE_CHECKING: + from django.db.models import QuerySet + class CustomerViewSet(ReadOnlyModelViewSet): """Customer view set.""" @@ -23,13 +30,10 @@ class CustomerViewSet(ReadOnlyModelViewSet): filterset_class = filters.CustomerFilterSet ordering = ("name",) - def get_queryset(self): + def get_queryset(self) -> QuerySet[models.Customer]: """Prefetch related data. If an employee is external, get only assigned customers. - - :return: The customers - :rtype: QuerySet """ user = self.request.user queryset = models.Customer.objects.prefetch_related("projects") @@ -63,7 +67,7 @@ class BillingTypeViewSet(ReadOnlyModelViewSet): ), ) - def get_queryset(self): + def get_queryset(self) -> QuerySet[models.BillingType] | None: """Get billing types depending on the user's role. Internal employees should see all billing types. @@ -108,7 +112,7 @@ class CostCenterViewSet(ReadOnlyModelViewSet): ), ) - def get_queryset(self): + def get_queryset(self) -> QuerySet[models.CostCenter]: return models.CostCenter.objects.all() @@ -136,7 +140,7 @@ class ProjectViewSet(ModelViewSet): ), ) - def get_queryset(self): + def get_queryset(self) -> QuerySet[models.Project]: """Get only assigned projects, if an employee is external.""" user = self.request.user queryset = ( @@ -180,7 +184,7 @@ class TaskViewSet(ModelViewSet): ), ) - def filter_queryset(self, queryset): + def filter_queryset(self, queryset: QuerySet[models.Task]) -> QuerySet[models.Task]: """Specific filter queryset options.""" # my most frequent filter uses LIMIT so default ordering # needs to be disabled to avoid exception @@ -190,7 +194,7 @@ def filter_queryset(self, queryset): return super().filter_queryset(queryset) - def get_queryset(self): + def get_queryset(self) -> QuerySet[models.Task]: """Get only assigned tasks, if an employee is external.""" user = self.request.user queryset = super().get_queryset().select_related("project", "cost_center") @@ -218,7 +222,7 @@ class TaskAsssigneeViewSet(ReadOnlyModelViewSet): serializer_class = serializers.TaskAssigneeSerializer filterset_class = filters.TaskAssigneeFilterSet - def get_queryset(self): + def get_queryset(self) -> QuerySet[models.TaskAssignee]: """Don't show task assignees to customers.""" user = self.request.user @@ -234,7 +238,7 @@ class ProjectAsssigneeViewSet(ReadOnlyModelViewSet): serializer_class = serializers.ProjectAssigneeSerializer filterset_class = filters.ProjectAssigneeFilterSet - def get_queryset(self): + def get_queryset(self) -> QuerySet[models.ProjectAssignee]: """Don't show project assignees to customers.""" user = self.request.user @@ -250,7 +254,7 @@ class CustomerAsssigneeViewSet(ReadOnlyModelViewSet): serializer_class = serializers.CustomerAssigneeSerializer filterset_class = filters.CustomerAssigneeFilterSet - def get_queryset(self): + def get_queryset(self) -> QuerySet[models.CustomerAssignee]: """Don't show customer assignees to customers.""" user = self.request.user diff --git a/timed/reports/views.py b/timed/reports/views.py index a8c33250..5f724fa7 100644 --- a/timed/reports/views.py +++ b/timed/reports/views.py @@ -1,7 +1,10 @@ +from __future__ import annotations + import re from collections import defaultdict from datetime import date from io import BytesIO +from typing import TYPE_CHECKING from zipfile import ZipFile from django.conf import settings @@ -23,6 +26,13 @@ from . import filters +if TYPE_CHECKING: + from typing import Iterable + + from ezodf.document import FlatXMLDocument, PackagedDocument + + from timed.employment.models import User + class YearStatisticViewSet(AggregateQuerysetMixin, ReadOnlyModelViewSet): """Year statistics calculates total reported time per year.""" @@ -273,7 +283,7 @@ def _parse_query_params(self, queryset, request): form.is_valid() return form.cleaned_data - def _clean_filename(self, name): + def _clean_filename(self, name: str) -> str: """Clean name so it can be used in file paths. To accomplish this it will remove all special chars and @@ -282,7 +292,7 @@ def _clean_filename(self, name): escaped = re.sub(r"[^\w\s-]", "", name) return re.sub(r"\s+", "_", escaped) - def _generate_workreport_name(self, from_date, project): + def _generate_workreport_name(self, from_date: date, project: Project) -> str: """Generate workreport name. Name is in format: YYMM-YYYYMMDD-$Customer-$Project.ods @@ -296,7 +306,14 @@ def _generate_workreport_name(self, from_date, project): self._clean_filename(project.name), ) - def _create_workreport(self, from_date, to_date, project, reports, user): # noqa: PLR0913 + def _create_workreport( # noqa: PLR0913 + self, + from_date: date, + to_date: date, + project: Project, + reports: Iterable[Report], + user: User, + ) -> tuple[str, PackagedDocument | FlatXMLDocument]: """Create ods workreport. :rtype: tuple @@ -312,7 +329,7 @@ def _create_workreport(self, from_date, to_date, project, reports, user): # noq ) tmpl = settings.WORK_REPORT_PATH - doc = opendoc(tmpl) + doc: PackagedDocument | FlatXMLDocument = opendoc(tmpl) table = doc.sheets[0] tasks = defaultdict(int) date_style = table["C5"].style_name diff --git a/timed/subscription/views.py b/timed/subscription/views.py index 1cb08ec4..771e9064 100644 --- a/timed/subscription/views.py +++ b/timed/subscription/views.py @@ -1,3 +1,7 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + from django.db.models import Q from rest_framework import decorators, exceptions, response, status, viewsets from rest_framework_json_api.serializers import ValidationError @@ -16,6 +20,9 @@ from . import filters, models, serializers +if TYPE_CHECKING: + from django.db.models import QuerySet + class SubscriptionProjectViewSet(viewsets.ReadOnlyModelViewSet): """Subscription specific project view. @@ -31,7 +38,7 @@ class SubscriptionProjectViewSet(viewsets.ReadOnlyModelViewSet): "id", ) - def get_queryset(self): + def get_queryset(self) -> QuerySet[Project]: user = self.request.user queryset = Project.objects.filter(archived=False, customer_visible=True) current_employment = user.get_active_employment() @@ -53,7 +60,7 @@ class PackageViewSet(viewsets.ReadOnlyModelViewSet): serializer_class = serializers.PackageSerializer filterset_class = filters.PackageFilter - def get_queryset(self): + def get_queryset(self) -> QuerySet[models.Package]: return models.Package.objects.select_related("billing_type") @@ -111,7 +118,7 @@ def confirm(self, request, pk=None): # noqa: ARG002 return response.Response(status=status.HTTP_204_NO_CONTENT) - def get_queryset(self): + def get_queryset(self) -> QuerySet[models.Order]: return models.Order.objects.select_related("project") def destroy(self, _request, pk=None): # noqa: ARG002 diff --git a/timed/tracking/views.py b/timed/tracking/views.py index 366c0ea7..687734aa 100644 --- a/timed/tracking/views.py +++ b/timed/tracking/views.py @@ -1,6 +1,9 @@ """Viewsets for the tracking app.""" +from __future__ import annotations + from datetime import date +from typing import TYPE_CHECKING import django_excel from django.conf import settings @@ -34,6 +37,9 @@ from . import tasks +if TYPE_CHECKING: + from django.db.models import QuerySet + class ActivityViewSet(ModelViewSet): """Activity view set.""" @@ -50,12 +56,8 @@ class ActivityViewSet(ModelViewSet): ), ) - def get_queryset(self): - """Filter the queryset by the user of the request. - - :return: The filtered activities - :rtype: QuerySet - """ + def get_queryset(self) -> QuerySet[models.Activity]: + """Filter the queryset by the user of the request.""" return models.Activity.objects.select_related( "task", "user", "task__project", "task__project__customer" ).filter(user=self.request.user) @@ -77,12 +79,8 @@ class AttendanceViewSet(ModelViewSet): ), ) - def get_queryset(self): - """Filter the queryset by the user of the request. - - :return: The filtered attendances - :rtype: QuerySet - """ + def get_queryset(self) -> QuerySet[models.Attendance]: + """Filter the queryset by the user of the request.""" return models.Attendance.objects.select_related("user").filter( user=self.request.user ) @@ -128,7 +126,7 @@ class ReportViewSet(ModelViewSet): "rejected", ) - def get_queryset(self): + def get_queryset(self) -> QuerySet[models.Report]: """Get filtered reports for external employees.""" user = self.request.user queryset = super().get_queryset() @@ -375,7 +373,7 @@ class AbsenceViewSet(ModelViewSet): ), ) - def get_queryset(self): + def get_queryset(self) -> QuerySet[models.Absence]: """Get absences only for internal employees. User should be able to create an absence on a public holiday if the