From 7454ab93e23a0da9366a018c1048b0eb670883b6 Mon Sep 17 00:00:00 2001 From: Anvesh Nalimela <151531961+AnveshNalimela@users.noreply.github.com> Date: Mon, 25 Nov 2024 20:22:05 +0530 Subject: [PATCH] Added CSV support To API listFacilityDischargedPatients (#2601) Added CSV support To API listFacilityDischargedPatients (#2601) Co-authored-by: Aakash Singh --- care/facility/api/viewsets/patient.py | 73 +-------------- care/utils/exports/__init__.py | 0 care/utils/exports/mixins.py | 129 ++++++++++++++++++++++++++ pyproject.toml | 1 + 4 files changed, 135 insertions(+), 68 deletions(-) create mode 100644 care/utils/exports/__init__.py create mode 100644 care/utils/exports/mixins.py diff --git a/care/facility/api/viewsets/patient.py b/care/facility/api/viewsets/patient.py index 963b6d4731..585d13cdee 100644 --- a/care/facility/api/viewsets/patient.py +++ b/care/facility/api/viewsets/patient.py @@ -19,7 +19,6 @@ from django.db.models.query import QuerySet from django.utils import timezone from django_filters import rest_framework as filters -from djqscsv import render_to_csv_response from drf_spectacular.utils import extend_schema, extend_schema_view from dry_rest_permissions.generics import DRYPermissionFiltersBase, DRYPermissions from rest_framework import filters as rest_framework_filters @@ -80,6 +79,7 @@ from care.facility.models.patient_consultation import PatientConsultation from care.users.models import User from care.utils.cache.cache_allowed_facilities import get_accessible_facilities +from care.utils.exports.mixins import CSVExportViewSetMixin from care.utils.filters.choicefilter import CareChoiceFilter from care.utils.filters.multiselect import MultiSelectFilter from care.utils.notification_handler import NotificationGenerator @@ -376,6 +376,7 @@ def filter_queryset(self, request, queryset, view): @extend_schema_view(history=extend_schema(tags=["patient"])) class PatientViewSet( + CSVExportViewSetMixin, HistoryMixin, mixins.CreateModelMixin, mixins.ListModelMixin, @@ -475,7 +476,6 @@ class PatientViewSet( "last_consultation_encounter_date", "last_consultation_discharge_date", ] - CSV_EXPORT_LIMIT = 7 def get_queryset(self): queryset = super().get_queryset().order_by("modified_date") @@ -520,71 +520,6 @@ def filter_queryset(self, queryset: QuerySet) -> QuerySet: return super().filter_queryset(queryset) - def list(self, request, *args, **kwargs): - """ - Patient List - - `without_facility` accepts boolean - default is false - - if true: shows only patients without a facility mapped - if false (default behaviour): shows only patients with a facility mapped - - `disease_status` accepts - string and int - - SUSPECTED = 1 - POSITIVE = 2 - NEGATIVE = 3 - RECOVERY = 4 - RECOVERED = 5 - EXPIRED = 6 - - """ - if settings.CSV_REQUEST_PARAMETER in request.GET: - # Start Date Validation - temp = filters.DjangoFilterBackend().get_filterset( - self.request, self.queryset, self - ) - temp.is_valid() - within_limits = False - for field in self.date_range_fields: - slice_obj = temp.form.cleaned_data.get(field) - if slice_obj: - if not slice_obj.start or not slice_obj.stop: - raise ValidationError( - { - field: "both starting and ending date must be provided for export" - } - ) - days_difference = ( - temp.form.cleaned_data.get(field).stop - - temp.form.cleaned_data.get(field).start - ).days - if days_difference <= self.CSV_EXPORT_LIMIT: - within_limits = True - else: - raise ValidationError( - { - field: f"Cannot export more than {self.CSV_EXPORT_LIMIT} days at a time" - } - ) - if not within_limits: - raise ValidationError( - { - "date": f"Atleast one date field must be filtered to be within {self.CSV_EXPORT_LIMIT} days" - } - ) - # End Date Limiting Validation - queryset = ( - self.filter_queryset(self.get_queryset()) - .annotate(**PatientRegistration.CSV_ANNOTATE_FIELDS) - .values(*PatientRegistration.CSV_MAPPING.keys()) - ) - return render_to_csv_response( - queryset, - field_header_map=PatientRegistration.CSV_MAPPING, - field_serializer_map=PatientRegistration.CSV_MAKE_PRETTY, - ) - - return super().list(request, *args, **kwargs) - @extend_schema(tags=["patient"]) @action(detail=True, methods=["POST"]) def transfer(self, request, *args, **kwargs): @@ -678,7 +613,9 @@ def filter_by_bed_type(self, queryset, name, value): @extend_schema_view(tags=["patient"]) -class FacilityDischargedPatientViewSet(GenericViewSet, mixins.ListModelMixin): +class FacilityDischargedPatientViewSet( + CSVExportViewSetMixin, GenericViewSet, mixins.ListModelMixin +): permission_classes = (IsAuthenticated, DRYPermissions) lookup_field = "external_id" serializer_class = PatientListSerializer diff --git a/care/utils/exports/__init__.py b/care/utils/exports/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/care/utils/exports/mixins.py b/care/utils/exports/mixins.py new file mode 100644 index 0000000000..4e80310dbe --- /dev/null +++ b/care/utils/exports/mixins.py @@ -0,0 +1,129 @@ +from django.conf import settings +from django.db import models +from django_filters import rest_framework as filters +from djqscsv import render_to_csv_response +from rest_framework.exceptions import ValidationError + + +class CSVExportViewSetMixin: + """Mixin that adds CSV export functionality to a viewset""" + + csv_export_limit = 7 + date_range_fields = [] + + def get_model(self): + """Get model class from viewset's queryset or model attribute""" + if hasattr(self, "queryset"): + return self.queryset.model + if hasattr(self, "model"): + return self.model + msg = ( + "Cannot determine model class from viewset, set model or queryset attribute" + ) + raise ValueError(msg) + + def get_date_range_fields(self): + """Get date range fields from model and filterset""" + if self.date_range_fields: + return self.date_range_fields + + model = self.get_model() + date_fields = [] + + # Get fields from model that are DateField/DateTimeField + for field in model._meta.fields: # noqa: SLF001 + if isinstance(field, (models.DateField, models.DateTimeField)): + date_fields.append(field.name) + + # Get date range fields from filterset if defined + if hasattr(self, "filterset_class"): + for name, field in self.filterset_class.declared_filters.items(): + if isinstance(field, filters.DateFromToRangeFilter): + date_fields.append(name) + + return list(set(date_fields)) + + def get_csv_settings(self): + """Get CSV export configuration from model""" + model = self.get_model() + + # Try to get settings from model + annotations = getattr(model, "CSV_ANNOTATE_FIELDS", {}) + field_mapping = getattr(model, "CSV_MAPPING", {}) + field_serializers = getattr(model, "CSV_MAKE_PRETTY", {}) + + if not field_mapping: + # Auto-generate field mapping from model fields + field_mapping = {f.name: f.verbose_name.title() for f in model._meta.fields} # noqa: SLF001 + + fields = list(field_mapping.keys()) + + return { + "annotations": annotations, + "field_mapping": field_mapping, + "field_serializers": field_serializers, + "fields": fields, + } + + def validate_date_ranges(self, request): + """Validates that at least one date range filter is within limits""" + filterset = filters.DjangoFilterBackend().get_filterset( + request, self.queryset, self + ) + if not filterset.is_valid(): + raise ValidationError(filterset.errors) + + within_limits = False + for field in self.get_date_range_fields(): + slice_obj = filterset.form.cleaned_data.get(field) + if slice_obj: + if not slice_obj.start or not slice_obj.stop: + raise ValidationError( + { + field: "both starting and ending date must be provided for export" + } + ) + + days_difference = ( + filterset.form.cleaned_data.get(field).stop + - filterset.form.cleaned_data.get(field).start + ).days + + if days_difference <= self.csv_export_limit: + within_limits = True + else: + raise ValidationError( + { + field: f"Cannot export more than {self.csv_export_limit} days at a time" + } + ) + + if not within_limits: + raise ValidationError( + { + "date": f"At least one date field must be filtered to be within {self.csv_export_limit} days" + } + ) + + def export_as_csv(self, request): + """Exports queryset as CSV""" + self.validate_date_ranges(request) + + csv_settings = self.get_csv_settings() + queryset = self.filter_queryset(self.get_queryset()) + + if csv_settings["annotations"]: + queryset = queryset.annotate(**csv_settings["annotations"]) + + queryset = queryset.values(*csv_settings["fields"]) + + return render_to_csv_response( + queryset, + field_header_map=csv_settings["field_mapping"], + field_serializer_map=csv_settings["field_serializers"], + ) + + def list(self, request, *args, **kwargs): + if settings.CSV_REQUEST_PARAMETER in request.GET: + return self.export_as_csv(request) + return super().list(request, *args, **kwargs) diff --git a/pyproject.toml b/pyproject.toml index 93d676ccd2..6a7a5c5089 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -83,6 +83,7 @@ ignore = [ "FBT001", # why not! "S106", "S105", + "UP038" # this results in slower code ]