From 81d8cffe83a893d09cf44acbb68615015c04c65b Mon Sep 17 00:00:00 2001 From: David Vogt Date: Fri, 27 Dec 2024 18:43:33 +0100 Subject: [PATCH 1/7] fix(dev): correctly run `dbshell` in db container, not a temporary one The `docker compose run --rm` starts up a new container, which does not have access to the actual database, so the make target didn't actually work --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 3b1a78fa4..c16ec07f5 100644 --- a/Makefile +++ b/Makefile @@ -32,7 +32,7 @@ backend-test: ## Test the backend .PHONY: backend-dbshell backend-dbshell: ## Start a psql shell - @$(ORCHESTRATOR) compose run -it --rm db psql -Utimed timed + @$(ORCHESTRATOR) compose exec -it db psql -Utimed timed .PHONY: shellplus shellplus: ## Run shell_plus From 6b24818df90a011397856d009588aa98a2075a8e Mon Sep 17 00:00:00 2001 From: David Vogt Date: Fri, 27 Dec 2024 15:17:57 +0100 Subject: [PATCH 2/7] chore(test): add test to validate multiple-filter behaviour in task stats --- .../reports/tests/test_task_statistic.py | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/backend/timed/reports/tests/test_task_statistic.py b/backend/timed/reports/tests/test_task_statistic.py index 92de5a0d4..b3bba0065 100644 --- a/backend/timed/reports/tests/test_task_statistic.py +++ b/backend/timed/reports/tests/test_task_statistic.py @@ -138,3 +138,40 @@ def test_task_statistic_filtered( json = result.json() assert json["meta"]["total-time"] == f"{expected_result:02}:00:00" + + +def test_task_statistic_multiple_filters( + auth_client, + setup_customer_and_employment_status, +): + user = auth_client.user + setup_customer_and_employment_status( + user=user, + is_assignee=True, + is_customer=True, + is_employed=True, + is_external=False, + ) + + cost_center = CostCenterFactory() + task_z = TaskFactory.create(name="Z", cost_center=cost_center) + task_test = TaskFactory.create(name="Test") + reviewer = TaskAssigneeFactory(user=UserFactory(), task=task_test, is_reviewer=True) + + ReportFactory.create(duration=timedelta(hours=1), date="2022-08-05", task=task_test) + ReportFactory.create(duration=timedelta(hours=2), date="2022-08-30", task=task_test) + ReportFactory.create(duration=timedelta(hours=4), date="2022-09-01", task=task_z) + + url = reverse("task-statistic-list") + result = auth_client.get( + url, + data={ + "ordering": "name", + "include": "project,project.customer", + "from_date": "2022-08-20", + "reviewer": str(reviewer.user.pk), + }, + ) + assert result.status_code == status.HTTP_200_OK + json = result.json() + assert json["meta"]["total-time"] == "02:00:00" From 133980f0d81a509a18d1c52cda78786d757fad70 Mon Sep 17 00:00:00 2001 From: David Vogt Date: Mon, 23 Dec 2024 14:40:15 +0100 Subject: [PATCH 3/7] chore(tests): improve test by using powers-of-two only This improves the test such that from the resulting sum, we can see exactly which two reports were affected, because every sum of reports will now return a distinct sum (any combination of 1,2, and 4 will return a different result) This was not the case with 1,2, and 3 before. --- backend/timed/reports/tests/test_task_statistic.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/timed/reports/tests/test_task_statistic.py b/backend/timed/reports/tests/test_task_statistic.py index b3bba0065..dd47ee1a2 100644 --- a/backend/timed/reports/tests/test_task_statistic.py +++ b/backend/timed/reports/tests/test_task_statistic.py @@ -94,7 +94,7 @@ def test_task_statistic_list( @pytest.mark.parametrize( ("filter", "expected_result"), - [("from_date", 5), ("customer", 3), ("cost_center", 3), ("reviewer", 3)], + [("from_date", 6), ("customer", 3), ("cost_center", 3), ("reviewer", 3)], ) def test_task_statistic_filtered( auth_client, @@ -118,7 +118,7 @@ def test_task_statistic_filtered( ReportFactory.create(duration=timedelta(hours=1), date="2022-08-05", task=task_test) ReportFactory.create(duration=timedelta(hours=2), date="2022-08-30", task=task_test) - ReportFactory.create(duration=timedelta(hours=3), date="2022-09-01", task=task_z) + ReportFactory.create(duration=timedelta(hours=4), date="2022-09-01", task=task_z) filter_values = { "from_date": "2022-08-20", # last two reports From 41686e839f84aad24c7b5d4d4e921f663c3cd145 Mon Sep 17 00:00:00 2001 From: David Vogt Date: Mon, 23 Dec 2024 14:19:12 +0100 Subject: [PATCH 4/7] fix(statistics): use subquery instead of join to avoid cartesian product The way we filter in the statistics view, any added filter (that affects reports) adds a "dimension" to the cartesian product, exploding the total number of hours reported. Instead of using JOIN, we do EXISTS(SUBQUERY) now, which should avoid this issue. Might be a tiny bit slower, but let's try to make it correct first, then fast. --- backend/timed/reports/views.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/backend/timed/reports/views.py b/backend/timed/reports/views.py index 1821b9cb3..1c01110d4 100644 --- a/backend/timed/reports/views.py +++ b/backend/timed/reports/views.py @@ -8,7 +8,7 @@ from zipfile import ZipFile from django.conf import settings -from django.db.models import F, Q, QuerySet, Sum +from django.db.models import Exists, F, OuterRef, Q, QuerySet, Sum from django.db.models.functions import ExtractMonth, ExtractYear from django.http import HttpResponse from django.utils.http import content_disposition_header @@ -117,9 +117,14 @@ def filter(self, /, **kwargs): return new_qs def filter_base(self, *args, **kwargs): + filtered = ( + self.model.objects.filter(*args, **kwargs) + .values("pk") + .filter(pk=OuterRef("pk")) + ) return StatisticQueryset( model=self.model, - base_qs=self._base.filter(*args, **kwargs), + base_qs=self._base.filter(Exists(filtered)), catch_prefixes=self._catch_prefixes, agg_filters=self._agg_filters, ) From 0a7557712b10ba560566371fe14ede47cd482db6 Mon Sep 17 00:00:00 2001 From: David Vogt Date: Fri, 27 Dec 2024 16:06:56 +0100 Subject: [PATCH 5/7] refactor: use common base statistics queryset mixin for analysis endpoints Note: experimental, will likely not work. WIP commit --- backend/timed/reports/views.py | 187 +++++++++++++++------------------ 1 file changed, 83 insertions(+), 104 deletions(-) diff --git a/backend/timed/reports/views.py b/backend/timed/reports/views.py index 1c01110d4..4032232b7 100644 --- a/backend/timed/reports/views.py +++ b/backend/timed/reports/views.py @@ -35,7 +35,52 @@ from timed.employment.models import User -class YearStatisticViewSet(AggregateQuerysetMixin, ReadOnlyModelViewSet): +class BaseStatisticQuerysetMixin: + """Base statistics queryset mixin. + + Build and filter the statistics queryset according to the following + principles: + + 0) For every statistic view (year, month, customer, project, task, user) + we use the same basic queryset and the same filterset. + 1) Build up a full queryset with annotations and everything we need + from a *task* perspective. + 2) Filter the queryset in the sxact same way in all the viewsets. + 3) Annotate the queryset in the viewset, according to their needs. This will + also cause the GROUP BY to happen as needed. + + For this to work, each viewset defines two properties: + + * The `qs_fields` define which fields are to be selected + * The `pk_field` is an expression that will be used as a primary key in the + REST sense (not really related to the database primary key, but serves as + a row identifier) + + And because we use the report queryset as our base, we can easily reuse + the report filterset as well. + """ + + def get_queryset(self): + return ( + Report.objects.all() + .select_related("user", "task", "task__project", "task__project__customer") + .annotate(year=ExtractYear("date")) + .annotate(month=ExtractYear("date") * 100 + ExtractMonth("date")) + ) + + def filter_queryset(self, queryset): + queryset = super().filter_queryset(queryset) + if isinstance(self.qs_fields, dict): + # qs fields need to be aliased + queryset = queryset.annotate(**self.qs_fields) + + queryset = queryset.values(*list(self.qs_fields)) + queryset = queryset.annotate(duration=Sum("duration")) + queryset = queryset.annotate(pk=F(self.pk_field)) + return queryset + + +class YearStatisticViewSet(BaseStatisticQuerysetMixin, ReadOnlyModelViewSet): """Year statistics calculates total reported time per year.""" serializer_class = serializers.YearStatisticSerializer @@ -52,14 +97,11 @@ class YearStatisticViewSet(AggregateQuerysetMixin, ReadOnlyModelViewSet): ), ) - def get_queryset(self): - queryset = Report.objects.all() - queryset = queryset.annotate(year=ExtractYear("date")).values("year") - queryset = queryset.annotate(duration=Sum("duration")) - return queryset.annotate(pk=F("year")) + qs_fields = ("year", "duration") + pk_field = "year" -class MonthStatisticViewSet(AggregateQuerysetMixin, ReadOnlyModelViewSet): +class MonthStatisticViewSet(BaseStatisticQuerysetMixin, ReadOnlyModelViewSet): """Month statistics calculates total reported time per month.""" serializer_class = serializers.MonthStatisticSerializer @@ -80,89 +122,17 @@ class MonthStatisticViewSet(AggregateQuerysetMixin, ReadOnlyModelViewSet): ), ) - def get_queryset(self): - queryset = Report.objects.all() - queryset = queryset.annotate( - year=ExtractYear("date"), month=ExtractMonth("date") - ) - queryset = queryset.values("year", "month") - queryset = queryset.annotate(duration=Sum("duration")) - return queryset.annotate(pk=F("year") * 100 + F("month")) - - -class StatisticQueryset(QuerySet): - def __init__(self, catch_prefixes, *args, base_qs=None, agg_filters=None, **kwargs): - super().__init__(*args, **kwargs) - if base_qs is None: - base_qs = self.model.objects.all() - self._base = base_qs - self._agg_filters = agg_filters - self._catch_prefixes = catch_prefixes - - def filter(self, /, **kwargs): - my_filters = { - k: v for k, v in kwargs.items() if not k.startswith(self._catch_prefixes) - } - - agg_filters = { - k: v for k, v in kwargs.items() if k.startswith(self._catch_prefixes) - } - - new_qs = self - if my_filters: - new_qs = self.filter_base(**my_filters) - if agg_filters: - new_qs = new_qs.filter_aggregate(**agg_filters) - - return new_qs - - def filter_base(self, *args, **kwargs): - filtered = ( - self.model.objects.filter(*args, **kwargs) - .values("pk") - .filter(pk=OuterRef("pk")) - ) - return StatisticQueryset( - model=self.model, - base_qs=self._base.filter(Exists(filtered)), - catch_prefixes=self._catch_prefixes, - agg_filters=self._agg_filters, - ) - - def _clone(self): - return StatisticQueryset( - model=self.model, - base_qs=self._base._clone(), # noqa: SLF001 - catch_prefixes=self._catch_prefixes, - agg_filters=self._agg_filters, - ) - - def __str__(self) -> str: - return f"StatisticQueryset({self._base!s} | {self._agg_filters!s})" - - def __repr__(self) -> str: - return f"StatisticQueryset({self._base!r} | {self._agg_filters!r})" + qs_fields = ("year", "month", "duration") + pk_field = "month" - def filter_aggregate(self, *args, **kwargs): - filter_q = Q(*args, **kwargs) - new_filters = self._agg_filters & filter_q if self._agg_filters else filter_q - - return StatisticQueryset( - model=self.model, - base_qs=self._base, - catch_prefixes=self._catch_prefixes, - agg_filters=new_filters, - ) - - -class CustomerStatisticViewSet(AggregateQuerysetMixin, ReadOnlyModelViewSet): +class CustomerStatisticViewSet(BaseStatisticQuerysetMixin, ReadOnlyModelViewSet): """Customer statistics calculates total reported time per customer.""" serializer_class = serializers.CustomerStatisticSerializer - filterset_class = filters.CustomerStatisticFilterSet + filterset_class = ReportFilterSet ordering_fields = ( - "name", + "task__project__customer__name", "duration", "estimated_time", "remaining_effort", @@ -175,23 +145,27 @@ class CustomerStatisticViewSet(AggregateQuerysetMixin, ReadOnlyModelViewSet): (IsInternal | IsSuperUser) & IsAuthenticated ), ) - - def get_queryset(self): - return StatisticQueryset(model=Customer, catch_prefixes="projects__") + qs_fields = { # noqa: RUF012 + "year": F("year"), + "month": F("month"), + "name": F("task__project__customer__name"), + "customer_id": F("task__project__customer_id"), + } + pk_field = "customer_id" class ProjectStatisticViewSet(AggregateQuerysetMixin, ReadOnlyModelViewSet): """Project statistics calculates total reported time per project.""" serializer_class = serializers.ProjectStatisticSerializer - filterset_class = filters.ProjectStatisticFilterSet + filterset_class = ReportFilterSet ordering_fields = ( - "name", + "task__project__name", "duration", "estimated_time", "remaining_effort", ) - ordering = ("name",) + ordering = ("task__project__name",) permission_classes = ( ( # internal employees or super users may read all customer statistics @@ -199,22 +173,27 @@ class ProjectStatisticViewSet(AggregateQuerysetMixin, ReadOnlyModelViewSet): ), ) - def get_queryset(self): - return StatisticQueryset(model=Project, catch_prefixes="tasks__") + qs_fields = { # noqa: RUF012 + "year": F("year"), + "month": F("month"), + "name": F("task__project__name"), + "project_id": F("task__project_id"), + } + pk_field = "project_id" -class TaskStatisticViewSet(AggregateQuerysetMixin, ReadOnlyModelViewSet): +class TaskStatisticViewSet(BaseStatisticQuerysetMixin, ReadOnlyModelViewSet): """Task statistics calculates total reported time per task.""" serializer_class = serializers.TaskStatisticSerializer - filterset_class = filters.TaskStatisticFilterSet + filterset_class = ReportFilterSet ordering_fields = ( - "name", + "task__name", "duration", "estimated_time", "remaining_effort", ) - ordering = ("name",) + ordering = ("task__name",) permission_classes = ( ( # internal employees or super users may read all customer statistics @@ -222,11 +201,15 @@ class TaskStatisticViewSet(AggregateQuerysetMixin, ReadOnlyModelViewSet): ), ) - def get_queryset(self): - return StatisticQueryset(model=Task, catch_prefixes="tasks__") + qs_fields = { # noqa: RUF012 + "year": F("year"), + "month": F("month"), + "name": F("task__name"), + } + pk_field = "task_id" -class UserStatisticViewSet(AggregateQuerysetMixin, ReadOnlyModelViewSet): +class UserStatisticViewSet(BaseStatisticQuerysetMixin, ReadOnlyModelViewSet): """User calculates total reported time per user.""" serializer_class = serializers.UserStatisticSerializer @@ -243,11 +226,7 @@ class UserStatisticViewSet(AggregateQuerysetMixin, ReadOnlyModelViewSet): ), ) - def get_queryset(self): - queryset = Report.objects.all() - queryset = queryset.values("user") - queryset = queryset.annotate(duration=Sum("duration")) - return queryset.annotate(pk=F("user")) + pk_field = "user" class WorkReportViewSet(GenericViewSet): From 502540fbf285c4645cda9eac0944f8fb638df942 Mon Sep 17 00:00:00 2001 From: David Vogt Date: Fri, 27 Dec 2024 18:39:06 +0100 Subject: [PATCH 6/7] refactor(statistics): introduce a statistics base model based on SQL VIEW This is still WIP, but I think this is the right way forward. TODO: * Further refactor the views, as the customer,project,task viewsets should return all the selected objects, regardless of reported sum. I think we will need to base those viewsets off of their respective models and join/prefetch the report statistic onto it * Finalize the rest of the refactoring to make it work again Optionally (and I hope it's not necessary): Build a statistic (database) view for all statistics endpoints. This would allow us to optimize the whole thing much more (and improve performance AND readability) at the cost of quite some more boilerplate code --- backend/timed/reports/filters.py | 203 +++++------------- .../0001_initial_create_analysis_view.py | 61 ++++++ backend/timed/reports/migrations/__init__.py | 0 backend/timed/reports/models.py | 34 +++ backend/timed/reports/serializers.py | 28 ++- .../reports/tests/test_customer_statistic.py | 18 +- backend/timed/reports/views.py | 133 +++++++----- backend/timed/serializers.py | 1 + backend/timed/tracking/filters.py | 35 +-- 9 files changed, 281 insertions(+), 232 deletions(-) create mode 100644 backend/timed/reports/migrations/0001_initial_create_analysis_view.py create mode 100644 backend/timed/reports/migrations/__init__.py create mode 100644 backend/timed/reports/models.py diff --git a/backend/timed/reports/filters.py b/backend/timed/reports/filters.py index 29d8784f4..2c466b83a 100644 --- a/backend/timed/reports/filters.py +++ b/backend/timed/reports/filters.py @@ -1,175 +1,76 @@ from __future__ import annotations -from typing import TYPE_CHECKING +from datetime import timedelta -from django.db.models import DurationField, F, Q, Sum, Value +from django.db.models import Case, Exists, F, OuterRef, Q, QuerySet, Sum, Value, When from django.db.models.functions import Coalesce +from django_filters import utils from django_filters.rest_framework import ( - BaseInFilter, + CharFilter, DateFilter, + DjangoFilterBackend, FilterSet, NumberFilter, ) -from timed.projects.models import CustomerAssignee, ProjectAssignee, TaskAssignee +from timed.projects.filters import TaskFilterSet +from timed.tracking.filters import CostCenterFilter, ReportFilterSet -if TYPE_CHECKING: - from django.db.models import QuerySet - from timed.tracking.models import Report +class NOOPFilter(CharFilter): + def filter(self, qs, value): + return qs -class StatisticFiltersetBase: - def filter_has_reviewer( - self, queryset: QuerySet[Report], _name: str, value: int - ) -> QuerySet[Report]: - if not value: # pragma: no cover - return queryset +class ReportStatisticFilterset(FilterSet): + user = NumberFilter(field_name="user") + customer = NumberFilter(field_name="customer_id") + from_date = DateFilter(field_name="date", lookup_expr="gte") + to_date = DateFilter(field_name="date", lookup_expr="lte") + cost_center = CostCenterFilter(task_prefix="task") + reviewer = NumberFilter(method="filter_reviewer") - task_prefix = self._refs["task_prefix"] - project_prefix = self._refs["project_prefix"] - customer_prefix = self._refs["customer_prefix"] + def filter_reviewer(self, qs, name, value): + return ReportFilterSet.filter_has_reviewer(self, qs, name, value) - customer_assignees = CustomerAssignee.objects.filter( - is_reviewer=True, user_id=value - ).values("customer_id") - project_assignees = ProjectAssignee.objects.filter( - is_reviewer=True, user_id=value - ).values("project_id") - task_assignees = TaskAssignee.objects.filter( - is_reviewer=True, user_id=value - ).values("task_id") +class OrgReportFilterset(FilterSet): + """Base class for organisational filtersets (customer, project, task). - the_filter = ( - Q(**{f"{customer_prefix}pk__in": customer_assignees}) - | Q(**{f"{project_prefix}pk__in": project_assignees}) - | Q(**{f"{task_prefix}id__in": task_assignees}) - ) - return queryset.filter_aggregate(the_filter).filter_base(the_filter) - - 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 - statistic summarizes: - * When viewing the statistic over customers, the work durations are - filtered (either project or task) - * When viewing the statistic over project, only the projects - are filtered - * When viewing the statistic over tasks, only the tasks - are filtered - """ - # TODO: Discuss Is this the desired behaviour by our users? - - if not value: # pragma: no cover - return queryset - - is_customer = not self._refs["customer_prefix"] - - task_prefix = self._refs["task_prefix"] - project_prefix = self._refs["project_prefix"] - - filter_q = Q(**{f"{task_prefix}cost_center": value}) | Q( - **{ - f"{project_prefix}cost_center": value, - f"{task_prefix}cost_center__isnull": True, - } - ) + These have in common that none of the report-specific things can be + filtered, so instead of copy-pasting noop filters everywhere, we collect + them here + """ - if is_customer: - # Customer mode: We only need to filter aggregates, - # as the customer has no cost center - return queryset.filter_aggregate(filter_q) - # Project or task: Filter both to get the correct result - return queryset.filter_base(filter_q).filter_aggregate(filter_q) + user = NOOPFilter() + from_date = NOOPFilter() + to_date = NOOPFilter() + cost_center = NOOPFilter() + reviewer = NOOPFilter() - def filter_queryset(self, queryset: QuerySet[Report]) -> QuerySet[Report]: - qs = super().filter_queryset(queryset) - duration_ref = self._refs["reports_ref"] + "__duration" +class CustomerReportStatisticFilterSet(OrgReportFilterset): + customer = NumberFilter(field_name="pk") - full_qs = qs._base.annotate( # noqa: SLF001 - duration=Coalesce( - Sum(duration_ref, filter=qs._agg_filters), # noqa: SLF001 - Value("00:00:00", DurationField(null=False)), - ), - pk=F("id"), - ) - return full_qs.values() - - -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 "" - task_prefix = f"{task_ref}__" if task_ref else "" - - return type( - name, - (StatisticFiltersetBase, FilterSet), - { - "_refs": { - "reports_prefix": reports_prefix, - "project_prefix": project_prefix, - "customer_prefix": customer_prefix, - "task_prefix": task_prefix, - "reports_ref": reports_ref, - "project_ref": project_ref, - "customer_ref": customer_ref, - "task_ref": task_ref, - }, - "from_date": DateFilter( - field_name=f"{reports_prefix}date", lookup_expr="gte" - ), - "to_date": DateFilter( - field_name=f"{reports_prefix}date", lookup_expr="lte" - ), - "project": NumberFilter(field_name=f"{project_prefix}pk"), - "customer": NumberFilter(field_name=f"{customer_prefix}pk"), - "review": NumberFilter(field_name=f"{reports_prefix}review"), - "not_billable": NumberFilter(field_name=f"{reports_prefix}not_billable"), - "billed": NumberFilter(field_name=f"{reports_prefix}billed"), - "verified": NumberFilter( - field_name=f"{reports_prefix}verified_by_id", - lookup_expr="isnull", - exclude=True, - ), - "verifier": NumberFilter(field_name=f"{reports_prefix}verified_by"), - "billing_type": NumberFilter(field_name=f"{project_prefix}billing_type"), - "user": NumberFilter(field_name=f"{reports_prefix}user_id"), - "rejected": NumberFilter(field_name=f"{reports_prefix}rejected"), - "id": BaseInFilter(), - "cost_center": NumberFilter(method="filter_cost_center"), - "reviewer": NumberFilter(method="filter_has_reviewer"), - }, - ) - - -CustomerStatisticFilterSet = statistic_filterset_builder( - "CustomerStatisticFilterSet", - reports_ref="projects__tasks__reports", - project_ref="projects", - task_ref="projects__tasks", - customer_ref="", -) -ProjectStatisticFilterSet = statistic_filterset_builder( - "ProjectStatisticFilterSet", - reports_ref="tasks__reports", - project_ref="", - task_ref="tasks", - customer_ref="customer", -) +class StatisticSecondaryFilterBackend(DjangoFilterBackend): + # Special statistic filter backend. Turn -TaskStatisticFilterSet = statistic_filterset_builder( - "TaskStatisticFilterSet", - reports_ref="reports", - project_ref="project", - task_ref="", - customer_ref="project__customer", -) + def get_filterset_class(self, view, queryset=None): + return view.secondary_filterset_class + + def filter_queryset(self, request, queryset, view): + secondary_filterset = self.get_filterset(request, view.secondary_queryset, view) + + if not secondary_filterset.is_valid() and self.raise_exception: + raise utils.translate_validation(secondary_filterset.errors) + + secondary_qs = secondary_filterset.qs + + queryset = ( + queryset.filter(**{view.secondary_link_field: OuterRef("pk")}) + .values(view.secondary_link_field) + .annotate(duration=Sum("duration")) + ) + + return secondary_qs.annotate(duration=Sum(queryset.values("duration"))) diff --git a/backend/timed/reports/migrations/0001_initial_create_analysis_view.py b/backend/timed/reports/migrations/0001_initial_create_analysis_view.py new file mode 100644 index 000000000..bbdfe485b --- /dev/null +++ b/backend/timed/reports/migrations/0001_initial_create_analysis_view.py @@ -0,0 +1,61 @@ +from django.db import migrations, models + +CREATE_VIEW_SQL = """ + CREATE VIEW "reports_reportstatistic" AS ( + SELECT + projects_task.id as id, -- avoid jumping through hoops for Django + projects_task.id as task_id, + user_id, + + projects_project.id as project_id, + projects_project.customer_id as customer_id, + + sum(coalesce(duration, '00:00:00')) as duration, + + date, + extract('year' from date) as year, + extract('month' from date) as month, + (extract('year' from date) * 100 + extract('month' from date)) + as full_month + + FROM projects_task + LEFT JOIN tracking_report ON projects_task.id = tracking_report.task_id + JOIN projects_project ON projects_project.id = projects_task.project_id + + GROUP BY + projects_task.id, + user_id, + date, + projects_task.id, + projects_project.id, + projects_project.customer_id + ) +""" + +DROP_VIEW_SQL = """ + DROP VIEW "reports_reportstatistic" +""" + + +class Migration(migrations.Migration): + + dependencies = [ + ('tracking', '0001_initial') + ] + + operations = [ + migrations.RunSQL(CREATE_VIEW_SQL, DROP_VIEW_SQL), + migrations.CreateModel( + name='reportstatistic', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('duration', models.DurationField()), + ('month', models.IntegerField()), + ('full_month', models.CharField(max_length=7)), + ('year', models.CharField(max_length=4)), + ], + options={ + 'managed': False, + }, + ), + ] diff --git a/backend/timed/reports/migrations/__init__.py b/backend/timed/reports/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/backend/timed/reports/models.py b/backend/timed/reports/models.py new file mode 100644 index 000000000..e82d49f1b --- /dev/null +++ b/backend/timed/reports/models.py @@ -0,0 +1,34 @@ +from django.db import models + +from timed.employment.models import User +from timed.projects.models import Customer, Project, Task + + +class ReportStatistic(models.Model): + # Implement a basic STAR SCHEMA view: Basic aggregate is done in the VIEW, + # everything else of interest can be JOINed directly + + task = models.OneToOneField( + Task, on_delete=models.DO_NOTHING, null=True, related_name="+" + ) + user = models.ForeignKey( + User, on_delete=models.DO_NOTHING, null=True, related_name="+" + ) + project = models.ForeignKey( + Project, on_delete=models.DO_NOTHING, null=False, related_name="+" + ) + customer = models.ForeignKey( + Customer, on_delete=models.DO_NOTHING, null=False, related_name="+" + ) + duration = models.DurationField() + + month = models.IntegerField() + full_month = models.CharField(max_length=7) + year = models.IntegerField() + date = models.DateField() + + class Meta: + managed = False + + def __str__(self): + return f"ReportStat({self.task} @ {self.date}, {self.user})" diff --git a/backend/timed/reports/serializers.py b/backend/timed/reports/serializers.py index a10786ede..bb7fefeeb 100644 --- a/backend/timed/reports/serializers.py +++ b/backend/timed/reports/serializers.py @@ -2,6 +2,7 @@ from __future__ import annotations +from datetime import timedelta from typing import TYPE_CHECKING from django.contrib.auth import get_user_model @@ -21,8 +22,25 @@ from typing import ClassVar +class ReportDurationField(DurationField): + """Custom duration field, to turn None into 00:00:00. + + We don't wanna complicate the query any more than neccessary, and + cast the NULL durations into something else on DB level. Let's just + do it at deserialisation time instead. + """ + + # TODO: this shouldn't be needed, for total_time calc we still need + # the *numbers* in the QS + + def to_representation(self, value): + if value is None: + value = timedelta(seconds=0) + return super().to_representation(value) + + class YearStatisticSerializer(TotalTimeRootMetaMixin, Serializer): - duration = DurationField() + duration = DurationField(read_only=True) year = IntegerField() class Meta: @@ -30,7 +48,7 @@ class Meta: class MonthStatisticSerializer(TotalTimeRootMetaMixin, Serializer): - duration = DurationField() + duration = DurationField(read_only=True) year = IntegerField() month = IntegerField() @@ -39,7 +57,7 @@ class Meta: class CustomerStatisticSerializer(TotalTimeRootMetaMixin, Serializer): - duration = DurationField() + duration = DurationField(read_only=True) name = CharField(read_only=True) class Meta: @@ -47,8 +65,8 @@ class Meta: class ProjectStatisticSerializer(TotalTimeRootMetaMixin, Serializer): - duration = DurationField() - name = CharField() + duration = DurationField(read_only=True) + name = CharField(source="project__name") amount_offered = DecimalField(max_digits=None, decimal_places=2) amount_offered_currency = CharField() amount_invoiced = DecimalField(max_digits=None, decimal_places=2) diff --git a/backend/timed/reports/tests/test_customer_statistic.py b/backend/timed/reports/tests/test_customer_statistic.py index 52cd4fcd7..4186bd064 100644 --- a/backend/timed/reports/tests/test_customer_statistic.py +++ b/backend/timed/reports/tests/test_customer_statistic.py @@ -10,13 +10,19 @@ @pytest.mark.parametrize( - ("is_employed", "is_customer_assignee", "is_customer", "expected", "status_code"), + ( + "is_employed", + "is_customer_assignee", + "is_customer", + "num_queries", + "status_code", + ), [ (False, True, False, 1, status.HTTP_403_FORBIDDEN), (False, True, True, 1, status.HTTP_403_FORBIDDEN), - (True, False, False, 3, status.HTTP_200_OK), - (True, True, False, 3, status.HTTP_200_OK), - (True, True, True, 3, status.HTTP_200_OK), + (True, False, False, 5, status.HTTP_200_OK), + (True, True, False, 5, status.HTTP_200_OK), + (True, True, True, 5, status.HTTP_200_OK), ], ) def test_customer_statistic_list( @@ -24,7 +30,7 @@ def test_customer_statistic_list( is_employed, is_customer_assignee, is_customer, - expected, + num_queries, status_code, django_assert_num_queries, setup_customer_and_employment_status, @@ -49,7 +55,7 @@ def test_customer_statistic_list( report2 = ReportFactory.create(duration=timedelta(hours=4)) url = reverse("customer-statistic-list") - with django_assert_num_queries(expected): + with django_assert_num_queries(num_queries): result = auth_client.get(url, data={"ordering": "duration"}) assert result.status_code == status_code diff --git a/backend/timed/reports/views.py b/backend/timed/reports/views.py index 4032232b7..6537b201c 100644 --- a/backend/timed/reports/views.py +++ b/backend/timed/reports/views.py @@ -1,14 +1,15 @@ from __future__ import annotations +from django_filters.rest_framework import DjangoFilterBackend import re from collections import defaultdict -from datetime import date +from datetime import date, timedelta from io import BytesIO from typing import TYPE_CHECKING from zipfile import ZipFile from django.conf import settings -from django.db.models import Exists, F, OuterRef, Q, QuerySet, Sum +from django.db.models import Exists, F, OuterRef, Q, QuerySet, Sum, Value, When, Case from django.db.models.functions import ExtractMonth, ExtractYear from django.http import HttpResponse from django.utils.http import content_disposition_header @@ -20,7 +21,9 @@ from timed.mixins import AggregateQuerysetMixin from timed.permissions import IsAuthenticated, IsInternal, IsSuperUser from timed.projects.models import Customer, Project, Task +from timed.projects import filters as project_filters from timed.reports import serializers +from timed.reports.models import ReportStatistic from timed.tracking.filters import ReportFilterSet from timed.tracking.models import Report from timed.tracking.views import ReportViewSet @@ -35,7 +38,7 @@ from timed.employment.models import User -class BaseStatisticQuerysetMixin: +class BaseStatisticQuerysetMixin(AggregateQuerysetMixin): """Base statistics queryset mixin. Build and filter the statistics queryset according to the following @@ -51,7 +54,7 @@ class BaseStatisticQuerysetMixin: For this to work, each viewset defines two properties: - * The `qs_fields` define which fields are to be selected + * The `select_fields` is a list of fields to select (and implicitly GROUP BY) * The `pk_field` is an expression that will be used as a primary key in the REST sense (not really related to the database primary key, but serves as a row identifier) @@ -61,30 +64,40 @@ class BaseStatisticQuerysetMixin: """ def get_queryset(self): - return ( - Report.objects.all() - .select_related("user", "task", "task__project", "task__project__customer") - .annotate(year=ExtractYear("date")) - .annotate(month=ExtractYear("date") * 100 + ExtractMonth("date")) - ) + # TODO we're doing select_related() here, which makes a JOIN inside + # the VIEW superfluous. Refactor this to drop it in the VIEW and join + # normally here - should be slightly faster. But "first make it correct" + return ReportStatistic.objects.all() def filter_queryset(self, queryset): queryset = super().filter_queryset(queryset) - if isinstance(self.qs_fields, dict): - # qs fields need to be aliased - queryset = queryset.annotate(**self.qs_fields) + old_sql = str(queryset.query) + # invariant + count_before = queryset.count() + + queryset = queryset.values(*self.select_fields) + queryset = queryset.annotate( + duration=Sum( + Case( + When(duration__isnull=True, then=Value(timedelta(seconds=0))), + default=F("duration"), + ), + ), + pk=F(self.pk_field), + ) - queryset = queryset.values(*list(self.qs_fields)) - queryset = queryset.annotate(duration=Sum("duration")) - queryset = queryset.annotate(pk=F(self.pk_field)) - return queryset + count_after = queryset.count() + assert ( + count_after == count_before + ), f"qs size changed! old sql: \n{old_sql}\n\nnew sql: \n{queryset.query}" + return queryset # noqa: RET504 class YearStatisticViewSet(BaseStatisticQuerysetMixin, ReadOnlyModelViewSet): """Year statistics calculates total reported time per year.""" serializer_class = serializers.YearStatisticSerializer - filterset_class = ReportFilterSet + filterset_class = filters.ReportStatisticFilterset ordering_fields = ( "year", "duration", @@ -97,15 +110,15 @@ class YearStatisticViewSet(BaseStatisticQuerysetMixin, ReadOnlyModelViewSet): ), ) - qs_fields = ("year", "duration") pk_field = "year" + select_fields = ("year",) class MonthStatisticViewSet(BaseStatisticQuerysetMixin, ReadOnlyModelViewSet): """Month statistics calculates total reported time per month.""" serializer_class = serializers.MonthStatisticSerializer - filterset_class = ReportFilterSet + filterset_class = filters.ReportStatisticFilterset ordering_fields = ( "year", "month", @@ -122,45 +135,54 @@ class MonthStatisticViewSet(BaseStatisticQuerysetMixin, ReadOnlyModelViewSet): ), ) - qs_fields = ("year", "month", "duration") - pk_field = "month" + queryset = ReportStatistic.objects + pk_field = "full_month" + select_fields = ("full_month", "month", "year") -class CustomerStatisticViewSet(BaseStatisticQuerysetMixin, ReadOnlyModelViewSet): + +class CustomerStatisticViewSet(ReadOnlyModelViewSet): """Customer statistics calculates total reported time per customer.""" serializer_class = serializers.CustomerStatisticSerializer - filterset_class = ReportFilterSet + filterset_class = filters.ReportStatisticFilterset ordering_fields = ( - "task__project__customer__name", + "customer__name", "duration", - "estimated_time", - "remaining_effort", ) + filter_backends = ( + DjangoFilterBackend, + filters.StatisticSecondaryFilterBackend, + # TODO: rest_framework.filters.OrderingFilter + ) + queryset = ReportStatistic.objects + secondary_link_field = "customer_id" + secondary_queryset = Customer.objects.all().prefetch_related( + "projects", "projects__tasks" + ) + secondary_filterset_class = filters.CustomerReportStatisticFilterSet - ordering = ("name",) + ordering = ("customer__name",) permission_classes = ( ( # internal employees or super users may read all customer statistics (IsInternal | IsSuperUser) & IsAuthenticated ), ) - qs_fields = { # noqa: RUF012 - "year": F("year"), - "month": F("month"), - "name": F("task__project__customer__name"), - "customer_id": F("task__project__customer_id"), - } - pk_field = "customer_id" -class ProjectStatisticViewSet(AggregateQuerysetMixin, ReadOnlyModelViewSet): +class ProjectStatisticViewSet(BaseStatisticQuerysetMixin, ReadOnlyModelViewSet): """Project statistics calculates total reported time per project.""" serializer_class = serializers.ProjectStatisticSerializer - filterset_class = ReportFilterSet + filterset_class = filters.ReportStatisticFilterset + filter_backends = ( + DjangoFilterBackend, + filters.StatisticSecondaryFilterBackend, + # TODO: rest_framework.filters.OrderingFilter + ) ordering_fields = ( - "task__project__name", + "project__name", "duration", "estimated_time", "remaining_effort", @@ -173,20 +195,20 @@ class ProjectStatisticViewSet(AggregateQuerysetMixin, ReadOnlyModelViewSet): ), ) - qs_fields = { # noqa: RUF012 - "year": F("year"), - "month": F("month"), - "name": F("task__project__name"), - "project_id": F("task__project_id"), - } pk_field = "project_id" + select_fields = ( + "customer__name", + "project__name", + "task__estimated_time", + "task__remaining_effort", + ) class TaskStatisticViewSet(BaseStatisticQuerysetMixin, ReadOnlyModelViewSet): """Task statistics calculates total reported time per task.""" serializer_class = serializers.TaskStatisticSerializer - filterset_class = ReportFilterSet + filterset_class = filters.ReportStatisticFilterset ordering_fields = ( "task__name", "duration", @@ -195,25 +217,23 @@ class TaskStatisticViewSet(BaseStatisticQuerysetMixin, ReadOnlyModelViewSet): ) ordering = ("task__name",) permission_classes = ( - ( - # internal employees or super users may read all customer statistics - (IsInternal | IsSuperUser) & IsAuthenticated - ), + # internal employees or super users may read all customer statistics + ((IsInternal | IsSuperUser) & IsAuthenticated), ) - qs_fields = { # noqa: RUF012 - "year": F("year"), - "month": F("month"), - "name": F("task__name"), - } pk_field = "task_id" + select_fields = ( + "task__name", + "task__estimated_time", + "task__remaining_effort", + ) class UserStatisticViewSet(BaseStatisticQuerysetMixin, ReadOnlyModelViewSet): """User calculates total reported time per user.""" serializer_class = serializers.UserStatisticSerializer - filterset_class = ReportFilterSet + filterset_class = filters.ReportStatisticFilterset ordering_fields = ( "user__username", "duration", @@ -227,6 +247,7 @@ class UserStatisticViewSet(BaseStatisticQuerysetMixin, ReadOnlyModelViewSet): ) pk_field = "user" + select_fields = ("user__username",) class WorkReportViewSet(GenericViewSet): @@ -236,7 +257,7 @@ class WorkReportViewSet(GenericViewSet): in several projects work reports will be returned as zip. """ - filterset_class = ReportFilterSet + filterset_class = filters.ReportStatisticFilterset ordering = ReportViewSet.ordering ordering_fields = ReportViewSet.ordering_fields diff --git a/backend/timed/serializers.py b/backend/timed/serializers.py index 28a255630..8da999926 100644 --- a/backend/timed/serializers.py +++ b/backend/timed/serializers.py @@ -12,6 +12,7 @@ def get_root_meta(self, _resource, many): if many: view = self.context["view"] queryset = view.filter_queryset(view.get_queryset()) + breakpoint() data = queryset.aggregate(total_time=Sum(self.duration_field)) data["total_time"] = duration_string(data["total_time"] or timedelta(0)) return data diff --git a/backend/timed/tracking/filters.py b/backend/timed/tracking/filters.py index 58fa47ae1..d00e15d98 100644 --- a/backend/timed/tracking/filters.py +++ b/backend/timed/tracking/filters.py @@ -93,6 +93,26 @@ class Meta: fields = ("date",) +class CostCenterFilter(NumberFilter): + def __init__(self, *, task_prefix, **kwargs): + super().__init__(**kwargs) + self._task_prefix = task_prefix + + def filter(self, qs: QuerySet, value: int) -> QuerySet: + """Filter report by cost center. + + Cost center on task has higher priority over project cost + center. + """ + prefix = f"{self._task_prefix}__" if self._task_prefix else "" + return qs.filter( + Q(**{f"{prefix}cost_center": value}) + | Q(**{f"{prefix}project__cost_center": value}) + & Q(**{f"{prefix}cost_center__isnull": True}) + ) + return super().filter(qs, value) + + class ReportFilterSet(FilterSet): """Filter set for the reports endpoint.""" @@ -112,7 +132,7 @@ class ReportFilterSet(FilterSet): verifier = NumberFilter(field_name="verified_by") billing_type = NumberFilter(field_name="task__project__billing_type") user = NumberFilter(field_name="user_id") - cost_center = NumberFilter(method="filter_cost_center") + cost_center = CostCenterFilter(task_prefix="task") rejected = NumberFilter(field_name="rejected") comment = CharFilter(method="filter_comment") @@ -219,19 +239,6 @@ def filter_editable( return queryset.exclude(editable_filter) - 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 - center. - """ - return queryset.filter( - Q(task__cost_center=value) - | Q(task__project__cost_center=value) & Q(task__cost_center__isnull=True) - ) - def filter_comment( self, queryset: QuerySet[models.Report], _name: str, value: str ) -> QuerySet[models.Report]: From 18d29698548bd4d9ca6b5fa5bf4f504b6ccbaa11 Mon Sep 17 00:00:00 2001 From: David Vogt Date: Tue, 7 Jan 2025 15:03:47 +0100 Subject: [PATCH 7/7] chore: dependencies --- backend/poetry.lock | 135 ++++++++++++++++++++++---------------------- 1 file changed, 69 insertions(+), 66 deletions(-) diff --git a/backend/poetry.lock b/backend/poetry.lock index d6f32adf9..f6f77d488 100644 --- a/backend/poetry.lock +++ b/backend/poetry.lock @@ -77,75 +77,78 @@ files = [ [[package]] name = "cffi" -version = "1.15.1" +version = "1.17.1" description = "Foreign Function Interface for Python calling C code." optional = false -python-versions = "*" +python-versions = ">=3.8" files = [ - {file = "cffi-1.15.1-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:a66d3508133af6e8548451b25058d5812812ec3798c886bf38ed24a98216fab2"}, - {file = "cffi-1.15.1-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:470c103ae716238bbe698d67ad020e1db9d9dba34fa5a899b5e21577e6d52ed2"}, - {file = "cffi-1.15.1-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:9ad5db27f9cabae298d151c85cf2bad1d359a1b9c686a275df03385758e2f914"}, - {file = "cffi-1.15.1-cp27-cp27m-win32.whl", hash = "sha256:b3bbeb01c2b273cca1e1e0c5df57f12dce9a4dd331b4fa1635b8bec26350bde3"}, - {file = "cffi-1.15.1-cp27-cp27m-win_amd64.whl", hash = "sha256:e00b098126fd45523dd056d2efba6c5a63b71ffe9f2bbe1a4fe1716e1d0c331e"}, - {file = "cffi-1.15.1-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:d61f4695e6c866a23a21acab0509af1cdfd2c013cf256bbf5b6b5e2695827162"}, - {file = "cffi-1.15.1-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:ed9cb427ba5504c1dc15ede7d516b84757c3e3d7868ccc85121d9310d27eed0b"}, - {file = "cffi-1.15.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:39d39875251ca8f612b6f33e6b1195af86d1b3e60086068be9cc053aa4376e21"}, - {file = "cffi-1.15.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:285d29981935eb726a4399badae8f0ffdff4f5050eaa6d0cfc3f64b857b77185"}, - {file = "cffi-1.15.1-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3eb6971dcff08619f8d91607cfc726518b6fa2a9eba42856be181c6d0d9515fd"}, - {file = "cffi-1.15.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:21157295583fe8943475029ed5abdcf71eb3911894724e360acff1d61c1d54bc"}, - {file = "cffi-1.15.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5635bd9cb9731e6d4a1132a498dd34f764034a8ce60cef4f5319c0541159392f"}, - {file = "cffi-1.15.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2012c72d854c2d03e45d06ae57f40d78e5770d252f195b93f581acf3ba44496e"}, - {file = "cffi-1.15.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd86c085fae2efd48ac91dd7ccffcfc0571387fe1193d33b6394db7ef31fe2a4"}, - {file = "cffi-1.15.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:fa6693661a4c91757f4412306191b6dc88c1703f780c8234035eac011922bc01"}, - {file = "cffi-1.15.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:59c0b02d0a6c384d453fece7566d1c7e6b7bae4fc5874ef2ef46d56776d61c9e"}, - {file = "cffi-1.15.1-cp310-cp310-win32.whl", hash = "sha256:cba9d6b9a7d64d4bd46167096fc9d2f835e25d7e4c121fb2ddfc6528fb0413b2"}, - {file = "cffi-1.15.1-cp310-cp310-win_amd64.whl", hash = "sha256:ce4bcc037df4fc5e3d184794f27bdaab018943698f4ca31630bc7f84a7b69c6d"}, - {file = "cffi-1.15.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3d08afd128ddaa624a48cf2b859afef385b720bb4b43df214f85616922e6a5ac"}, - {file = "cffi-1.15.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3799aecf2e17cf585d977b780ce79ff0dc9b78d799fc694221ce814c2c19db83"}, - {file = "cffi-1.15.1-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a591fe9e525846e4d154205572a029f653ada1a78b93697f3b5a8f1f2bc055b9"}, - {file = "cffi-1.15.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3548db281cd7d2561c9ad9984681c95f7b0e38881201e157833a2342c30d5e8c"}, - {file = "cffi-1.15.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:91fc98adde3d7881af9b59ed0294046f3806221863722ba7d8d120c575314325"}, - {file = "cffi-1.15.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:94411f22c3985acaec6f83c6df553f2dbe17b698cc7f8ae751ff2237d96b9e3c"}, - {file = "cffi-1.15.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:03425bdae262c76aad70202debd780501fabeaca237cdfddc008987c0e0f59ef"}, - {file = "cffi-1.15.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:cc4d65aeeaa04136a12677d3dd0b1c0c94dc43abac5860ab33cceb42b801c1e8"}, - {file = "cffi-1.15.1-cp311-cp311-win32.whl", hash = "sha256:a0f100c8912c114ff53e1202d0078b425bee3649ae34d7b070e9697f93c5d52d"}, - {file = "cffi-1.15.1-cp311-cp311-win_amd64.whl", hash = "sha256:04ed324bda3cda42b9b695d51bb7d54b680b9719cfab04227cdd1e04e5de3104"}, - {file = "cffi-1.15.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:50a74364d85fd319352182ef59c5c790484a336f6db772c1a9231f1c3ed0cbd7"}, - {file = "cffi-1.15.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e263d77ee3dd201c3a142934a086a4450861778baaeeb45db4591ef65550b0a6"}, - {file = "cffi-1.15.1-cp36-cp36m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cec7d9412a9102bdc577382c3929b337320c4c4c4849f2c5cdd14d7368c5562d"}, - {file = "cffi-1.15.1-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4289fc34b2f5316fbb762d75362931e351941fa95fa18789191b33fc4cf9504a"}, - {file = "cffi-1.15.1-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:173379135477dc8cac4bc58f45db08ab45d228b3363adb7af79436135d028405"}, - {file = "cffi-1.15.1-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:6975a3fac6bc83c4a65c9f9fcab9e47019a11d3d2cf7f3c0d03431bf145a941e"}, - {file = "cffi-1.15.1-cp36-cp36m-win32.whl", hash = "sha256:2470043b93ff09bf8fb1d46d1cb756ce6132c54826661a32d4e4d132e1977adf"}, - {file = "cffi-1.15.1-cp36-cp36m-win_amd64.whl", hash = "sha256:30d78fbc8ebf9c92c9b7823ee18eb92f2e6ef79b45ac84db507f52fbe3ec4497"}, - {file = "cffi-1.15.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:198caafb44239b60e252492445da556afafc7d1e3ab7a1fb3f0584ef6d742375"}, - {file = "cffi-1.15.1-cp37-cp37m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5ef34d190326c3b1f822a5b7a45f6c4535e2f47ed06fec77d3d799c450b2651e"}, - {file = "cffi-1.15.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8102eaf27e1e448db915d08afa8b41d6c7ca7a04b7d73af6514df10a3e74bd82"}, - {file = "cffi-1.15.1-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5df2768244d19ab7f60546d0c7c63ce1581f7af8b5de3eb3004b9b6fc8a9f84b"}, - {file = "cffi-1.15.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a8c4917bd7ad33e8eb21e9a5bbba979b49d9a97acb3a803092cbc1133e20343c"}, - {file = "cffi-1.15.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0e2642fe3142e4cc4af0799748233ad6da94c62a8bec3a6648bf8ee68b1c7426"}, - {file = "cffi-1.15.1-cp37-cp37m-win32.whl", hash = "sha256:e229a521186c75c8ad9490854fd8bbdd9a0c9aa3a524326b55be83b54d4e0ad9"}, - {file = "cffi-1.15.1-cp37-cp37m-win_amd64.whl", hash = "sha256:a0b71b1b8fbf2b96e41c4d990244165e2c9be83d54962a9a1d118fd8657d2045"}, - {file = "cffi-1.15.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:320dab6e7cb2eacdf0e658569d2575c4dad258c0fcc794f46215e1e39f90f2c3"}, - {file = "cffi-1.15.1-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1e74c6b51a9ed6589199c787bf5f9875612ca4a8a0785fb2d4a84429badaf22a"}, - {file = "cffi-1.15.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a5c84c68147988265e60416b57fc83425a78058853509c1b0629c180094904a5"}, - {file = "cffi-1.15.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3b926aa83d1edb5aa5b427b4053dc420ec295a08e40911296b9eb1b6170f6cca"}, - {file = "cffi-1.15.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:87c450779d0914f2861b8526e035c5e6da0a3199d8f1add1a665e1cbc6fc6d02"}, - {file = "cffi-1.15.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4f2c9f67e9821cad2e5f480bc8d83b8742896f1242dba247911072d4fa94c192"}, - {file = "cffi-1.15.1-cp38-cp38-win32.whl", hash = "sha256:8b7ee99e510d7b66cdb6c593f21c043c248537a32e0bedf02e01e9553a172314"}, - {file = "cffi-1.15.1-cp38-cp38-win_amd64.whl", hash = "sha256:00a9ed42e88df81ffae7a8ab6d9356b371399b91dbdf0c3cb1e84c03a13aceb5"}, - {file = "cffi-1.15.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:54a2db7b78338edd780e7ef7f9f6c442500fb0d41a5a4ea24fff1c929d5af585"}, - {file = "cffi-1.15.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:fcd131dd944808b5bdb38e6f5b53013c5aa4f334c5cad0c72742f6eba4b73db0"}, - {file = "cffi-1.15.1-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7473e861101c9e72452f9bf8acb984947aa1661a7704553a9f6e4baa5ba64415"}, - {file = "cffi-1.15.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6c9a799e985904922a4d207a94eae35c78ebae90e128f0c4e521ce339396be9d"}, - {file = "cffi-1.15.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3bcde07039e586f91b45c88f8583ea7cf7a0770df3a1649627bf598332cb6984"}, - {file = "cffi-1.15.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:33ab79603146aace82c2427da5ca6e58f2b3f2fb5da893ceac0c42218a40be35"}, - {file = "cffi-1.15.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5d598b938678ebf3c67377cdd45e09d431369c3b1a5b331058c338e201f12b27"}, - {file = "cffi-1.15.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:db0fbb9c62743ce59a9ff687eb5f4afbe77e5e8403d6697f7446e5f609976f76"}, - {file = "cffi-1.15.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:98d85c6a2bef81588d9227dde12db8a7f47f639f4a17c9ae08e773aa9c697bf3"}, - {file = "cffi-1.15.1-cp39-cp39-win32.whl", hash = "sha256:40f4774f5a9d4f5e344f31a32b5096977b5d48560c5592e2f3d2c4374bd543ee"}, - {file = "cffi-1.15.1-cp39-cp39-win_amd64.whl", hash = "sha256:70df4e3b545a17496c9b3f41f5115e69a4f2e77e94e1d2a8e1070bc0c38c8a3c"}, - {file = "cffi-1.15.1.tar.gz", hash = "sha256:d400bfb9a37b1351253cb402671cea7e89bdecc294e8016a707f6d1d8ac934f9"}, + {file = "cffi-1.17.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:df8b1c11f177bc2313ec4b2d46baec87a5f3e71fc8b45dab2ee7cae86d9aba14"}, + {file = "cffi-1.17.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8f2cdc858323644ab277e9bb925ad72ae0e67f69e804f4898c070998d50b1a67"}, + {file = "cffi-1.17.1-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:edae79245293e15384b51f88b00613ba9f7198016a5948b5dddf4917d4d26382"}, + {file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:45398b671ac6d70e67da8e4224a065cec6a93541bb7aebe1b198a61b58c7b702"}, + {file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ad9413ccdeda48c5afdae7e4fa2192157e991ff761e7ab8fdd8926f40b160cc3"}, + {file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5da5719280082ac6bd9aa7becb3938dc9f9cbd57fac7d2871717b1feb0902ab6"}, + {file = "cffi-1.17.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bb1a08b8008b281856e5971307cc386a8e9c5b625ac297e853d36da6efe9c17"}, + {file = "cffi-1.17.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:045d61c734659cc045141be4bae381a41d89b741f795af1dd018bfb532fd0df8"}, + {file = "cffi-1.17.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:6883e737d7d9e4899a8a695e00ec36bd4e5e4f18fabe0aca0efe0a4b44cdb13e"}, + {file = "cffi-1.17.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:6b8b4a92e1c65048ff98cfe1f735ef8f1ceb72e3d5f0c25fdb12087a23da22be"}, + {file = "cffi-1.17.1-cp310-cp310-win32.whl", hash = "sha256:c9c3d058ebabb74db66e431095118094d06abf53284d9c81f27300d0e0d8bc7c"}, + {file = "cffi-1.17.1-cp310-cp310-win_amd64.whl", hash = "sha256:0f048dcf80db46f0098ccac01132761580d28e28bc0f78ae0d58048063317e15"}, + {file = "cffi-1.17.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a45e3c6913c5b87b3ff120dcdc03f6131fa0065027d0ed7ee6190736a74cd401"}, + {file = "cffi-1.17.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:30c5e0cb5ae493c04c8b42916e52ca38079f1b235c2f8ae5f4527b963c401caf"}, + {file = "cffi-1.17.1-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f75c7ab1f9e4aca5414ed4d8e5c0e303a34f4421f8a0d47a4d019ceff0ab6af4"}, + {file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a1ed2dd2972641495a3ec98445e09766f077aee98a1c896dcb4ad0d303628e41"}, + {file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:46bf43160c1a35f7ec506d254e5c890f3c03648a4dbac12d624e4490a7046cd1"}, + {file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a24ed04c8ffd54b0729c07cee15a81d964e6fee0e3d4d342a27b020d22959dc6"}, + {file = "cffi-1.17.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:610faea79c43e44c71e1ec53a554553fa22321b65fae24889706c0a84d4ad86d"}, + {file = "cffi-1.17.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:a9b15d491f3ad5d692e11f6b71f7857e7835eb677955c00cc0aefcd0669adaf6"}, + {file = "cffi-1.17.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:de2ea4b5833625383e464549fec1bc395c1bdeeb5f25c4a3a82b5a8c756ec22f"}, + {file = "cffi-1.17.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:fc48c783f9c87e60831201f2cce7f3b2e4846bf4d8728eabe54d60700b318a0b"}, + {file = "cffi-1.17.1-cp311-cp311-win32.whl", hash = "sha256:85a950a4ac9c359340d5963966e3e0a94a676bd6245a4b55bc43949eee26a655"}, + {file = "cffi-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:caaf0640ef5f5517f49bc275eca1406b0ffa6aa184892812030f04c2abf589a0"}, + {file = "cffi-1.17.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:805b4371bf7197c329fcb3ead37e710d1bca9da5d583f5073b799d5c5bd1eee4"}, + {file = "cffi-1.17.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:733e99bc2df47476e3848417c5a4540522f234dfd4ef3ab7fafdf555b082ec0c"}, + {file = "cffi-1.17.1-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1257bdabf294dceb59f5e70c64a3e2f462c30c7ad68092d01bbbfb1c16b1ba36"}, + {file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da95af8214998d77a98cc14e3a3bd00aa191526343078b530ceb0bd710fb48a5"}, + {file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d63afe322132c194cf832bfec0dc69a99fb9bb6bbd550f161a49e9e855cc78ff"}, + {file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f79fc4fc25f1c8698ff97788206bb3c2598949bfe0fef03d299eb1b5356ada99"}, + {file = "cffi-1.17.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b62ce867176a75d03a665bad002af8e6d54644fad99a3c70905c543130e39d93"}, + {file = "cffi-1.17.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:386c8bf53c502fff58903061338ce4f4950cbdcb23e2902d86c0f722b786bbe3"}, + {file = "cffi-1.17.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4ceb10419a9adf4460ea14cfd6bc43d08701f0835e979bf821052f1805850fe8"}, + {file = "cffi-1.17.1-cp312-cp312-win32.whl", hash = "sha256:a08d7e755f8ed21095a310a693525137cfe756ce62d066e53f502a83dc550f65"}, + {file = "cffi-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:51392eae71afec0d0c8fb1a53b204dbb3bcabcb3c9b807eedf3e1e6ccf2de903"}, + {file = "cffi-1.17.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f3a2b4222ce6b60e2e8b337bb9596923045681d71e5a082783484d845390938e"}, + {file = "cffi-1.17.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0984a4925a435b1da406122d4d7968dd861c1385afe3b45ba82b750f229811e2"}, + {file = "cffi-1.17.1-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d01b12eeeb4427d3110de311e1774046ad344f5b1a7403101878976ecd7a10f3"}, + {file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:706510fe141c86a69c8ddc029c7910003a17353970cff3b904ff0686a5927683"}, + {file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de55b766c7aa2e2a3092c51e0483d700341182f08e67c63630d5b6f200bb28e5"}, + {file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c59d6e989d07460165cc5ad3c61f9fd8f1b4796eacbd81cee78957842b834af4"}, + {file = "cffi-1.17.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd398dbc6773384a17fe0d3e7eeb8d1a21c2200473ee6806bb5e6a8e62bb73dd"}, + {file = "cffi-1.17.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3edc8d958eb099c634dace3c7e16560ae474aa3803a5df240542b305d14e14ed"}, + {file = "cffi-1.17.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:72e72408cad3d5419375fc87d289076ee319835bdfa2caad331e377589aebba9"}, + {file = "cffi-1.17.1-cp313-cp313-win32.whl", hash = "sha256:e03eab0a8677fa80d646b5ddece1cbeaf556c313dcfac435ba11f107ba117b5d"}, + {file = "cffi-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a"}, + {file = "cffi-1.17.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:636062ea65bd0195bc012fea9321aca499c0504409f413dc88af450b57ffd03b"}, + {file = "cffi-1.17.1-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c7eac2ef9b63c79431bc4b25f1cd649d7f061a28808cbc6c47b534bd789ef964"}, + {file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e221cf152cff04059d011ee126477f0d9588303eb57e88923578ace7baad17f9"}, + {file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:31000ec67d4221a71bd3f67df918b1f88f676f1c3b535a7eb473255fdc0b83fc"}, + {file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6f17be4345073b0a7b8ea599688f692ac3ef23ce28e5df79c04de519dbc4912c"}, + {file = "cffi-1.17.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0e2b1fac190ae3ebfe37b979cc1ce69c81f4e4fe5746bb401dca63a9062cdaf1"}, + {file = "cffi-1.17.1-cp38-cp38-win32.whl", hash = "sha256:7596d6620d3fa590f677e9ee430df2958d2d6d6de2feeae5b20e82c00b76fbf8"}, + {file = "cffi-1.17.1-cp38-cp38-win_amd64.whl", hash = "sha256:78122be759c3f8a014ce010908ae03364d00a1f81ab5c7f4a7a5120607ea56e1"}, + {file = "cffi-1.17.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b2ab587605f4ba0bf81dc0cb08a41bd1c0a5906bd59243d56bad7668a6fc6c16"}, + {file = "cffi-1.17.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:28b16024becceed8c6dfbc75629e27788d8a3f9030691a1dbf9821a128b22c36"}, + {file = "cffi-1.17.1-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1d599671f396c4723d016dbddb72fe8e0397082b0a77a4fab8028923bec050e8"}, + {file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca74b8dbe6e8e8263c0ffd60277de77dcee6c837a3d0881d8c1ead7268c9e576"}, + {file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f7f5baafcc48261359e14bcd6d9bff6d4b28d9103847c9e136694cb0501aef87"}, + {file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:98e3969bcff97cae1b2def8ba499ea3d6f31ddfdb7635374834cf89a1a08ecf0"}, + {file = "cffi-1.17.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cdf5ce3acdfd1661132f2a9c19cac174758dc2352bfe37d98aa7512c6b7178b3"}, + {file = "cffi-1.17.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:9755e4345d1ec879e3849e62222a18c7174d65a6a92d5b346b1863912168b595"}, + {file = "cffi-1.17.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:f1e22e8c4419538cb197e4dd60acc919d7696e5ef98ee4da4e01d3f8cfa4cc5a"}, + {file = "cffi-1.17.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:c03e868a0b3bc35839ba98e74211ed2b05d2119be4e8a0f224fba9384f1fe02e"}, + {file = "cffi-1.17.1-cp39-cp39-win32.whl", hash = "sha256:e31ae45bc2e29f6b2abd0de1cc3b9d5205aa847cafaecb8af1476a609a2f6eb7"}, + {file = "cffi-1.17.1-cp39-cp39-win_amd64.whl", hash = "sha256:d016c76bdd850f3c626af19b0542c9677ba156e4ee4fccfdd7848803533ef662"}, + {file = "cffi-1.17.1.tar.gz", hash = "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824"}, ] [package.dependencies]