diff --git a/plugins/polio/api/campaigns/campaigns.py b/plugins/polio/api/campaigns/campaigns.py index ff709f002a..329a10825b 100644 --- a/plugins/polio/api/campaigns/campaigns.py +++ b/plugins/polio/api/campaigns/campaigns.py @@ -293,26 +293,35 @@ def update(self, instance: Campaign, validated_data): rounds = validated_data.pop("rounds", []) initial_org_unit = validated_data.get("initial_org_unit") account = self.context["request"].user.iaso_profile.account + separate_scopes_per_round = validated_data.get("separate_scopes_per_round", instance.separate_scopes_per_round) + switch_to_scope_per_round = separate_scopes_per_round and not instance.separate_scopes_per_round + switch_to_scope_per_campaign = not separate_scopes_per_round and instance.separate_scopes_per_round + keep_scope_per_round = separate_scopes_per_round and instance.separate_scopes_per_round + keep_scope_per_campaign = not separate_scopes_per_round and not instance.separate_scopes_per_round - for scope in campaign_scopes: - vaccine = scope.get("vaccine", "") - org_units = scope.get("group", {}).get("org_units") - scope, created = instance.scopes.get_or_create(vaccine=vaccine) - source_version_id = None - name = f"scope for campaign {instance.obr_name}" + (f" - {vaccine}" if vaccine else "") - if org_units: - source_version_ids = set([ou.version_id for ou in org_units]) - if len(source_version_ids) != 1: - raise serializers.ValidationError("All orgunit should be in the same source version") - source_version_id = list(source_version_ids)[0] - if not scope.group: - scope.group = Group.objects.create(name=name, source_version_id=source_version_id) - else: - scope.group.source_version_id = source_version_id - scope.group.name = name - scope.group.save() + if switch_to_scope_per_round and instance.scopes.exists(): + instance.scopes.all().delete() - scope.group.org_units.set(org_units) + if switch_to_scope_per_campaign or keep_scope_per_campaign: + for scope in campaign_scopes: + vaccine = scope.get("vaccine", "") + org_units = scope.get("group", {}).get("org_units") + scope, created = instance.scopes.get_or_create(vaccine=vaccine) + source_version_id = None + name = f"scope for campaign {instance.obr_name} - {vaccine or ''}" + if org_units: + source_version_ids = set([ou.version_id for ou in org_units]) + if len(source_version_ids) != 1: + raise serializers.ValidationError("All orgunit should be in the same source version") + source_version_id = list(source_version_ids)[0] + if not scope.group: + scope.group = Group.objects.create(name=name, source_version_id=source_version_id) + else: + scope.group.source_version_id = source_version_id + scope.group.name = name + scope.group.save() + + scope.group.org_units.set(org_units) round_instances = [] # find existing round either by id or number @@ -355,27 +364,30 @@ def update(self, instance: Campaign, validated_data): round_instance = round_serializer.save() round_instances.append(round_instance) round_datelogs = [] - for scope in scopes: - vaccine = scope.get("vaccine", "") - org_units = scope.get("group", {}).get("org_units") - source_version_id = None - if org_units: - source_version_ids = set([ou.version_id for ou in org_units]) - if len(source_version_ids) != 1: - raise serializers.ValidationError("All orgunit should be in the same source version") - source_version_id = list(source_version_ids)[0] - name = f"scope for round {round_instance.number} campaign {instance.obr_name}" + ( - f" - {vaccine}" if vaccine else "" - ) - scope, created = round_instance.scopes.get_or_create(vaccine=vaccine) - if not scope.group: - scope.group = Group.objects.create(name=name) - else: - scope.group.source_version_id = source_version_id - scope.group.name = name - scope.group.save() + if switch_to_scope_per_campaign and round.scopes.exists(): + round.scopes.all().delete() + if switch_to_scope_per_round or keep_scope_per_round: + for scope in scopes: + vaccine = scope.get("vaccine", "") + org_units = scope.get("group", {}).get("org_units") + source_version_id = None + if org_units: + source_version_ids = set([ou.version_id for ou in org_units]) + if len(source_version_ids) != 1: + raise serializers.ValidationError("All orgunit should be in the same source version") + source_version_id = list(source_version_ids)[0] + name = f"scope for round {round_instance.number} campaign {instance.obr_name}" + ( + f" - {vaccine}" if vaccine else "" + ) + scope, created = round_instance.scopes.get_or_create(vaccine=vaccine) + if not scope.group: + scope.group = Group.objects.create(name=name) + else: + scope.group.source_version_id = source_version_id + scope.group.name = name + scope.group.save() - scope.group.org_units.set(org_units) + scope.group.org_units.set(org_units) # When some rounds need to be deleted, the payload contains only the rounds to keep. # So we have to detect if somebody wants to delete a round to prevent deletion of diff --git a/plugins/polio/api/vaccines/repository.py b/plugins/polio/api/vaccines/repository.py index b08938de21..266538666c 100644 --- a/plugins/polio/api/vaccines/repository.py +++ b/plugins/polio/api/vaccines/repository.py @@ -1,7 +1,6 @@ """API endpoints and serializers for vaccine repository management.""" from datetime import datetime, timedelta - from django.db.models import Max, Min, OuterRef, Subquery from drf_yasg import openapi from drf_yasg.utils import swagger_auto_schema @@ -10,9 +9,11 @@ from rest_framework.filters import OrderingFilter, SearchFilter from rest_framework.mixins import ListModelMixin from rest_framework.viewsets import GenericViewSet +from django.db.models import OuterRef, Subquery, Q, Value, Case, When, CharField from iaso.api.common import Paginator from plugins.polio.models import ( + VACCINES, CampaignType, OutgoingStockMovement, Round, @@ -22,56 +23,6 @@ ) -class VaccineRepositorySerializer(serializers.Serializer): - country_name = serializers.CharField(source="campaign.country.name") - campaign_obr_name = serializers.CharField(source="campaign.obr_name") - round_id = serializers.IntegerField(source="id") - round_number = serializers.IntegerField(source="number") - start_date = serializers.DateField(source="started_at") - end_date = serializers.DateField(source="ended_at") - - vrf_data = serializers.SerializerMethodField() - pre_alert_data = serializers.SerializerMethodField() - form_a_data = serializers.SerializerMethodField() - - def get_vrf_data(self, obj): - vrfs = VaccineRequestForm.objects.filter(campaign=obj.campaign, rounds=obj) - return [ - { - "date": vrf.date_vrf_reception, - "file": vrf.document.url if vrf.document else None, - "is_missing": vrf.vrf_type == VaccineRequestFormType.MISSING, - "is_not_required": vrf.vrf_type == VaccineRequestFormType.NOT_REQUIRED, - "id": vrf.id, - } - for vrf in vrfs - ] - - def get_pre_alert_data(self, obj): - pre_alerts = VaccinePreAlert.objects.filter(request_form__campaign=obj.campaign, request_form__rounds=obj) - return [ - { - "date": pa.date_pre_alert_reception, - "file": pa.document.url if pa.document else None, - "vrf_id": pa.request_form.id, - } - for pa in pre_alerts - ] - - def get_form_a_data(self, obj): - form_as = OutgoingStockMovement.objects.filter(campaign=obj.campaign, round=obj) - return [ - { - "date": fa.form_a_reception_date, - "file": fa.document.url if fa.document else None, - "is_late": fa.form_a_reception_date > (obj.ended_at + timedelta(days=14)) - if fa.form_a_reception_date and obj.ended_at - else None, - } - for fa in form_as - ] - - class VaccineReportingFilterBackend(filters.BaseFilterBackend): """Filter backend for vaccine reporting that handles campaign status, country, and file type filtering.""" @@ -79,19 +30,6 @@ def filter_queryset(self, request, queryset, view): # Filter by campaign status campaign_status = request.query_params.get("campaign_status", None) - # Get campaign dates subquery - campaign_dates = ( - Round.objects.filter(campaign=OuterRef("campaign")) - .values("campaign") - .annotate(campaign_started_at=Min("started_at"), campaign_ended_at=Max("ended_at")) - ) - - # Add campaign dates to main queryset - queryset = queryset.annotate( - campaign_started_at=Subquery(campaign_dates.values("campaign_started_at")), - campaign_ended_at=Subquery(campaign_dates.values("campaign_ended_at")), - ) - if campaign_status: today = datetime.now().date() if campaign_status.upper() == "ONGOING": @@ -157,6 +95,65 @@ def filter_queryset(self, request, queryset, view): return queryset.distinct() +class VaccineRepositorySerializer(serializers.Serializer): + country_name = serializers.CharField(source="campaign__country__name") + campaign_obr_name = serializers.CharField(source="campaign__obr_name") + round_id = serializers.IntegerField(source="id") + number = serializers.IntegerField() + start_date = serializers.DateField(source="started_at") + end_date = serializers.DateField(source="ended_at") + vaccine_name = serializers.CharField() + + vrf_data = serializers.SerializerMethodField() + pre_alert_data = serializers.SerializerMethodField() + form_a_data = serializers.SerializerMethodField() + + def get_vrf_data(self, obj): + vrfs = VaccineRequestForm.objects.filter( + campaign__id=obj["campaign__id"], rounds=obj["id"], vaccine_type=obj["vaccine_name"] + ) + return [ + { + "date": vrf.date_vrf_reception, + "file": vrf.document.url if vrf.document else None, + "is_missing": vrf.vrf_type == VaccineRequestFormType.MISSING, + "is_not_required": vrf.vrf_type == VaccineRequestFormType.NOT_REQUIRED, + "id": vrf.id, + } + for vrf in vrfs + ] + + def get_pre_alert_data(self, obj): + pre_alerts = VaccinePreAlert.objects.filter( + request_form__campaign=obj["campaign__id"], + request_form__rounds=obj["id"], + request_form__vaccine_type=obj["vaccine_name"], + ) + return [ + { + "date": pa.date_pre_alert_reception, + "file": pa.document.url if pa.document else None, + "vrf_id": pa.request_form.id, + } + for pa in pre_alerts + ] + + def get_form_a_data(self, obj): + form_as = OutgoingStockMovement.objects.filter( + campaign=obj["campaign__id"], round=obj["id"], vaccine_stock__vaccine=obj["vaccine_name"] + ) + return [ + { + "date": fa.form_a_reception_date, + "file": fa.document.url if fa.document else None, + "is_late": fa.form_a_reception_date > (obj["ended_at"] + timedelta(days=14)) + if fa.form_a_reception_date and obj["ended_at"] + else None, + } + for fa in form_as + ] + + class VaccineRepositoryViewSet(GenericViewSet, ListModelMixin): """ ViewSet for retrieving vaccine repository data. @@ -165,7 +162,7 @@ class VaccineRepositoryViewSet(GenericViewSet, ListModelMixin): serializer_class = VaccineRepositorySerializer pagination_class = Paginator filter_backends = [OrderingFilter, SearchFilter, VaccineReportingFilterBackend] - ordering_fields = ["campaign__country__name", "campaign__obr_name", "started_at"] + ordering_fields = ["campaign__country__name", "campaign__obr_name", "started_at", "vaccine_name", "number"] ordering = ["-started_at"] search_fields = ["campaign__country__name", "campaign__obr_name"] permission_classes = [permissions.IsAuthenticatedOrReadOnly] @@ -260,14 +257,59 @@ def get_queryset(self): """ Get the queryset for Round objects with their campaigns. """ + rounds_queryset = ( + Round.objects.filter( + campaign__isnull=False, + campaign__deleted_at__isnull=True, + campaign__campaign_types__name=CampaignType.POLIO, + ) + .select_related( + "campaign", + "campaign__country", + ) + .prefetch_related("scopes", "campaign__scopes") + ) - rounds_queryset = Round.objects.filter( - campaign__isnull=False, - campaign__deleted_at__isnull=True, - campaign__campaign_types__name=CampaignType.POLIO, - ).select_related( - "campaign", - "campaign__country", + # Get campaign dates subquery + campaign_dates = ( + Round.objects.filter(campaign=OuterRef("campaign")) + .values("campaign") + .annotate(campaign_started_at=Min("started_at"), campaign_ended_at=Max("ended_at")) + ) + + # Add campaign dates to main queryset + rounds_queryset = rounds_queryset.annotate( + campaign_started_at=Subquery(campaign_dates.values("campaign_started_at")), + campaign_ended_at=Subquery(campaign_dates.values("campaign_ended_at")), + ) + + # This query assumes that campaign__scopes is empty if separate scopes per round and vice-versa + # Fixed in POLIO-1770 + rounds_queryset = rounds_queryset.values( + "campaign__country__name", + "campaign__country__id", + "campaign__obr_name", + "campaign__scopes__id", + "campaign__scopes__vaccine", + "campaign__separate_scopes_per_round", + "id", + "campaign__id", + "started_at", + "ended_at", + "number", + "scopes__id", + "scopes__vaccine", + "campaign_started_at", + "campaign_ended_at", + ) + + # 393 results without filter + rounds_queryset = rounds_queryset.annotate( + vaccine_name=Case( + When(campaign__separate_scopes_per_round=False, then="campaign__scopes__vaccine"), + default="scopes__vaccine", + output_field=CharField(), + ) ) return rounds_queryset diff --git a/plugins/polio/js/src/constants/messages.ts b/plugins/polio/js/src/constants/messages.ts index c58f74982a..5724b09aa4 100644 --- a/plugins/polio/js/src/constants/messages.ts +++ b/plugins/polio/js/src/constants/messages.ts @@ -2446,6 +2446,26 @@ const MESSAGES = defineMessages({ id: 'iaso.polio.repository.title', defaultMessage: 'Vaccine Management Repository', }, + scopesWillBeDeleted: { + id: 'iaso.polio.campaign.label.scopesWillBeDeleted', + defaultMessage: 'The previous scopes will be deleted', + }, + scopeWarningTitle: { + id: 'iaso.polio.campaign.label.scopeWarningTitle', + defaultMessage: 'Scope type has been changed', + }, + proceed: { + id: 'blsq.label.proceed', + defaultMessage: 'proceed', + }, + doYouWantToClose: { + id: 'blsq.dialog.doYouWantToClose', + defaultMessage: 'Do you want to close?', + }, + unsavedDataWillBeLost: { + id: 'blsq.dialog.unsavedDataWillBeLost', + defaultMessage: 'Unsaved data will be lost', + }, }); export default MESSAGES; diff --git a/plugins/polio/js/src/constants/translations/en.json b/plugins/polio/js/src/constants/translations/en.json index 83f1b9f785..c9eb2c12d1 100644 --- a/plugins/polio/js/src/constants/translations/en.json +++ b/plugins/polio/js/src/constants/translations/en.json @@ -1,5 +1,8 @@ { "aso.polio.label.stockCorrection": "Stock correction", + "blsq.dialog.doYouWantToClose": "Do you want to close?", + "blsq.dialog.unsavedDataWillBeLost": "Unsaved data will be lost", + "blsq.label.proceed": "proceed", "iaso.forms.error.positiveInteger": "Please use a positive integer", "iaso.forms.error.requiredUuid": "Please use an UUID", "iaso.forms.options.budgetTypeError": "Value should be one of \"submission\", \"comments\" or \"validation\"", @@ -73,6 +76,8 @@ "iaso.polio.campaign.form": "Form", "iaso.polio.campaign.initial_org_unit": "Org Unit", "iaso.polio.campaign.key": "Key", + "iaso.polio.campaign.label.scopesWillBeDeleted": "The previous scopes will be deleted", + "iaso.polio.campaign.label.scopeWarningTitle": "Scope type has been changed", "iaso.polio.campaign.lqasMap": "LQAS map", "iaso.polio.campaign.mobilePayment": "Mobile Payment", "iaso.polio.campaign.restoreError": "Error restoring campaign", @@ -262,10 +267,10 @@ "iaso.polio.forms.options.approval": "Approval", "iaso.polio.forms.options.approval_ongoing": "Approval ongoing", "iaso.polio.import_file.label": "Excel Line File", - "iaso.polio.label.12months": "Last 12 months", "iaso.polio.label.3months": "Last 3 months", "iaso.polio.label.6months": "Last 6 months", "iaso.polio.label.9months": "Last 9 months", + "iaso.polio.label.12months": "Last 12 months", "iaso.polio.label.add": "Add", "iaso.polio.label.addDestruction": "Add destruction", "iaso.polio.label.addLink": "Add link", diff --git a/plugins/polio/js/src/constants/translations/fr.json b/plugins/polio/js/src/constants/translations/fr.json index 6bcd31293d..753cc964c4 100644 --- a/plugins/polio/js/src/constants/translations/fr.json +++ b/plugins/polio/js/src/constants/translations/fr.json @@ -1,4 +1,7 @@ { + "blsq.dialog.doYouWantToClose": "Voulez-vous fermer?", + "blsq.dialog.unsavedDataWillBeLost": "Les données non-sauvegardées seront perdues", + "blsq.label.proceed": "continuer", "iaso.forms.error.positiveInteger": "Veuillez utiliser un nombre entier positif", "iaso.forms.error.requiredUuid": "Veuillez utiliser un UUID", "iaso.forms.options.budgetTypeError": "Choisir une valeur parmis \"soumission\", \"commentaires\" ou \"validation\"", @@ -72,6 +75,8 @@ "iaso.polio.campaign.form": "Formulaire", "iaso.polio.campaign.initial_org_unit": "Org Unit", "iaso.polio.campaign.key": "Clé", + "iaso.polio.campaign.label.scopesWillBeDeleted": "Les périmètres précédents vont être effacés", + "iaso.polio.campaign.label.scopeWarningTitle": "Changement de type de périmètre", "iaso.polio.campaign.lqasMap": "Carte LQAS", "iaso.polio.campaign.mobilePayment": "Paiement mobile", "iaso.polio.campaign.restoreError": "Erreur lors de la restauration de la campagne", @@ -261,10 +266,10 @@ "iaso.polio.forms.options.approval": "Approbation", "iaso.polio.forms.options.approval_ongoing": "Approbation en cours", "iaso.polio.import_file.label": "Excel Line File", - "iaso.polio.label.12months": "12 derniers mois", "iaso.polio.label.3months": "3 derniers mois", "iaso.polio.label.6months": "6 derniers mois", "iaso.polio.label.9months": "9 derniers mois", + "iaso.polio.label.12months": "12 derniers mois", "iaso.polio.label.add": "Ajouter", "iaso.polio.label.addDestruction": "Ajouter une destruction", "iaso.polio.label.addLink": "Ajouter un lien", diff --git a/plugins/polio/js/src/domains/Campaigns/MainDialog/CreateEditDialog.tsx b/plugins/polio/js/src/domains/Campaigns/MainDialog/CreateEditDialog.tsx index e8ce938adc..3658ed59a5 100644 --- a/plugins/polio/js/src/domains/Campaigns/MainDialog/CreateEditDialog.tsx +++ b/plugins/polio/js/src/domains/Campaigns/MainDialog/CreateEditDialog.tsx @@ -32,6 +32,7 @@ import { useSaveCampaign } from '../hooks/api/useSaveCampaign'; import { useValidateCampaign } from '../hooks/useValidateCampaign'; import { PolioDialogTabs } from './PolioDialogTabs'; import { usePolioDialogTabs } from './usePolioDialogTabs'; +import { WarningModal } from './WarningModal'; type Props = { isOpen: boolean; @@ -58,6 +59,7 @@ const CreateEditDialog: FunctionComponent = ({ isOpen, ); const [isBackdropOpen, setIsBackdropOpen] = useState(false); + const [isScopeWarningOpen, setIsScopeWarningOpen] = useState(false); const [isUpdated, setIsUpdated] = useState(false); const { formatMessage } = useSafeIntl(); const classes: Record = useStyles(); @@ -132,6 +134,30 @@ const CreateEditDialog: FunctionComponent = ({ } onClose(); }; + + const handleConfirm = useCallback(() => { + // If scope type has changed + if ( + formik.values.separate_scopes_per_round !== + formik.initialValues.separate_scopes_per_round && + formik.values.id + ) { + // Open warning modal + setIsScopeWarningOpen(true); + } else { + formik.handleSubmit(); + } + // All hooks deps present, but ES-lint wants to add formik object, which is too much + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ + formik.handleSubmit, + formik.values.id, + formik.values.separate_scopes_per_round, + formik.initialValues.separate_scopes_per_round, + ]); + + const scopeWarningTitle = formatMessage(MESSAGES.scopeWarningTitle); + const scopeWarningBody = formatMessage(MESSAGES.scopesWillBeDeleted); const tabs = usePolioDialogTabs(formik, selectedCampaign); const [selectedTab, setSelectedTab] = useState(0); @@ -163,6 +189,14 @@ const CreateEditDialog: FunctionComponent = ({ closeDialog={() => setIsBackdropOpen(false)} onConfirm={() => handleClose()} /> + setIsScopeWarningOpen(false)} + onConfirm={() => formik.handleSubmit()} + dataTestId="scopewarning-modal" + /> @@ -224,7 +258,7 @@ const CreateEditDialog: FunctionComponent = ({ {formatMessage(MESSAGES.close)}