diff --git a/plugins/polio/api/vaccines/repository_reports.py b/plugins/polio/api/vaccines/repository_reports.py index f55deb86d7..27e1095034 100644 --- a/plugins/polio/api/vaccines/repository_reports.py +++ b/plugins/polio/api/vaccines/repository_reports.py @@ -1,7 +1,4 @@ """API endpoints and serializers for vaccine repository reports.""" - -from datetime import datetime, timedelta -from django.db.models import OuterRef, Subquery, Q, Value, Case, When, CharField, Exists from drf_yasg import openapi from drf_yasg.utils import swagger_auto_schema from rest_framework import filters, permissions, serializers @@ -9,7 +6,6 @@ from rest_framework.filters import OrderingFilter, SearchFilter from rest_framework.mixins import ListModelMixin from rest_framework.viewsets import GenericViewSet -from django.db.models import Prefetch from iaso.api.common import Paginator from plugins.polio.models import VaccineStock, DestructionReport, IncidentReport @@ -47,10 +43,20 @@ def filter_queryset(self, request, queryset, view): if file_type: try: filetypes = [tp.strip().upper() for tp in file_type.split(",")] - if "INCIDENT" in filetypes: + + has_incident = "INCIDENT" in filetypes + has_destruction = "DESTRUCTION" in filetypes + + if has_incident and has_destruction: + # Both types specified - must have both + queryset = queryset.filter(incidentreport__isnull=False, destructionreport__isnull=False) + elif has_incident: + # Only incident reports queryset = queryset.filter(incidentreport__isnull=False) - if "DESTRUCTION" in filetypes: + elif has_destruction: + # Only destruction reports queryset = queryset.filter(destructionreport__isnull=False) + # If no types specified, show all (no filtering needed) except ValueError: raise ValidationError("file_type must be a comma-separated list of strings") @@ -65,22 +71,26 @@ class VaccineRepositoryReportSerializer(serializers.Serializer): destruction_report_data = serializers.SerializerMethodField() def get_incident_report_data(self, obj): - return [ + pir = obj.incidentreport_set.all() + data = [ { "date": ir.date_of_incident_report, "file": ir.document.url if ir.document else None, } - for ir in obj.prefetched_incident_reports + for ir in pir ] + return data def get_destruction_report_data(self, obj): - return [ + drs = obj.destructionreport_set.all() + data = [ { "date": dr.destruction_report_date, "file": dr.document.url if dr.document else None, } - for dr in obj.prefetched_destruction_reports + for dr in drs ] + return data class VaccineRepositoryReportsViewSet(GenericViewSet, ListModelMixin): @@ -97,26 +107,18 @@ class VaccineRepositoryReportsViewSet(GenericViewSet, ListModelMixin): def get_queryset(self): """Get the queryset for VaccineStock objects.""" + base_qs = VaccineStock.objects.select_related( "country", - ).filter(Q(incidentreport__isnull=False) | Q(destructionreport__isnull=False)) - - incident_qs = IncidentReport.objects.only( - "vaccine_stock_id", - "date_of_incident_report", - "document", + ).prefetch_related( + "incidentreport_set", + "destructionreport_set", ) - destruction_qs = DestructionReport.objects.only( - "vaccine_stock_id", - "destruction_report_date", - "document", - ) + if self.request.user and self.request.user.is_authenticated: + base_qs = base_qs.filter(account=self.request.user.iaso_profile.account) - return base_qs.prefetch_related( - Prefetch("incidentreport_set", queryset=incident_qs, to_attr="prefetched_incident_reports"), - Prefetch("destructionreport_set", queryset=destruction_qs, to_attr="prefetched_destruction_reports"), - ).distinct() + return base_qs @swagger_auto_schema( manual_parameters=[ diff --git a/plugins/polio/js/src/domains/VaccineModule/Repository/VaccineRepository.tsx b/plugins/polio/js/src/domains/VaccineModule/Repository/VaccineRepository.tsx index 7738e57ecb..6f96d8d6cb 100644 --- a/plugins/polio/js/src/domains/VaccineModule/Repository/VaccineRepository.tsx +++ b/plugins/polio/js/src/domains/VaccineModule/Repository/VaccineRepository.tsx @@ -55,7 +55,7 @@ export const VaccineRepository: FunctionComponent = () => { ...params, tab: newTab, }; - redirectTo(baseUrl, newParams); + redirectTo(redirectUrl, newParams); }; return ( diff --git a/plugins/polio/migrations/0208_alter_vaccinerequestform_rounds_and_more.py b/plugins/polio/migrations/0208_alter_vaccinerequestform_rounds_and_more.py new file mode 100644 index 0000000000..63a033576e --- /dev/null +++ b/plugins/polio/migrations/0208_alter_vaccinerequestform_rounds_and_more.py @@ -0,0 +1,99 @@ +# Generated by Django 4.2.17 on 2024-12-16 10:55 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("polio", "0207_merge_20241204_1433"), + ] + + operations = [ + migrations.AlterField( + model_name="vaccinerequestform", + name="rounds", + field=models.ManyToManyField(db_index=True, to="polio.round"), + ), + migrations.AddIndex( + model_name="destructionreport", + index=models.Index( + fields=["vaccine_stock", "destruction_report_date"], name="polio_destr_vaccine_e5b90d_idx" + ), + ), + migrations.AddIndex( + model_name="destructionreport", + index=models.Index(fields=["rrt_destruction_report_reception_date"], name="polio_destr_rrt_des_449e4f_idx"), + ), + migrations.AddIndex( + model_name="incidentreport", + index=models.Index( + fields=["vaccine_stock", "date_of_incident_report"], name="polio_incid_vaccine_b012dc_idx" + ), + ), + migrations.AddIndex( + model_name="incidentreport", + index=models.Index(fields=["incident_report_received_by_rrt"], name="polio_incid_inciden_067b16_idx"), + ), + migrations.AddIndex( + model_name="outgoingstockmovement", + index=models.Index(fields=["vaccine_stock", "campaign"], name="polio_outgo_vaccine_fa2e84_idx"), + ), + migrations.AddIndex( + model_name="outgoingstockmovement", + index=models.Index(fields=["form_a_reception_date"], name="polio_outgo_form_a__b64b56_idx"), + ), + migrations.AddIndex( + model_name="outgoingstockmovement", + index=models.Index(fields=["report_date"], name="polio_outgo_report__44ffe2_idx"), + ), + migrations.AddIndex( + model_name="vaccinearrivalreport", + index=models.Index(fields=["request_form", "arrival_report_date"], name="polio_vacci_request_48e891_idx"), + ), + migrations.AddIndex( + model_name="vaccinearrivalreport", + index=models.Index(fields=["po_number"], name="polio_vacci_po_numb_bd6c9f_idx"), + ), + migrations.AddIndex( + model_name="vaccinearrivalreport", + index=models.Index(fields=["doses_received"], name="polio_vacci_doses_r_d2cd9d_idx"), + ), + migrations.AddIndex( + model_name="vaccineprealert", + index=models.Index( + fields=["request_form", "estimated_arrival_time"], name="polio_vacci_request_4c2b0b_idx" + ), + ), + migrations.AddIndex( + model_name="vaccineprealert", + index=models.Index(fields=["po_number"], name="polio_vacci_po_numb_511963_idx"), + ), + migrations.AddIndex( + model_name="vaccineprealert", + index=models.Index(fields=["date_pre_alert_reception"], name="polio_vacci_date_pr_b7d59e_idx"), + ), + migrations.AddIndex( + model_name="vaccinerequestform", + index=models.Index(fields=["campaign", "vaccine_type"], name="polio_vacci_campaig_f43af8_idx"), + ), + migrations.AddIndex( + model_name="vaccinerequestform", + index=models.Index(fields=["vrf_type"], name="polio_vacci_vrf_typ_2acd7d_idx"), + ), + migrations.AddIndex( + model_name="vaccinerequestform", + index=models.Index(fields=["created_at"], name="polio_vacci_created_8563f0_idx"), + ), + migrations.AddIndex( + model_name="vaccinerequestform", + index=models.Index(fields=["updated_at"], name="polio_vacci_updated_fd171a_idx"), + ), + migrations.AddIndex( + model_name="vaccinestock", + index=models.Index(fields=["country", "vaccine"], name="polio_vacci_country_91274d_idx"), + ), + migrations.AddIndex( + model_name="vaccinestock", + index=models.Index(fields=["account"], name="polio_vacci_account_f1f77e_idx"), + ), + ] diff --git a/plugins/polio/migrations/0209_merge_20241216_1100.py b/plugins/polio/migrations/0209_merge_20241216_1100.py new file mode 100644 index 0000000000..97c2f3cb7b --- /dev/null +++ b/plugins/polio/migrations/0209_merge_20241216_1100.py @@ -0,0 +1,12 @@ +# Generated by Django 4.2.17 on 2024-12-16 11:00 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("polio", "0208_alter_vaccinerequestform_rounds_and_more"), + ("polio", "0208_migrate_vrf_orpg_fields"), + ] + + operations = [] diff --git a/plugins/polio/models/base.py b/plugins/polio/models/base.py index bbe3c2eb08..9e369515ad 100644 --- a/plugins/polio/models/base.py +++ b/plugins/polio/models/base.py @@ -1036,9 +1036,17 @@ class VaccineRequestFormType(models.TextChoices): class VaccineRequestForm(SoftDeletableModel): - campaign = models.ForeignKey(Campaign, on_delete=models.CASCADE) + class Meta: + indexes = [ + models.Index(fields=["campaign", "vaccine_type"]), # Frequently filtered together + models.Index(fields=["vrf_type"]), # Filtered in repository_forms.py + models.Index(fields=["created_at"]), # Used for ordering + models.Index(fields=["updated_at"]), # Used for ordering + ] + + campaign = models.ForeignKey(Campaign, on_delete=models.CASCADE, db_index=True) vaccine_type = models.CharField(max_length=5, choices=VACCINES) - rounds = models.ManyToManyField(Round) + rounds = models.ManyToManyField(Round, db_index=True) date_vrf_signature = models.DateField(null=True, blank=True) date_vrf_reception = models.DateField(null=True, blank=True) date_dg_approval = models.DateField(null=True, blank=True) @@ -1117,6 +1125,13 @@ def save(self, *args, **kwargs): def get_doses_per_vial(self): return DOSES_PER_VIAL[self.request_form.vaccine_type] + class Meta: + indexes = [ + models.Index(fields=["request_form", "estimated_arrival_time"]), # Used together in queries + models.Index(fields=["po_number"]), # Unique field that's queried + models.Index(fields=["date_pre_alert_reception"]), # Used for filtering/ordering + ] + class VaccineArrivalReport(models.Model): request_form = models.ForeignKey(VaccineRequestForm, on_delete=models.CASCADE) @@ -1152,6 +1167,13 @@ def save(self, *args, **kwargs): super().save(*args, **kwargs) + class Meta: + indexes = [ + models.Index(fields=["request_form", "arrival_report_date"]), # Frequently queried together + models.Index(fields=["po_number"]), # Unique field that's queried + models.Index(fields=["doses_received"]), # Used in aggregations + ] + class VaccineStock(models.Model): account = models.ForeignKey("iaso.account", on_delete=models.CASCADE, related_name="vaccine_stocks") @@ -1167,6 +1189,10 @@ class VaccineStock(models.Model): class Meta: unique_together = ("country", "vaccine") + indexes = [ + models.Index(fields=["country", "vaccine"]), # Already unique_together, but used in many queries + models.Index(fields=["account"]), # Frequently filtered by account + ] def __str__(self): return f"{self.country} - {self.vaccine}" @@ -1204,6 +1230,13 @@ class Meta: # Form A class OutgoingStockMovement(models.Model): + class Meta: + indexes = [ + models.Index(fields=["vaccine_stock", "campaign"]), # Frequently queried together + models.Index(fields=["form_a_reception_date"]), # Used in ordering + models.Index(fields=["report_date"]), # Used in filtering/ordering + ] + campaign = models.ForeignKey(Campaign, on_delete=models.CASCADE) round = models.ForeignKey(Round, on_delete=models.CASCADE, null=True, blank=True) vaccine_stock = models.ForeignKey( @@ -1234,6 +1267,12 @@ class DestructionReport(models.Model): storage=CustomPublicStorage(), upload_to="public_documents/destructionreport/", null=True, blank=True ) + class Meta: + indexes = [ + models.Index(fields=["vaccine_stock", "destruction_report_date"]), # Used together in queries + models.Index(fields=["rrt_destruction_report_reception_date"]), # Used in filtering + ] + class IncidentReport(models.Model): class StockCorrectionChoices(models.TextChoices): @@ -1262,6 +1301,12 @@ class StockCorrectionChoices(models.TextChoices): storage=CustomPublicStorage(), upload_to="public_documents/incidentreport/", null=True, blank=True ) + class Meta: + indexes = [ + models.Index(fields=["vaccine_stock", "date_of_incident_report"]), # Frequently queried together + models.Index(fields=["incident_report_received_by_rrt"]), # Used in filtering + ] + class Notification(models.Model): """ diff --git a/plugins/polio/tasks/archive_vaccine_stock_for_rounds.py b/plugins/polio/tasks/archive_vaccine_stock_for_rounds.py index 70fd6d1c1f..6656857af5 100644 --- a/plugins/polio/tasks/archive_vaccine_stock_for_rounds.py +++ b/plugins/polio/tasks/archive_vaccine_stock_for_rounds.py @@ -14,21 +14,22 @@ def archive_stock_for_round(round, vaccine_stock, reference_date, country=None): vaccine_stock_for_vaccine = vaccine_stock - if vaccine_stock_for_vaccine.exists(): + if vaccine_stock_for_vaccine: if not country: - vaccine_stock_for_vaccine = vaccine_stock_for_vaccine.filter(country=round.campaign.country.id) - vaccine_stock_for_vaccine = vaccine_stock_for_vaccine.first() - calculator = VaccineStockCalculator(vaccine_stock_for_vaccine) - total_usable_vials_in, total_usable_doses_in = calculator.get_total_of_usable_vials(reference_date) - total_unusable_vials_in, total_unusable_doses_in = calculator.get_total_of_unusable_vials(reference_date) - VaccineStockHistory.objects.create( - vaccine_stock=vaccine_stock_for_vaccine, - round=round, - usable_vials_in=total_usable_vials_in, - usable_doses_in=total_usable_doses_in, - unusable_vials_in=total_unusable_vials_in, - unusable_doses_in=total_unusable_doses_in, - ) + vaccine_stock_for_vaccine = vaccine_stock_for_vaccine.filter(country=round.campaign.country) + if vaccine_stock_for_vaccine: + vaccine_stock_for_vaccine = vaccine_stock_for_vaccine.first() + calculator = VaccineStockCalculator(vaccine_stock_for_vaccine) + total_usable_vials_in, total_usable_doses_in = calculator.get_total_of_usable_vials(reference_date) + total_unusable_vials_in, total_unusable_doses_in = calculator.get_total_of_unusable_vials(reference_date) + VaccineStockHistory.objects.create( + vaccine_stock=vaccine_stock_for_vaccine, + round=round, + usable_vials_in=total_usable_vials_in, + usable_doses_in=total_usable_doses_in, + unusable_vials_in=total_unusable_vials_in, + unusable_doses_in=total_unusable_doses_in, + ) @task_decorator(task_name="archive_vaccine_stock") @@ -38,7 +39,9 @@ def archive_vaccine_stock_for_rounds(date=None, country=None, campaign=None, vac reference_date = datetime.strptime(date, "%Y-%m-%d") if date else task_start round_end_date = reference_date - timedelta(days=14) - rounds_qs = Round.objects.filter(ended_at__lte=round_end_date, campaign__account=account) + rounds_qs = Round.objects.filter(ended_at__lte=round_end_date, campaign__account=account).select_related( + "campaign__country" + ) if country: rounds_qs = rounds_qs.filter(campaign__country__id=country) @@ -69,20 +72,21 @@ def archive_vaccine_stock_for_rounds(date=None, country=None, campaign=None, vac i = 0 for vax in vaccines: qs = vax_dict[vax] - vaccine_stock = VaccineStock.objects.filter(vaccine=vax) for r in qs: i += 1 - try: - archive_stock_for_round(round=r, reference_date=reference_date, vaccine_stock=vaccine_stock) - task.report_progress_and_stop_if_killed( - progress_value=i, - end_value=count, - progress_message=f"Stock history added for {r.pk} and vaccine {vax}", - ) - except IntegrityError: - task.report_progress_and_stop_if_killed( - progress_value=i, - end_value=count, - progress_message=f"Could not add stock history for round {r.pk} vaccine {vax}. History already exists", - ) + vaccine_stock = VaccineStock.objects.filter(vaccine=vax) + if vaccine_stock: + try: + archive_stock_for_round(round=r, reference_date=reference_date, vaccine_stock=vaccine_stock) + task.report_progress_and_stop_if_killed( + progress_value=i, + end_value=count, + progress_message=f"Stock history added for {r.pk} and vaccine {vax}", + ) + except IntegrityError: + task.report_progress_and_stop_if_killed( + progress_value=i, + end_value=count, + progress_message=f"Could not add stock history for round {r.pk} vaccine {vax}. History already exists", + )