diff --git a/hat/assets/js/apps/Iaso/domains/app/translations/en.json b/hat/assets/js/apps/Iaso/domains/app/translations/en.json
index c986e73cd8..f03ca07a5f 100644
--- a/hat/assets/js/apps/Iaso/domains/app/translations/en.json
+++ b/hat/assets/js/apps/Iaso/domains/app/translations/en.json
@@ -405,9 +405,9 @@
"iaso.instance.coordinate": "Coordinates",
"iaso.instance.created_at": "Created in Iaso",
"iaso.instance.delete": "Delete instance(s)",
+ "iaso.instance.deleteCount": "Delete {count} instance(s)",
"iaso.instance.deleted_at": "Deleted at",
"iaso.instance.deleted_by": "Deleted by",
- "iaso.instance.deleteCount": "Delete {count} instance(s)",
"iaso.instance.deleteInstanceWarning": "This operation can still be undone",
"iaso.instance.deleteText": "Undelete is possible on submission detail page",
"iaso.instance.deleteTitle": "Are you sure you want to delete this submission?",
@@ -1172,6 +1172,8 @@
"iaso.permissions.iaso_polio_chronogram": "Polio chronogram - Read and Write",
"iaso.permissions.iaso_polio_chronogram_restricted_write": "Polio chronogram (user) - Restricted Write",
"iaso.permissions.iaso_polio_notifications": "Polio Notifications - Read and Write",
+ "iaso.permissions.iaso_polio_performance_permissions": "Performance dashboard",
+ "iaso.permissions.iaso_polio_performance_permissions_tooltip": "Allows to manage data for vaccine module's performance dashboard",
"iaso.permissions.iaso_polio_vaccine_stock_earmarks_admin": "Polio vaccine stock earmarks - Admin",
"iaso.permissions.iaso_polio_vaccine_stock_earmarks_admin_tooltip": "Edit and add vaccine stock earmarks data",
"iaso.permissions.iaso_polio_vaccine_stock_earmarks_nonadmin": "Polio vaccine stock earmarks - Non-admin",
@@ -1190,8 +1192,8 @@
"iaso.permissions.iaso_registry_write": "Registry - Write",
"iaso.permissions.iaso_saas_account_creation": "SaaS account creation",
"iaso.permissions.iaso_saas_account_creation_tooltip": "Allows to create new SaaS accounts and to check for account/user name availability",
- "iaso.permissions.iaso_stock_management": "Stock management",
- "iaso.permissions.iaso_stock_management_tooltip": "Allows to manage all aspects of stock management of items such as medical supplies or equipment",
+ "iaso.permissions.iaso_stock_management": "Stock management",
+ "iaso.permissions.iaso_stock_management_tooltip": "Allows to manage all aspects of stock management of items such as medical supplies or equipment",
"iaso.permissions.iaso_workflows": "Workflows",
"iaso.permissions.iaso_write_sources": "Geo data sources - Read and Write",
"iaso.permissions.links": "Geo data sources matching",
@@ -1794,4 +1796,4 @@
"trypelim.permissions.zones": "Zones",
"trypelim.permissions.zones_edit": "Edit zones",
"trypelim.permissions.zones_shapes_edit": "Edit zone shapes"
-}
+}
\ No newline at end of file
diff --git a/hat/assets/js/apps/Iaso/domains/app/translations/fr.json b/hat/assets/js/apps/Iaso/domains/app/translations/fr.json
index 1d70d077d2..ac14e79e32 100644
--- a/hat/assets/js/apps/Iaso/domains/app/translations/fr.json
+++ b/hat/assets/js/apps/Iaso/domains/app/translations/fr.json
@@ -406,9 +406,9 @@
"iaso.instance.coordinate": "Coordonnées",
"iaso.instance.created_at": "Création dans Iaso",
"iaso.instance.delete": "Effacer une(des) soumission(s)",
+ "iaso.instance.deleteCount": "Effacer {count} soumission(s)",
"iaso.instance.deleted_at": "Deleted at",
"iaso.instance.deleted_by": "Deleted by",
- "iaso.instance.deleteCount": "Effacer {count} soumission(s)",
"iaso.instance.deleteInstanceWarning": "Cette opération peut toujours être annulée.",
"iaso.instance.deleteText": "Restaurer la soumission est possible sur la page detail",
"iaso.instance.deleteTitle": "Êtes-vous certain de vouloir effacer cette soumission ?",
@@ -1173,6 +1173,8 @@
"iaso.permissions.iaso_polio_chronogram": "Chronogramme Polio - Lecture et écriture",
"iaso.permissions.iaso_polio_chronogram_restricted_write": "Chronogramme Polio (utilisateur) - Écriture restreinte",
"iaso.permissions.iaso_polio_notifications": "Notifications Polio - Lecture et écriture",
+ "iaso.permissions.iaso_polio_performance_permissions": "Performance dashboard",
+ "iaso.permissions.iaso_polio_performance_permissions_tooltip": "Allows to manage data for vaccine module's performance dashboard",
"iaso.permissions.iaso_polio_vaccine_stock_earmarks_admin": "Polio stock earmarks - Admin",
"iaso.permissions.iaso_polio_vaccine_stock_earmarks_admin_tooltip": "Editer et ajouter des données de stock earmarks",
"iaso.permissions.iaso_polio_vaccine_stock_earmarks_nonadmin": "Polio stock earmarks - Non-admin",
@@ -1191,8 +1193,8 @@
"iaso.permissions.iaso_registry_write": "Registre - Ecriture",
"iaso.permissions.iaso_saas_account_creation": "Création de compte SaaS",
"iaso.permissions.iaso_saas_account_creation_tooltip": "Permet de créer de nouveaux comptes SaaS et de vérifier la disponibilité des noms de compte/d'utilisateur",
- "iaso.permissions.iaso_stock_management": "Gestion des stocks",
- "iaso.permissions.iaso_stock_management_tooltip": "Permet d'accéder à tous les aspects de la gestion de stocks (équipement médical, fournitures, médicaments...)",
+ "iaso.permissions.iaso_stock_management": "Gestion des stocks",
+ "iaso.permissions.iaso_stock_management_tooltip": "Permet d'accéder à tous les aspects de la gestion de stocks (équipement médical, fournitures, médicaments...)",
"iaso.permissions.iaso_workflows": "Workflows",
"iaso.permissions.iaso_write_sources": "Sources de données géo - Lecture et écriture",
"iaso.permissions.links": "Liens entre sources de données géo",
@@ -1794,4 +1796,4 @@
"trypelim.permissions.zones": "Zones",
"trypelim.permissions.zones_edit": "Edit zones",
"trypelim.permissions.zones_shapes_edit": "Editer les contours géographiques des zones de santé"
-}
+}
\ No newline at end of file
diff --git a/hat/assets/js/apps/Iaso/domains/users/permissionsMessages.ts b/hat/assets/js/apps/Iaso/domains/users/permissionsMessages.ts
index 2e9c6e8195..a61f5e1971 100644
--- a/hat/assets/js/apps/Iaso/domains/users/permissionsMessages.ts
+++ b/hat/assets/js/apps/Iaso/domains/users/permissionsMessages.ts
@@ -805,6 +805,14 @@ const PERMISSIONS_MESSAGES = defineMessages({
iaso_stock_management_tooltip: {
id: 'iaso.permissions.iaso_stock_management_tooltip',
defaultMessage: 'Allows to manage all aspects of stock management of items such as medical supplies or equipment',
+ },
+ iaso_polio_performance_permissions: {
+ id: 'iaso.permissions.iaso_polio_performance_permissions',
+ defaultMessage: 'Performance dashboard',
+ },
+ iaso_polio_performance_permissions_tooltip: {
+ id: 'iaso.permissions.iaso_polio_performance_permissions_tooltip',
+ defaultMessage: "Allows to manage data for vaccine module's performance dashboard",
}
});
diff --git a/plugins/polio/api/perfomance_dashboard/__init__.py b/plugins/polio/api/perfomance_dashboard/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/plugins/polio/api/perfomance_dashboard/filters.py b/plugins/polio/api/perfomance_dashboard/filters.py
new file mode 100644
index 0000000000..aa8edf0236
--- /dev/null
+++ b/plugins/polio/api/perfomance_dashboard/filters.py
@@ -0,0 +1,23 @@
+import django_filters
+
+from plugins.polio.models.performance_dashboard import PerformanceDashboard
+
+
+class NumberInFilter(django_filters.BaseInFilter, django_filters.NumberFilter):
+ """
+ A filter that allows filtering by a comma-separated list of numbers.
+ e.g., ?country_blocks=1,2,3
+ """
+
+
+class PerformanceDashboardFilter(django_filters.FilterSet):
+ """
+ Filter for the Performance dashboard model.
+ """
+
+ country = django_filters.NumberFilter(field_name="country__id")
+ country_blocks = NumberInFilter(field_name="country__groups__id", lookup_expr="in")
+
+ class Meta:
+ model = PerformanceDashboard
+ fields = ["country", "country_blocks"]
diff --git a/plugins/polio/api/perfomance_dashboard/permissions.py b/plugins/polio/api/perfomance_dashboard/permissions.py
new file mode 100644
index 0000000000..9726702cf6
--- /dev/null
+++ b/plugins/polio/api/perfomance_dashboard/permissions.py
@@ -0,0 +1,63 @@
+import datetime
+
+from django.utils import timezone
+from rest_framework import permissions
+
+from plugins.polio.models.performance_dashboard import PerformanceDashboard
+from plugins.polio.permissions import (
+ POLIO_PERFORMANCE_ADMIN_PERMISSION,
+ POLIO_PERFORMANCE_NON_ADMIN_PERMISSION,
+ POLIO_PERFORMANCE_READ_ONLY_PERMISSION,
+)
+
+
+DAYS_OPEN_FOR_NON_ADMIN_EDIT = 7
+
+
+class PerformanceDashboardPermission(permissions.BasePermission):
+ """
+ Custom permission for the Performance Dashboard.
+ - Read-only users can only view data.
+ - Non-admin users can create data and modify recent data.
+ - Admin users have unrestricted access.
+ """
+
+ def has_permission(self, request, view):
+ """
+ View - level permissions.
+ - Read access for safe methods if user has any of the required permissions.
+ - Write access for unsafe methods if user has write or admin permissions.
+ """
+ if request.method in permissions.SAFE_METHODS:
+ return (
+ request.user.has_perm(POLIO_PERFORMANCE_READ_ONLY_PERMISSION.full_name())
+ or request.user.has_perm(POLIO_PERFORMANCE_NON_ADMIN_PERMISSION.full_name())
+ or request.user.has_perm(POLIO_PERFORMANCE_ADMIN_PERMISSION.full_name())
+ )
+ return request.user.has_perm(POLIO_PERFORMANCE_NON_ADMIN_PERMISSION.full_name()) or request.user.has_perm(
+ POLIO_PERFORMANCE_ADMIN_PERMISSION.full_name()
+ )
+
+ def has_object_permission(self, request, view, obj: PerformanceDashboard):
+ """
+ Object-level permissions, checked for retrieve, update, and delete actions.
+ - Admins can do anything.
+ - Read-only users can only view.
+ - Non-admins can only edit/delete recent objects.
+ """
+ if request.user.has_perm(POLIO_PERFORMANCE_ADMIN_PERMISSION.full_name()):
+ return True
+ if request.user.has_perm(POLIO_PERFORMANCE_NON_ADMIN_PERMISSION.full_name()):
+ if request.method in permissions.SAFE_METHODS or request.method == "POST":
+ # Read and create actions are allowed for non-admins.
+ return True
+
+ if request.method in ("PUT", "PATCH", "DELETE"):
+ # Non-admins can only modify objects created within the time window.
+ time_limit = timezone.now() - datetime.timedelta(days=DAYS_OPEN_FOR_NON_ADMIN_EDIT)
+ return obj.created_at >= time_limit
+
+ if request.user.has_perm(POLIO_PERFORMANCE_READ_ONLY_PERMISSION.full_name()):
+ return request.method in permissions.SAFE_METHODS
+
+ return False
diff --git a/plugins/polio/api/perfomance_dashboard/serializers.py b/plugins/polio/api/perfomance_dashboard/serializers.py
new file mode 100644
index 0000000000..2b8922e26d
--- /dev/null
+++ b/plugins/polio/api/perfomance_dashboard/serializers.py
@@ -0,0 +1,76 @@
+import logging
+
+from rest_framework import serializers
+
+from iaso.api.common import UserSerializer
+from iaso.models import OrgUnit
+from plugins.polio.models.performance_dashboard import PerformanceDashboard
+
+
+logger = logging.getLogger(__name__)
+
+
+class OrgUnitNestedSerializer(serializers.ModelSerializer):
+ class Meta:
+ model = OrgUnit
+ fields = ["id", "name"]
+ ref_name = "OrgUnitNestedSerializerForNationalLogisticsPlan"
+
+
+class PerformanceDashboardListSerializer(serializers.ModelSerializer):
+ created_by = UserSerializer(read_only=True)
+ updated_by = UserSerializer(read_only=True)
+ created_at = serializers.DateTimeField(read_only=True)
+ updated_at = serializers.DateTimeField(read_only=True)
+
+ country_name = serializers.CharField(source="country.name", read_only=True)
+
+ class Meta:
+ model = PerformanceDashboard
+ fields = [
+ "id",
+ "date",
+ "status",
+ "country_name",
+ "country_id",
+ "vaccine",
+ "account",
+ "created_at",
+ "created_by",
+ "updated_at",
+ "updated_by",
+ ]
+ read_only_fields = ["account"]
+ extra_kwargs = {"country_id": {"read_only": True}}
+
+
+class PerformanceDashboardWriteSerializer(serializers.ModelSerializer):
+ # Expect the country ID for write operations
+ country_id = serializers.PrimaryKeyRelatedField(source="country", queryset=OrgUnit.objects.all(), write_only=True)
+
+ class Meta:
+ model = PerformanceDashboard
+ fields = [
+ "id",
+ "date",
+ "status",
+ "country_id",
+ "vaccine",
+ ]
+
+ def create(self, validated_data):
+ request = self.context.get("request")
+ if not request:
+ raise serializers.ValidationError("Request context is missing")
+ user = request.user
+ validated_data["created_by"] = user
+ validated_data["account"] = user.iaso_profile.account
+ return super().create(validated_data)
+
+ def update(self, instance, validated_data):
+ request = self.context["request"]
+ if not request:
+ raise serializers.ValidationError("Request context is missing")
+
+ validated_data["updated_by"] = request.user
+ return super().update(instance, validated_data)
diff --git a/plugins/polio/api/perfomance_dashboard/views.py b/plugins/polio/api/perfomance_dashboard/views.py
new file mode 100644
index 0000000000..be6fddc7f0
--- /dev/null
+++ b/plugins/polio/api/perfomance_dashboard/views.py
@@ -0,0 +1,65 @@
+import django_filters
+
+from rest_framework import filters
+
+from iaso.api.common import ModelViewSet
+from plugins.polio.api.perfomance_dashboard.serializers import (
+ PerformanceDashboardListSerializer,
+ PerformanceDashboardWriteSerializer,
+)
+from plugins.polio.models.performance_dashboard import PerformanceDashboard
+
+from .filters import PerformanceDashboardFilter
+from .permissions import PerformanceDashboardPermission
+
+
+class PerformanceDashboardViewSet(ModelViewSet):
+ """
+ API endpoint for Performance Dashboard.
+
+ This endpoint supports filtering by:
+ - `country` (ID of the country OrgUnit)
+ - `country_block` (ID of the parent OrgUnit of the country, e.g., a region)
+ - `status` (draft, commented, final)
+ - `vaccine` (bOPV, nOPV2, Co-administration.)
+
+ The permissions are structured as follows:
+ - **Read-only**: Can only list and retrieve plans.
+ - **Non-admin**: Can create and update plans.
+ - **Admin**: Can delete plans.
+ """
+
+ permission_classes = [PerformanceDashboardPermission]
+ filter_backends = [
+ filters.OrderingFilter,
+ django_filters.rest_framework.DjangoFilterBackend,
+ ]
+ filterset_class = PerformanceDashboardFilter
+ ordering_fields = ["date", "country__name", "status", "vaccine", "updated_at"]
+ http_method_names = ["get", "post", "patch", "delete"]
+
+ def get_queryset(self):
+ """
+ Get the queryset for the view, filtered for the current user's account.
+ """
+ return (
+ PerformanceDashboard.objects.filter_for_user(self.request.user)
+ .select_related("country", "created_by", "updated_by")
+ .order_by("-date")
+ )
+
+ def get_serializer_class(self):
+ """
+ Dynamically returns the appropriate serializer class based on the action.
+ """
+ if self.action in ["create", "update", "partial_update"]:
+ return PerformanceDashboardWriteSerializer
+
+ return PerformanceDashboardListSerializer
+
+ def perform_destroy(self, instance):
+ """
+ Perform a soft delete and log the user who performed the action.
+ """
+ instance.updated_by = self.request.user
+ instance.delete()
diff --git a/plugins/polio/api/urls.py b/plugins/polio/api/urls.py
index 993fe2d20e..f63b9b5a2f 100644
--- a/plugins/polio/api/urls.py
+++ b/plugins/polio/api/urls.py
@@ -37,6 +37,7 @@
from plugins.polio.api.lqas_im.lqasim_global_map import LQASIMGlobalMapViewSet
from plugins.polio.api.lqas_im.lqasim_zoom_in_map import LQASIMZoominMapBackgroundViewSet, LQASIMZoominMapViewSet
from plugins.polio.api.notifications.views import NotificationViewSet
+from plugins.polio.api.perfomance_dashboard.views import PerformanceDashboardViewSet
from plugins.polio.api.polio_org_units import PolioOrgunitViewSet
from plugins.polio.api.rounds.reasons_for_delay import ReasonForDelayViewSet
from plugins.polio.api.rounds.round import RoundViewSet
@@ -68,6 +69,7 @@
router = routers.SimpleRouter()
+router.register(r"polio/performance_dashboard", PerformanceDashboardViewSet, basename="performance_dashboard")
router.register(r"polio/orgunits", PolioOrgunitViewSet, basename="PolioOrgunit")
router.register(r"polio/campaigns", CampaignViewSet, basename="Campaign")
router.register(r"polio/campaigns_subactivities", SubActivityViewSet, basename="campaigns_subactivities")
diff --git a/plugins/polio/js/src/constants/menu.tsx b/plugins/polio/js/src/constants/menu.tsx
index 4ed6d2d4ca..63bf1cabe6 100644
--- a/plugins/polio/js/src/constants/menu.tsx
+++ b/plugins/polio/js/src/constants/menu.tsx
@@ -1,6 +1,7 @@
import React from 'react';
import AccountBalanceWalletIcon from '@mui/icons-material/AccountBalanceWallet';
import AssessmentIcon from '@mui/icons-material/Assessment';
+import BarChartIcon from '@mui/icons-material/BarChart';
import CalendarToday from '@mui/icons-material/CalendarToday';
import DonutSmallIcon from '@mui/icons-material/DonutSmall';
import ExtensionIcon from '@mui/icons-material/Extension';
@@ -40,6 +41,7 @@ import {
stockManagementPath,
supplychainPath,
vaccineRepositoryPath,
+ performanceDashboardPath,
} from './routes';
export const menu: MenuItem[] = [
@@ -144,6 +146,12 @@ export const menu: MenuItem[] = [
permissions: stockManagementPath.permissions,
icon: props => ,
},
+ {
+ label: MESSAGES.performanceDashboard,
+ key: 'performanceDashboard',
+ permissions: performanceDashboardPath.permissions,
+ icon: props => ,
+ },
{
label: MESSAGES.vaccineRepository,
key: 'repository',
diff --git a/plugins/polio/js/src/constants/messages.ts b/plugins/polio/js/src/constants/messages.ts
index 6519b09cdf..2f133e3d01 100644
--- a/plugins/polio/js/src/constants/messages.ts
+++ b/plugins/polio/js/src/constants/messages.ts
@@ -66,6 +66,10 @@ const MESSAGES = defineMessages({
defaultMessage: 'Campaigns',
id: 'iaso.polio.label.campaigns',
},
+ performanceDashboard: {
+ id: 'iaso.polio.performanceDashboard',
+ defaultMessage: 'Performance Dashboard',
+ },
campaign: {
defaultMessage: 'Campaign',
id: 'iaso.polio.label.campaign',
diff --git a/plugins/polio/js/src/constants/permissions.ts b/plugins/polio/js/src/constants/permissions.ts
index 6bef6a4912..a6a3733bdb 100644
--- a/plugins/polio/js/src/constants/permissions.ts
+++ b/plugins/polio/js/src/constants/permissions.ts
@@ -11,6 +11,12 @@ const STOCK_MANAGEMENT_READ = 'iaso_polio_vaccine_stock_management_read';
const STOCK_MANAGEMENT_READ_ONLY =
'iaso_polio_vaccine_stock_management_read_only';
const STOCK_MANAGEMENT_WRITE = 'iaso_polio_vaccine_stock_management_write';
+
+const POLIO_PERFORMANCE_READ_ONLY_PERMISSION =
+ 'iaso_polio_performance_read_only';
+const POLIO_PERFORMANCE_NON_ADMIN_PERMISSION =
+ 'iaso_polio_performance_non_admin';
+const POLIO_PERFORMANCE_ADMIN_PERMISSION = 'iaso_polio_performance_admin';
const NOTIFICATION = 'iaso_polio_notifications';
const CHRONOGRAM = 'iaso_polio_chronogram';
const CHRONOGRAM_RESTRICTED_WRITE = 'iaso_polio_chronogram_restricted_write';
@@ -30,6 +36,9 @@ export {
STOCK_MANAGEMENT_READ,
STOCK_MANAGEMENT_WRITE,
STOCK_MANAGEMENT_READ_ONLY,
+ POLIO_PERFORMANCE_READ_ONLY_PERMISSION,
+ POLIO_PERFORMANCE_NON_ADMIN_PERMISSION,
+ POLIO_PERFORMANCE_ADMIN_PERMISSION,
NOTIFICATION,
CHRONOGRAM,
CHRONOGRAM_RESTRICTED_WRITE,
diff --git a/plugins/polio/js/src/constants/routes.tsx b/plugins/polio/js/src/constants/routes.tsx
index 5f79d32dd8..7c47269095 100644
--- a/plugins/polio/js/src/constants/routes.tsx
+++ b/plugins/polio/js/src/constants/routes.tsx
@@ -20,6 +20,7 @@ import { LqasAfroOverview } from '../domains/LQAS-IM/LQAS/LqasAfroOverview/LqasA
import { Notifications } from '../domains/Notifications';
import { Nopv2AuthorisationsDetails } from '../domains/VaccineModule/Nopv2Authorisations/Details/Nopv2AuthorisationsDetails';
import { Nopv2Authorisations } from '../domains/VaccineModule/Nopv2Authorisations/Nopv2Authorisations';
+import { PerformanceDashboard } from '../domains/VaccineModule/PerformanceDashboard/PerformanceDashboard';
import { VaccineRepository } from '../domains/VaccineModule/Repository/VaccineRepository';
import { VaccineStockManagementDetails } from '../domains/VaccineModule/StockManagement/Details/VaccineStockManagementDetails';
import { PublicVaccineStock } from '../domains/VaccineModule/StockManagement/PublicPage/PublicVaccineStock';
@@ -41,6 +42,9 @@ import {
SUPPLYCHAIN_READ,
SUPPLYCHAIN_READ_ONLY,
SUPPLYCHAIN_WRITE,
+ POLIO_PERFORMANCE_READ_ONLY_PERMISSION,
+ POLIO_PERFORMANCE_NON_ADMIN_PERMISSION,
+ POLIO_PERFORMANCE_ADMIN_PERMISSION,
} from './permissions';
import {
EMBEDDED_CALENDAR_URL,
@@ -265,6 +269,16 @@ export const chronogramDetailsPath: RoutePath = {
element: ,
permissions: [CHRONOGRAM, CHRONOGRAM_RESTRICTED_WRITE],
};
+export const performanceDashboardPath: RoutePath = {
+ baseUrl: baseUrls.performanceDashboard,
+ routerUrl: `${baseUrls.performanceDashboard}/*`,
+ element: ,
+ permissions: [
+ POLIO_PERFORMANCE_READ_ONLY_PERMISSION,
+ POLIO_PERFORMANCE_NON_ADMIN_PERMISSION,
+ POLIO_PERFORMANCE_ADMIN_PERMISSION,
+ ],
+};
export const routes: (RoutePath | AnonymousRoutePath)[] = [
campaignsPath,
@@ -295,5 +309,6 @@ export const routes: (RoutePath | AnonymousRoutePath)[] = [
chronogramPath,
chronogramTemplateTaskPath,
chronogramDetailsPath,
+ performanceDashboardPath,
embeddedLqasCountryPath,
];
diff --git a/plugins/polio/js/src/constants/translations/en.json b/plugins/polio/js/src/constants/translations/en.json
index 9b2cea3f23..c000be5018 100644
--- a/plugins/polio/js/src/constants/translations/en.json
+++ b/plugins/polio/js/src/constants/translations/en.json
@@ -18,6 +18,7 @@
"iaso.label.actionType": "Action type",
"iaso.label.doses_in": "Doses IN",
"iaso.label.doses_out": "Doses OUT",
+ "iaso.label.edit": "Edit",
"iaso.label.other": "Other",
"iaso.label.publicVaccineStock.\toutgoing_stock_movement": "Form A",
"iaso.label.publicVaccineStock.outgoing_stock_movement": "Form A",
@@ -50,6 +51,7 @@
"iaso.permissions.tooltip.iaso_polio_notifications": "Manage polio notifications - Read and Write",
"iaso.permissions.tooltip.polio_vaccine_authorizations_admin": "Manage polio vaccine authorizations - Read and Write",
"iaso.permissions.tooltip.polio_vaccine_authorizations_read_only": "Manage polio vaccine authorizations - Read Only",
+ "iaso.polio.antigen": "Vaccine",
"iaso.polio.api.arrival_reportsApiSuccess": "Arrival report(s) sucessfully updated",
"iaso.polio.api.pre_alertsApiSuccess": "Pre-alert(s) sucessfully updated",
"iaso.polio.api.vrfApiSuccess": "VRF sucessfully updated",
@@ -280,10 +282,10 @@
"iaso.polio.forms.options.approval_ongoing": "Approval ongoing",
"iaso.polio.hideTestCampaigns": "Hide test campaigns",
"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",
@@ -803,6 +805,14 @@
"iaso.polio.notifications.title": "Virus notification",
"iaso.polio.notifications.validation.field_required": "This field is required",
"iaso.polio.notifications.validation.invalid_date": "Invalid date",
+ "iaso.polio.performance.add": "Add Performance Data",
+ "iaso.polio.performance.delete": "Delete Performance Data: {name}",
+ "iaso.polio.performance.deleteWarning": "Are you sure you want to delete this entry?",
+ "iaso.polio.performance.edit": "Edit Performance Data",
+ "iaso.polio.performance.status.commented": "Commented",
+ "iaso.polio.performance.status.draft": "Draft",
+ "iaso.polio.performance.status.final": "Final",
+ "iaso.polio.performanceDashboard": "Performance Dashboard",
"iaso.polio.periods.month": "Month",
"iaso.polio.periods.quarter": "Quarter",
"iaso.polio.periods.semester": "Semester",
@@ -867,7 +877,9 @@
"iaso.polio.tooltip.label.TEST_CAMPAIGN": "This campaign has been marked as test campaign",
"iaso.polio.vaccine": "Vaccine",
"iaso.polio.vaccines": "Vaccines",
+ "iaso.polio.validation.fieldRequired": "This field is required",
"iaso.polio.validation.futureDateError": "This date should not be in the future",
+ "iaso.polio.validation.invalidDate": "Invalid date",
"iaso.polio.validation.onsetAfterNotificationError": "Date of onset should be before virus notification",
"iaso.polio.validation.onsetAfterOutbreakDeclarationError": "Date of onset should be before outbreak declaration",
"iaso.polio.validation.virusNotificationAfterOutbreakDeclarationError": "Virus notification should be before outbreak declaration",
diff --git a/plugins/polio/js/src/constants/translations/fr.json b/plugins/polio/js/src/constants/translations/fr.json
index abc7c35711..d75e0b823b 100644
--- a/plugins/polio/js/src/constants/translations/fr.json
+++ b/plugins/polio/js/src/constants/translations/fr.json
@@ -18,6 +18,7 @@
"iaso.label.actionType": "Type d'action",
"iaso.label.doses_in": "Doses ENTREES",
"iaso.label.doses_out": "Doses SORTIES",
+ "iaso.label.edit": "Modifier",
"iaso.label.other": "Autre",
"iaso.label.publicVaccineStock.outgoing_stock_movement": "Form A",
"iaso.label.publicVaccineStock.PO": "PO",
@@ -49,6 +50,7 @@
"iaso.permissions.tooltip.iaso_polio_notifications": "Gérer les notifications de polio - Lecture et écriture",
"iaso.permissions.tooltip.polio_vaccine_authorizations_admin": "Gérer les autorisations de vaccins polio – Lecture et écriture",
"iaso.permissions.tooltip.polio_vaccine_authorizations_read_only": "Gérer les autorisations de vaccins polio - Lecture seule",
+ "iaso.polio.antigen": "Vaccin",
"iaso.polio.api.arrival_reportsApiSuccess": "rapport(s) d'arrivée mis à jour",
"iaso.polio.api.pre_alertsApiSuccess": "Pré-alerte(s) mise(s) à jour",
"iaso.polio.api.vrfApiSuccess": "VRF mis à jour",
@@ -279,10 +281,10 @@
"iaso.polio.forms.options.approval_ongoing": "Approbation en cours",
"iaso.polio.hideTestCampaigns": "Masquer les campagnes de test",
"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",
@@ -802,6 +804,14 @@
"iaso.polio.notifications.title": "Notification des virus",
"iaso.polio.notifications.validation.field_required": "Ce champ est obligatoire",
"iaso.polio.notifications.validation.invalid_date": "Invalid date",
+ "iaso.polio.performance.add": "Ajouter une donnée de performance",
+ "iaso.polio.performance.delete": "Effacer les données de performance: {name}",
+ "iaso.polio.performance.deleteWarning": "Voulez-vous vraiment supprimer cette entrée ?",
+ "iaso.polio.performance.edit": "Modifier les données de performance",
+ "iaso.polio.performance.status.commented": "Commenté",
+ "iaso.polio.performance.status.draft": "Ébauche",
+ "iaso.polio.performance.status.final": "Final",
+ "iaso.polio.performanceDashboard": "Tableau des performances",
"iaso.polio.periods.month": "Mois",
"iaso.polio.periods.quarter": "Trimestre",
"iaso.polio.periods.semester": "Semestre",
@@ -866,7 +876,9 @@
"iaso.polio.tooltip.label.TEST_CAMPAIGN": "Cette campagne a été marquée comme campagne de test",
"iaso.polio.vaccine": "Vaccin",
"iaso.polio.vaccines": "Vaccins",
+ "iaso.polio.validation.fieldRequired": "Champ requis",
"iaso.polio.validation.futureDateError": "Cette date ne peut être dans le futur",
+ "iaso.polio.validation.invalidDate": "Date invalide",
"iaso.polio.validation.onsetAfterNotificationError": "La date de début doit précéder la date de notification virus",
"iaso.polio.validation.onsetAfterOutbreakDeclarationError": "La date de début doit précéder la date de déclaration de l'épidémie",
"iaso.polio.validation.virusNotificationAfterOutbreakDeclarationError": "La date de notification virus doit précéder la déclaration de l'épidémie",
diff --git a/plugins/polio/js/src/constants/urls.ts b/plugins/polio/js/src/constants/urls.ts
index 224897a877..0d6c1728fc 100644
--- a/plugins/polio/js/src/constants/urls.ts
+++ b/plugins/polio/js/src/constants/urls.ts
@@ -47,6 +47,7 @@ export const NOTIFICATIONS_BASE_URL = 'polio/notifications';
export const CHRONOGRAM_BASE_URL = `${VACCINE_MODULE}/chronogram`;
export const CHRONOGRAM_TEMPLATE_TASK = `${CHRONOGRAM_BASE_URL}/templateTask`;
export const CHRONOGRAM_DETAILS = `${CHRONOGRAM_BASE_URL}/details`;
+export const PERFORMANCE_DASHBOARD = `${VACCINE_MODULE}/performancedashboard`;
export const campaignParams = [
'countries',
@@ -352,6 +353,10 @@ export const polioRouteConfigs: Record = {
'status',
],
},
+ performanceDashboard: {
+ url: PERFORMANCE_DASHBOARD,
+ params: [...paginationPathParams, 'country_blocks', 'country'],
+ },
};
export type PolioBaseUrls = {
@@ -384,6 +389,7 @@ export type PolioBaseUrls = {
chronogram: string;
chronogramTemplateTask: string;
chronogramDetails: string;
+ performanceDashboard: string;
};
export const baseUrls = extractUrls(polioRouteConfigs) as PolioBaseUrls;
export const baseParams = extractParams(polioRouteConfigs);
diff --git a/plugins/polio/js/src/domains/VaccineModule/PerformanceDashboard/PerformanceDashboard.tsx b/plugins/polio/js/src/domains/VaccineModule/PerformanceDashboard/PerformanceDashboard.tsx
new file mode 100644
index 0000000000..6f89ceae2e
--- /dev/null
+++ b/plugins/polio/js/src/domains/VaccineModule/PerformanceDashboard/PerformanceDashboard.tsx
@@ -0,0 +1,57 @@
+import React, { FunctionComponent } from 'react';
+import { Box, Grid } from '@mui/material';
+import {
+ useSafeIntl,
+ UrlParams,
+} from 'bluesquare-components';
+import { DisplayIfUserHasPerm } from '../../../../../../../hat/assets/js/apps/Iaso/components/DisplayIfUserHasPerm';
+import TopBar from '../../../../../../../hat/assets/js/apps/Iaso/components/nav/TopBarComponent';
+import { useParamsObject } from '../../../../../../../hat/assets/js/apps/Iaso/routing/hooks/useParamsObject';
+import {
+ POLIO_PERFORMANCE_ADMIN_PERMISSION,
+ POLIO_PERFORMANCE_NON_ADMIN_PERMISSION,
+} from '../../../constants/permissions';
+import { baseUrls } from '../../../constants/urls';
+import { useStyles } from '../../../styles/theme';
+import { PerformanceDashboardFilters } from './filters/PerformanceDashboardFilters';
+import MESSAGES from './messages';
+import { CreatePerformanceModal } from './modals/CreateEditModal';
+import { PerformanceDashboardTable } from './table/PerformanceDashboardTable';
+
+type PerformanceDashboardParams = {
+ country?: string;
+ country_blocks?: string;
+} & Partial;
+
+export const PerformanceDashboard: FunctionComponent = () => {
+ const params = useParamsObject(
+ baseUrls.performanceDashboard,
+ ) as PerformanceDashboardParams;
+ const { formatMessage } = useSafeIntl();
+ const classes: Record = useStyles();
+
+ return (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+
+ >
+ );
+};
diff --git a/plugins/polio/js/src/domains/VaccineModule/PerformanceDashboard/filters/PerformanceDashboardFilters.tsx b/plugins/polio/js/src/domains/VaccineModule/PerformanceDashboard/filters/PerformanceDashboardFilters.tsx
new file mode 100644
index 0000000000..3ae94724fb
--- /dev/null
+++ b/plugins/polio/js/src/domains/VaccineModule/PerformanceDashboard/filters/PerformanceDashboardFilters.tsx
@@ -0,0 +1,71 @@
+import React, { FunctionComponent } from 'react';
+import { Box, Grid } from '@mui/material';
+import { UrlParams, useSafeIntl } from 'bluesquare-components';
+import InputComponent from '../../../../../../../../hat/assets/js/apps/Iaso/components/forms/InputComponent';
+import { SearchButton } from '../../../../../../../../hat/assets/js/apps/Iaso/components/SearchButton';
+import { useGetGroupDropdown } from '../../../../../../../../hat/assets/js/apps/Iaso/domains/orgUnits/hooks/requests/useGetGroups';
+import { useFilterState } from '../../../../../../../../hat/assets/js/apps/Iaso/hooks/useFilterState';
+import { baseUrls } from '../../../../constants/urls';
+import { useGetCountriesOptions } from '../../SupplyChain/hooks/api/vrf';
+import MESSAGES from '../messages';
+
+export const usePerformanceDashboardFilters = (params: Partial) => {
+ return useFilterState({ baseUrl: baseUrls.performanceDashboard, params });
+};
+
+type Props = { params: any };
+
+export const PerformanceDashboardFilters: FunctionComponent = ({
+ params,
+}) => {
+ const { formatMessage } = useSafeIntl();
+ const { filters, handleSearch, handleChange, filtersUpdated } =
+ usePerformanceDashboardFilters(params);
+ const { data: countries, isFetching: isFetchingCountries } =
+ useGetCountriesOptions();
+ const { data: groupedOrgUnits, isFetching: isFetchingGroupedOrgUnits } =
+ useGetGroupDropdown({ blockOfCountries: 'true' });
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/plugins/polio/js/src/domains/VaccineModule/PerformanceDashboard/hooks/api/index.ts b/plugins/polio/js/src/domains/VaccineModule/PerformanceDashboard/hooks/api/index.ts
new file mode 100644
index 0000000000..b1ef20a0e8
--- /dev/null
+++ b/plugins/polio/js/src/domains/VaccineModule/PerformanceDashboard/hooks/api/index.ts
@@ -0,0 +1,3 @@
+export * from './useGetPerformance';
+export * from './useSavePerformance';
+export * from './useDeletePerformance';
diff --git a/plugins/polio/js/src/domains/VaccineModule/PerformanceDashboard/hooks/api/useDeletePerformance.ts b/plugins/polio/js/src/domains/VaccineModule/PerformanceDashboard/hooks/api/useDeletePerformance.ts
new file mode 100644
index 0000000000..c3a480f225
--- /dev/null
+++ b/plugins/polio/js/src/domains/VaccineModule/PerformanceDashboard/hooks/api/useDeletePerformance.ts
@@ -0,0 +1,22 @@
+import { useQueryClient } from 'react-query';
+import { deleteRequest } from '../../../../../../../../../hat/assets/js/apps/Iaso/libs/Api';
+import { useSnackMutation } from '../../../../../../../../../hat/assets/js/apps/Iaso/libs/apiHooks';
+
+const deletePerformanceDashboard = (id: number) => {
+ return deleteRequest(`/api/polio/performance_dashboard/${id}/`);
+};
+
+export const useDeletePerformance = () => {
+ const queryClient = useQueryClient();
+ return useSnackMutation(
+ (id: number) => deletePerformanceDashboard(id),
+ undefined,
+ undefined,
+ ['performance-dashboard'], // Invalidate the list query after deletion
+ {
+ onSuccess: () => {
+ queryClient.invalidateQueries(['performance-dashboard']);
+ },
+ },
+ );
+};
diff --git a/plugins/polio/js/src/domains/VaccineModule/PerformanceDashboard/hooks/api/useGetPerformance.ts b/plugins/polio/js/src/domains/VaccineModule/PerformanceDashboard/hooks/api/useGetPerformance.ts
new file mode 100644
index 0000000000..cc6fd7bf73
--- /dev/null
+++ b/plugins/polio/js/src/domains/VaccineModule/PerformanceDashboard/hooks/api/useGetPerformance.ts
@@ -0,0 +1,28 @@
+import { useSnackQuery } from 'Iaso/libs/apiHooks';
+import { useApiParams } from '../../../../../../../../../hat/assets/js/apps/Iaso/hooks/useApiParams';
+import { useUrlParams } from '../../../../../../../../../hat/assets/js/apps/Iaso/hooks/useUrlParams';
+import { getRequest } from '../../../../../../../../../hat/assets/js/apps/Iaso/libs/Api';
+
+import { PerformanceList, PerformanceData } from '../../types';
+
+const getPerformanceDashboard = (params: any) => {
+ const queryString = new URLSearchParams(params).toString();
+ return getRequest(`/api/polio/performance_dashboard/?${queryString}`);
+};
+
+export const useGetPerformanceDashboard = (params: any) => {
+ const safeParams = useUrlParams(params);
+ const apiParams = useApiParams(safeParams);
+
+ return useSnackQuery(
+ [
+ 'performance-dashboard', apiParams
+ ],
+ () => getPerformanceDashboard(apiParams),
+ {
+ keepPreviousData: true,
+ staleTime: 1000 * 60 * 5, // 5 minutes
+ cacheTime: 1000 * 60 * 5,
+ },
+ );
+};
diff --git a/plugins/polio/js/src/domains/VaccineModule/PerformanceDashboard/hooks/api/useSavePerformance.ts b/plugins/polio/js/src/domains/VaccineModule/PerformanceDashboard/hooks/api/useSavePerformance.ts
new file mode 100644
index 0000000000..a1dfc9f42d
--- /dev/null
+++ b/plugins/polio/js/src/domains/VaccineModule/PerformanceDashboard/hooks/api/useSavePerformance.ts
@@ -0,0 +1,24 @@
+import {
+ postRequest,
+ patchRequest,
+} from '../../../../../../../../../hat/assets/js/apps/Iaso/libs/Api';
+import { useSnackMutation } from '../../../../../../../../../hat/assets/js/apps/Iaso/libs/apiHooks';
+import { PerformanceData } from '../../types';
+
+const savePerformanceDashboard = (data: Partial) => {
+ if (data.id) {
+ return patchRequest(
+ `/api/polio/performance_dashboard/${data.id}/`,
+ data,
+ );
+ }
+ return postRequest('/api/polio/performance_dashboard/', data);
+};
+
+export const useSavePerformance = () => {
+ return useSnackMutation({
+ mutationFn: (data: Partial) =>
+ savePerformanceDashboard(data),
+ invalidateQueryKey: ['performance-dashboard'],
+ });
+};
diff --git a/plugins/polio/js/src/domains/VaccineModule/PerformanceDashboard/hooks/options.ts b/plugins/polio/js/src/domains/VaccineModule/PerformanceDashboard/hooks/options.ts
new file mode 100644
index 0000000000..23201671cb
--- /dev/null
+++ b/plugins/polio/js/src/domains/VaccineModule/PerformanceDashboard/hooks/options.ts
@@ -0,0 +1,25 @@
+import { useMemo } from 'react';
+import { useSafeIntl } from 'bluesquare-components';
+import { DropdownOptions } from '../../../../../../../../hat/assets/js/apps/Iaso/types/utils';
+import MESSAGES from '../messages';
+import { defaultVaccineOptions } from '../../SupplyChain/constants';
+
+const statuses: string[] = ['draft', 'commented', 'final'];
+
+export const useStatusOptions = (): DropdownOptions[] => {
+ const { formatMessage } = useSafeIntl();
+ return useMemo(() => {
+ return statuses.map(status => {
+ return {
+ value: status,
+ label: MESSAGES[status.toLowerCase()]
+ ? formatMessage(MESSAGES[status.toLowerCase()])
+ : status,
+ };
+ });
+ }, [formatMessage]);
+};
+
+export const useVaccineOptions = (): DropdownOptions[] => {
+ return defaultVaccineOptions.filter(option => option.value !== 'mOPV2');
+};
diff --git a/plugins/polio/js/src/domains/VaccineModule/PerformanceDashboard/messages.ts b/plugins/polio/js/src/domains/VaccineModule/PerformanceDashboard/messages.ts
new file mode 100644
index 0000000000..b413137a29
--- /dev/null
+++ b/plugins/polio/js/src/domains/VaccineModule/PerformanceDashboard/messages.ts
@@ -0,0 +1,98 @@
+import { defineMessages } from 'react-intl';
+
+const MESSAGES = defineMessages({
+ performanceDashboard: {
+ id: 'iaso.polio.performanceDashboard',
+ defaultMessage: 'Performance Dashboard',
+ },
+ country: {
+ id: 'iaso.polio.table.label.country',
+ defaultMessage: 'Country',
+ },
+ countryBlock: {
+ defaultMessage: 'Country block',
+ id: 'iaso.polio.label.countryBlock',
+ },
+ date: {
+ id: 'iaso.polio.form.label.date',
+ defaultMessage: 'Date',
+ },
+ status: {
+ id: 'iaso.polio.table.label.status',
+ defaultMessage: 'Status',
+ },
+ antigen: {
+ id: 'iaso.polio.antigen',
+ defaultMessage: 'Vaccine',
+ },
+ createdAt: {
+ id: 'iaso.forms.created_at',
+ defaultMessage: 'Created',
+ },
+ updatedAt: {
+ id: 'iaso.forms.updated_at',
+ defaultMessage: 'Updated',
+ },
+ actions: {
+ id: 'iaso.label.actions',
+ defaultMessage: 'Actions',
+ },
+ edit: {
+ id: 'iaso.label.edit',
+ defaultMessage: 'Edit',
+ },
+ deletePerformance: {
+ id: 'iaso.polio.performance.delete',
+ defaultMessage: 'Delete Performance Data: {name}',
+ },
+ deletePerformanceWarning: {
+ id: 'iaso.polio.performance.deleteWarning',
+ defaultMessage: 'Are you sure you want to delete this entry?',
+ },
+ invalidDate: {
+ id: 'iaso.polio.validation.invalidDate',
+ defaultMessage: 'Invalid date',
+ },
+ requiredField: {
+ id: 'iaso.polio.validation.fieldRequired',
+ defaultMessage: 'This field is required',
+ },
+ editPerformance: {
+ id: 'iaso.polio.performance.edit',
+ defaultMessage: 'Edit Performance Data',
+ },
+ addPerformance: {
+ id: 'iaso.polio.performance.add',
+ defaultMessage: 'Add Performance Data',
+ },
+ confirm: {
+ id: 'iaso.label.confirm',
+ defaultMessage: 'Confirm',
+ },
+ cancel: {
+ id: 'iaso.label.cancel',
+ defaultMessage: 'Cancel',
+ },
+ yes: {
+ id: 'iaso.label.yes',
+ defaultMessage: 'Yes',
+ },
+ no: {
+ id: 'iaso.label.no',
+ defaultMessage: 'No',
+ },
+ draft: {
+ id: 'iaso.polio.performance.status.draft',
+ defaultMessage: 'Draft',
+ },
+ commented: {
+ id: 'iaso.polio.performance.status.commented',
+ defaultMessage: 'Commented',
+ },
+ final: {
+ id: 'iaso.polio.performance.status.final',
+ defaultMessage: 'Final',
+ },
+});
+
+export default MESSAGES;
diff --git a/plugins/polio/js/src/domains/VaccineModule/PerformanceDashboard/modals/CreateEditModal.tsx b/plugins/polio/js/src/domains/VaccineModule/PerformanceDashboard/modals/CreateEditModal.tsx
new file mode 100644
index 0000000000..1e58f6d47b
--- /dev/null
+++ b/plugins/polio/js/src/domains/VaccineModule/PerformanceDashboard/modals/CreateEditModal.tsx
@@ -0,0 +1,139 @@
+import React, { FunctionComponent } from 'react';
+import { Box, Divider } from '@mui/material';
+import {
+ AddButton,
+ ConfirmCancelModal,
+ makeFullModal,
+ useSafeIntl,
+} from 'bluesquare-components';
+import { Field, FormikProvider, useFormik } from 'formik';
+import { isEqual } from 'lodash';
+
+import { EditIconButton } from '../../../../../../../../hat/assets/js/apps/Iaso/components/Buttons/EditIconButton';
+import { DateInput } from '../../../../components/Inputs/DateInput';
+import { SingleSelect } from '../../../../components/Inputs/SingleSelect';
+import { useGetCountriesOptions } from '../../SupplyChain/hooks/api/vrf';
+import { useSavePerformance } from '../hooks/api';
+import { useVaccineOptions, useStatusOptions } from '../hooks/options';
+import MESSAGES from '../messages';
+import { PerformanceData } from '../types';
+import { usePerformanceDashboardSchema } from './validation';
+
+type Props = {
+ isOpen: boolean;
+ closeDialog: () => void;
+ performanceData?: PerformanceData;
+};
+
+const CreateEditPerformanceModal: FunctionComponent = ({
+ isOpen,
+ closeDialog,
+ performanceData,
+}) => {
+ const { formatMessage } = useSafeIntl();
+ const { mutate: save } = useSavePerformance();
+ const schema = usePerformanceDashboardSchema();
+
+ const { data: countries, isFetching: isFetchingCountries } =
+ useGetCountriesOptions();
+ const statusOptions = useStatusOptions();
+ const vaccineOptions = useVaccineOptions();
+
+ const formik = useFormik({
+ initialValues: {
+ id: performanceData?.id,
+ date: performanceData?.date,
+ status: performanceData?.status,
+ country_id: performanceData?.country_id,
+ vaccine: performanceData?.vaccine,
+ },
+ enableReinitialize: true,
+ validateOnBlur: true,
+ validationSchema: schema,
+ onSubmit: values => {
+ save(values, {
+ onSuccess: () => {
+ closeDialog();
+ },
+ });
+ },
+ });
+
+ const isFormChanged = !isEqual(formik.values, formik.initialValues);
+ const allowConfirm =
+ !formik.isSubmitting && formik.isValid && isFormChanged;
+
+ const title = performanceData?.id
+ ? formatMessage(MESSAGES.editPerformance)
+ : formatMessage(MESSAGES.addPerformance);
+
+ return (
+
+ null}
+ id="create-edit-performance"
+ dataTestId="create-edit-performance"
+ titleMessage={title}
+ onConfirm={() => formik.handleSubmit()}
+ onCancel={() => null}
+ confirmMessage={MESSAGES.confirm}
+ allowConfirm={allowConfirm}
+ cancelMessage={MESSAGES.cancel}
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+const modalWithIcon = makeFullModal(CreateEditPerformanceModal, EditIconButton);
+const modalWithButton = makeFullModal(CreateEditPerformanceModal, AddButton);
+
+export { modalWithIcon as EditPerformanceModal };
+export { modalWithButton as CreatePerformanceModal };
diff --git a/plugins/polio/js/src/domains/VaccineModule/PerformanceDashboard/modals/validation.ts b/plugins/polio/js/src/domains/VaccineModule/PerformanceDashboard/modals/validation.ts
new file mode 100644
index 0000000000..2e84cfd819
--- /dev/null
+++ b/plugins/polio/js/src/domains/VaccineModule/PerformanceDashboard/modals/validation.ts
@@ -0,0 +1,27 @@
+import * as yup from 'yup';
+import { useSafeIntl } from 'bluesquare-components';
+import MESSAGES from '../messages';
+
+export const usePerformanceDashboardSchema = () => {
+ const { formatMessage } = useSafeIntl();
+ return yup.object().shape({
+ date: yup
+ .date()
+ .typeError(formatMessage(MESSAGES.invalidDate))
+ .nullable()
+ .required(formatMessage(MESSAGES.requiredField)),
+ status: yup
+ .string()
+ .nullable()
+ .required(formatMessage(MESSAGES.requiredField)),
+ country_id: yup
+ .number()
+ .positive()
+ .nullable()
+ .required(formatMessage(MESSAGES.requiredField)),
+ vaccine: yup
+ .string()
+ .nullable()
+ .required(formatMessage(MESSAGES.requiredField)),
+ });
+};
diff --git a/plugins/polio/js/src/domains/VaccineModule/PerformanceDashboard/table/PerformanceDashboardTable.tsx b/plugins/polio/js/src/domains/VaccineModule/PerformanceDashboard/table/PerformanceDashboardTable.tsx
new file mode 100644
index 0000000000..ffccb10311
--- /dev/null
+++ b/plugins/polio/js/src/domains/VaccineModule/PerformanceDashboard/table/PerformanceDashboardTable.tsx
@@ -0,0 +1,34 @@
+import React, { FunctionComponent } from 'react';
+import { UrlParams } from 'bluesquare-components';
+import { TableWithDeepLink } from '../../../../../../../../hat/assets/js/apps/Iaso/components/tables/TableWithDeepLink';
+import { baseUrls } from '../../../../constants/urls';
+import { useGetPerformanceDashboard } from '../hooks/api';
+import { usePerformanceDashboardColumns } from './usePerformanceDashboardColumns';
+
+type Props = { params: Partial };
+
+export const PerformanceDashboardTable: FunctionComponent = ({
+ params,
+}) => {
+ const { data: performanceList, isFetching } =
+ useGetPerformanceDashboard(params);
+ const columns = usePerformanceDashboardColumns();
+ return (
+
+ );
+};
diff --git a/plugins/polio/js/src/domains/VaccineModule/PerformanceDashboard/table/usePerformanceDashboardColumns.tsx b/plugins/polio/js/src/domains/VaccineModule/PerformanceDashboard/table/usePerformanceDashboardColumns.tsx
new file mode 100644
index 0000000000..a26f04f1ff
--- /dev/null
+++ b/plugins/polio/js/src/domains/VaccineModule/PerformanceDashboard/table/usePerformanceDashboardColumns.tsx
@@ -0,0 +1,121 @@
+import React, { useMemo } from 'react';
+import { Column, useSafeIntl } from 'bluesquare-components';
+import { DateCell } from '../../../../../../../../hat/assets/js/apps/Iaso/components/Cells/DateTimeCell';
+import { DisplayIfUserHasPerm } from '../../../../../../../../hat/assets/js/apps/Iaso/components/DisplayIfUserHasPerm';
+import { useCurrentUser } from '../../../../../../../../hat/assets/js/apps/Iaso/utils/usersUtils';
+import { userHasOneOfPermissions } from '../../../../../../../../hat/assets/js/apps/Iaso/domains/users/utils';
+import DeleteDialog from '../../../../../../../../hat/assets/js/apps/Iaso/components/dialogs/DeleteDialogComponent';
+import {
+ POLIO_PERFORMANCE_ADMIN_PERMISSION,
+ POLIO_PERFORMANCE_NON_ADMIN_PERMISSION,
+} from '../../../../constants/permissions';
+import MESSAGES from '../messages';
+import { EditPerformanceModal } from '../modals/CreateEditModal';
+import { useDeletePerformance } from '../hooks/api';
+
+export const usePerformanceDashboardColumns = (): Column[] => {
+ const { formatMessage } = useSafeIntl();
+ const currentUser = useCurrentUser();
+ const { mutate: deletePerformance } = useDeletePerformance();
+
+ return useMemo(() => {
+ const columns: Column[] = [
+ {
+ Header: formatMessage(MESSAGES.country),
+ accessor: 'country_name',
+ id: 'country_name',
+ sortable: true,
+ },
+ {
+ Header: formatMessage(MESSAGES.date),
+ accessor: 'date',
+ id: 'date',
+ sortable: true,
+ Cell: DateCell,
+ },
+ {
+ Header: formatMessage(MESSAGES.status),
+ accessor: 'status',
+ id: 'status',
+ sortable: true,
+ Cell: ({ value }: { value: string }) =>
+ MESSAGES[value.toLowerCase()]
+ ? formatMessage(MESSAGES[value.toLowerCase()])
+ : value,
+ },
+ {
+ Header: formatMessage(MESSAGES.antigen),
+ accessor: 'vaccine',
+ id: 'vaccine',
+ sortable: true,
+ },
+ {
+ Header: formatMessage(MESSAGES.createdAt),
+ accessor: 'created_at',
+ id: 'created_at',
+ sortable: true,
+ Cell: DateCell,
+ },
+ {
+ Header: formatMessage(MESSAGES.updatedAt),
+ accessor: 'updated_at',
+ id: 'updated_at',
+ sortable: true,
+ Cell: DateCell,
+ },
+ ];
+
+ const hasActionPermission = userHasOneOfPermissions(
+ [
+ POLIO_PERFORMANCE_ADMIN_PERMISSION,
+ POLIO_PERFORMANCE_NON_ADMIN_PERMISSION,
+ ],
+ currentUser,
+ );
+
+ if (hasActionPermission) {
+ columns.push({
+ Header: formatMessage(MESSAGES.actions),
+ accessor: 'actions',
+ sortable: false,
+ Cell: (settings: any) => {
+ const { original: performanceData } = settings.row;
+ const recordName = `${performanceData.country_name} - ${performanceData.date}`;
+ return (
+ <>
+
+
+
+
+
+ deletePerformance(performanceData.id)
+ }
+ />
+
+ >
+ );
+ },
+ });
+ }
+ return columns;
+ }, [formatMessage, currentUser, deletePerformance]);
+};
diff --git a/plugins/polio/js/src/domains/VaccineModule/PerformanceDashboard/types.ts b/plugins/polio/js/src/domains/VaccineModule/PerformanceDashboard/types.ts
new file mode 100644
index 0000000000..007f3e4595
--- /dev/null
+++ b/plugins/polio/js/src/domains/VaccineModule/PerformanceDashboard/types.ts
@@ -0,0 +1,31 @@
+export type PerformanceData = {
+ id: number;
+ date: string;
+ status: string;
+ country_name: string;
+ country_id: number;
+ vaccine: string;
+ account: number;
+ created_at: string;
+ created_by: {
+ id: number;
+ username: string;
+ first_name: string;
+ last_name: string;
+ };
+ updated_at: string;
+ updated_by: {
+ id: number;
+ username: string;
+ first_name: string;
+ last_name: string;
+ };
+};
+
+export type PerformanceList = {
+ results: PerformanceData[];
+ count: number;
+ pages: number;
+ has_next: boolean;
+ has_previous: boolean;
+};
diff --git a/plugins/polio/locale/fr/LC_MESSAGES/django.po b/plugins/polio/locale/fr/LC_MESSAGES/django.po
index 45d743ab0b..44c19a2f0b 100644
--- a/plugins/polio/locale/fr/LC_MESSAGES/django.po
+++ b/plugins/polio/locale/fr/LC_MESSAGES/django.po
@@ -676,3 +676,31 @@ msgstr "Polio - chaîne d'approvisionnement (écriture)"
#: plugins/polio/preparedness/spreadsheet_manager.py
msgid "No country found for campaign"
msgstr "Aucun pays trouvé pour la campagne"
+
+#: plugins/polio/performance_dashboard.py
+msgid "Draft"
+msgstr "Ébauche"
+
+#: plugins/polio/performance_dashboard.py
+msgid "Commented"
+msgstr "Commenté"
+
+#: plugins/polio/performance_dashboard.py
+msgid "Final"
+msgstr "Final"
+
+#: plugins/polio/performance_dashboard.py
+msgid "Performance Dashboard"
+msgstr "Tableau des performances"
+
+#: plugins/polio/permissions.py
+msgid "Polio Performance Read Only"
+msgstr "Polio - Performance (lecture seule)"
+
+#: plugins/polio/permissions.py
+msgid "Polio Performance Non Admin"
+msgstr "Polio - Performance Non Admin"
+
+#: plugins/polio/permissions.py
+msgid "Polio performance Admin"
+msgstr "Polio - Performance Admin"
\ No newline at end of file
diff --git a/plugins/polio/migrations/0247_alter_poliopermissionsupport_options_and_more.py b/plugins/polio/migrations/0247_alter_poliopermissionsupport_options_and_more.py
new file mode 100644
index 0000000000..ba1f69c34e
--- /dev/null
+++ b/plugins/polio/migrations/0247_alter_poliopermissionsupport_options_and_more.py
@@ -0,0 +1,121 @@
+# Generated by Django 4.2.26 on 2025-11-27 08:49
+
+import django.db.models.deletion
+
+from django.conf import settings
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ("iaso", "0352_alter_account_modules"),
+ ("polio", "0246_merge_20251024_1527"),
+ ]
+
+ operations = [
+ migrations.AlterModelOptions(
+ name="poliopermissionsupport",
+ options={
+ "default_permissions": [],
+ "managed": False,
+ "permissions": [
+ ("iaso_polio", "Polio"),
+ ("iaso_polio_budget", "Budget Polio"),
+ ("iaso_polio_budget_admin", "Budget Polio Admin"),
+ ("iaso_polio_config", "Polio config"),
+ ("iaso_polio_chronogram", "Polio chronogram"),
+ ("iaso_polio_chronogram_restricted_write", "Polio chronogram user (restricted write)"),
+ ("iaso_polio_notifications", "Polio notifications"),
+ ("iaso_polio_vaccine_authorizations_admin", "Polio Vaccine Authorizations Admin"),
+ ("iaso_polio_vaccine_authorizations_read_only", "Polio Vaccine Authorizations Read Only"),
+ ("iaso_polio_vaccine_stock_earmarks_admin", "Polio Vaccine Stock Earmarks Admin"),
+ ("iaso_polio_vaccine_stock_earmarks_nonadmin", "Polio Vaccine Stock Earmarks Non Admin"),
+ ("iaso_polio_vaccine_stock_earmarks_read_only", "Polio Vaccine Stock Earmarks Read Only"),
+ ("iaso_polio_vaccine_stock_management_read", "Polio Vaccine Stock Management Read"),
+ ("iaso_polio_vaccine_stock_management_read_only", "Polio Vaccine Stock Management Read Only"),
+ ("iaso_polio_vaccine_stock_management_write", "Polio Vaccine Stock Management Write"),
+ ("iaso_polio_vaccine_supply_chain_read", "Polio Vaccine Supply Chain Read"),
+ ("iaso_polio_vaccine_supply_chain_read_only", "Polio Vaccine Supply Chain Read Only"),
+ ("iaso_polio_vaccine_supply_chain_write", "Polio Vaccine Supply Chain Write"),
+ ("iaso_polio_performance_read_only", "Polio Performance Read Only"),
+ ("iaso_polio_performance_non_admin", "Polio Performance Non Admin"),
+ ("iaso_polio_performance_admin", "Polio performance Admin"),
+ ],
+ },
+ ),
+ migrations.CreateModel(
+ name="PerformanceDashboard",
+ fields=[
+ ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
+ ("deleted_at", models.DateTimeField(blank=True, default=None, null=True)),
+ ("date", models.DateField()),
+ (
+ "status",
+ models.CharField(
+ choices=[("draft", "Draft"), ("commented", "Commented"), ("final", "Final")],
+ default="draft",
+ max_length=20,
+ ),
+ ),
+ (
+ "vaccine",
+ models.CharField(
+ choices=[
+ ("mOPV2", "mOPV2"),
+ ("nOPV2", "nOPV2"),
+ ("bOPV", "bOPV"),
+ ("nOPV2 & bOPV", "nOPV2 & bOPV"),
+ ],
+ max_length=20,
+ ),
+ ),
+ ("created_at", models.DateTimeField(auto_now_add=True)),
+ ("updated_at", models.DateTimeField(auto_now=True)),
+ (
+ "account",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="performance_dashboard",
+ to="iaso.account",
+ ),
+ ),
+ (
+ "country",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.PROTECT,
+ related_name="performance_dashboard",
+ to="iaso.orgunit",
+ ),
+ ),
+ (
+ "created_by",
+ models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.SET_NULL,
+ related_name="performance_dashboard_created_set",
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
+ (
+ "updated_by",
+ models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.SET_NULL,
+ related_name="performance_dashboard_updated_set",
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
+ ],
+ options={
+ "verbose_name": "Performance Dashboard",
+ "ordering": ["-date", "country__name"],
+ "indexes": [
+ models.Index(fields=["account"], name="polio_perfo_account_aa3e44_idx"),
+ models.Index(fields=["country"], name="polio_perfo_country_1ff62b_idx"),
+ ],
+ },
+ ),
+ ]
diff --git a/plugins/polio/models/performance_dashboard.py b/plugins/polio/models/performance_dashboard.py
new file mode 100644
index 0000000000..17c972b3e4
--- /dev/null
+++ b/plugins/polio/models/performance_dashboard.py
@@ -0,0 +1,76 @@
+import typing
+
+from django.contrib.auth.models import AnonymousUser, User
+from django.db import models
+from django.db.models import QuerySet
+from django.utils.translation import gettext_lazy as _
+
+from iaso.models import OrgUnit
+from iaso.models.base import Account
+from iaso.models.entity import UserNotAuthError
+from iaso.utils.models.soft_deletable import (
+ DefaultSoftDeletableManager,
+ IncludeDeletedSoftDeletableManager,
+ OnlyDeletedSoftDeletableManager,
+ SoftDeletableModel,
+)
+from plugins.polio.models.base import VACCINES
+
+
+class PerformanceDashboardQuerySet(QuerySet):
+ def filter_for_user(self, user: typing.Optional[typing.Union[User, AnonymousUser]]):
+ if not user or not user.is_authenticated:
+ raise UserNotAuthError("User not Authenticated")
+ profile = user.iaso_profile
+ return self.filter(account=profile.account)
+
+
+class PerformanceDashboard(SoftDeletableModel):
+ class Status(models.TextChoices):
+ DRAFT = "draft", _("Draft")
+ COMMENTED = "commented", _("Commented")
+ FINAL = "final", _("Final")
+
+ date = models.DateField()
+ status = models.CharField(max_length=20, choices=Status.choices, default=Status.DRAFT)
+ country = models.ForeignKey(
+ OrgUnit,
+ on_delete=models.PROTECT,
+ related_name="performance_dashboard",
+ )
+ vaccine = models.CharField(max_length=20, choices=VACCINES)
+ account = models.ForeignKey(Account, on_delete=models.CASCADE, related_name="performance_dashboard", null=False)
+
+ created_at = models.DateTimeField(auto_now_add=True)
+ created_by = models.ForeignKey(
+ User,
+ null=True,
+ blank=True,
+ on_delete=models.SET_NULL,
+ related_name="performance_dashboard_created_set",
+ )
+ updated_at = models.DateTimeField(auto_now=True)
+ updated_by = models.ForeignKey(
+ User,
+ null=True,
+ blank=True,
+ on_delete=models.SET_NULL,
+ related_name="performance_dashboard_updated_set",
+ )
+
+ # Managers
+ objects = DefaultSoftDeletableManager.from_queryset(PerformanceDashboardQuerySet)()
+ objects_only_deleted = OnlyDeletedSoftDeletableManager.from_queryset(PerformanceDashboardQuerySet)()
+ objects_include_deleted = IncludeDeletedSoftDeletableManager.from_queryset(PerformanceDashboardQuerySet)()
+
+ class Meta:
+ verbose_name = _("Performance Dashboard")
+ ordering = ["-date", "country__name"]
+ indexes = [
+ models.Index(fields=["account"]),
+ models.Index(fields=["country"]),
+ ]
+
+ # No unique constraint specified in the ticket, so omitting for now.
+ def __str__(self):
+ return f"{self.country.name} - {self.date} - {self.vaccine}"
diff --git a/plugins/polio/permissions.py b/plugins/polio/permissions.py
index 6c4b0c4e24..5a746d4685 100644
--- a/plugins/polio/permissions.py
+++ b/plugins/polio/permissions.py
@@ -168,6 +168,33 @@ def full_name(self) -> str:
ui_type_in_category="admin",
ui_order_in_category=3,
)
+POLIO_PERFORMANCE_READ_ONLY_PERMISSION = PolioPermission(
+ codename="iaso_polio_performance_read_only",
+ label=_("Polio Performance Read Only"),
+ module=MODULE_POLIO_PROJECT,
+ ui_group=PERMISSION_GROUP_POLIO,
+ ui_category="iaso_polio_performance_permissions",
+ ui_type_in_category="read_only",
+ ui_order_in_category=1,
+)
+POLIO_PERFORMANCE_NON_ADMIN_PERMISSION = PolioPermission(
+ codename="iaso_polio_performance_non_admin",
+ label=_("Polio Performance Non Admin"),
+ module=MODULE_POLIO_PROJECT,
+ ui_group=PERMISSION_GROUP_POLIO,
+ ui_category="iaso_polio_performance_permissions",
+ ui_type_in_category="no_admin",
+ ui_order_in_category=2,
+)
+POLIO_PERFORMANCE_ADMIN_PERMISSION = PolioPermission(
+ codename="iaso_polio_performance_admin",
+ label=_("Polio performance Admin"),
+ module=MODULE_POLIO_PROJECT,
+ ui_group=PERMISSION_GROUP_POLIO,
+ ui_category="iaso_polio_performance_permissions",
+ ui_type_in_category="admin",
+ ui_order_in_category=3,
+)
permissions = {
diff --git a/plugins/polio/tests/api/performance_dashboard/__init__.py b/plugins/polio/tests/api/performance_dashboard/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/plugins/polio/tests/api/performance_dashboard/common_test_data.py b/plugins/polio/tests/api/performance_dashboard/common_test_data.py
new file mode 100644
index 0000000000..d64dac7773
--- /dev/null
+++ b/plugins/polio/tests/api/performance_dashboard/common_test_data.py
@@ -0,0 +1,146 @@
+import datetime
+
+from iaso import models as m
+from iaso.test import APITestCase
+from plugins.polio.models import performance_dashboard as p
+from plugins.polio.permissions import (
+ POLIO_PERFORMANCE_ADMIN_PERMISSION,
+ POLIO_PERFORMANCE_NON_ADMIN_PERMISSION,
+ POLIO_PERFORMANCE_READ_ONLY_PERMISSION,
+)
+
+
+class PerformanceDashboardAPIBase(APITestCase):
+ """ "
+ Creating the test data for the performance dashboard API
+ """
+
+ PERFORMANCE_DASHBOARD_API_URL = "/api/polio/performance_dashboard/"
+
+ @classmethod
+ def setUpTestData(cls):
+ # Main Account
+ cls.datasource_one = m.DataSource.objects.create(name="Datasource one")
+ cls.datasource_version_one = m.SourceVersion.objects.create(data_source=cls.datasource_one, number=1)
+ cls.account_one = m.Account.objects.create(name="Account1", default_version=cls.datasource_version_one)
+ cls.app_id = "hokage"
+ cls.project_Tsukuyomi = m.Project.objects.create(
+ name="Project Tsukuyomi", account=cls.account_one, app_id=cls.app_id
+ )
+ cls.datasource_one.projects.set([cls.project_Tsukuyomi])
+
+ # Users for the main account
+ cls.user_admin_1 = cls.create_user_with_profile(
+ username="hashirama",
+ account=cls.account_one,
+ permissions=[POLIO_PERFORMANCE_ADMIN_PERMISSION],
+ )
+
+ cls.user_admin_2 = cls.create_user_with_profile(
+ username="senju",
+ account=cls.account_one,
+ permissions=[POLIO_PERFORMANCE_ADMIN_PERMISSION],
+ )
+
+ cls.user_read_only_1 = cls.create_user_with_profile(
+ username="Neji Hyuga",
+ account=cls.account_one,
+ permissions=[POLIO_PERFORMANCE_READ_ONLY_PERMISSION],
+ )
+
+ cls.user_non_admin_1 = cls.create_user_with_profile(
+ username="kakashi",
+ account=cls.account_one,
+ permissions=[POLIO_PERFORMANCE_NON_ADMIN_PERMISSION],
+ )
+
+ # User with no permissions
+ cls.user_no_permissions_1 = cls.create_user_with_profile(
+ username="naruto", account=cls.account_one, permissions=[]
+ )
+
+ # A second account for data isolation tests
+ cls.account_two = m.Account.objects.create(name="account2", default_version=cls.datasource_version_one)
+ cls.user_with_account2 = cls.create_user_with_profile(
+ username="Pain",
+ account=cls.account_two,
+ permissions=[POLIO_PERFORMANCE_ADMIN_PERMISSION],
+ )
+
+ # Org Units
+ org_unit_type_country = m.OrgUnitType.objects.create(name="Country", category="COUNTRY")
+ org_unit_type_block = m.OrgUnitType.objects.create(name="Region", category="REGION")
+
+ cls.west = m.OrgUnit.objects.create(name="Land of Fire", org_unit_type=org_unit_type_block)
+ cls.est = m.OrgUnit.objects.create(name="Konoha", org_unit_type=org_unit_type_country, parent=cls.west)
+ cls.north = m.OrgUnit.objects.create(name="Land of Wind", org_unit_type=org_unit_type_block)
+ cls.south = m.OrgUnit.objects.create(name="Suna", org_unit_type=org_unit_type_country, parent=cls.north)
+
+ cls.dashboard_1 = p.PerformanceDashboard.objects.create(
+ account=cls.account_one,
+ country=cls.west,
+ date=datetime.date(2025, 10, 10),
+ status="draft",
+ vaccine="bOPV",
+ created_by=cls.user_admin_1,
+ )
+
+ cls.dashboard_2 = p.PerformanceDashboard.objects.create(
+ account=cls.account_one,
+ country=cls.est,
+ date=datetime.date(2025, 10, 10),
+ status="commented",
+ vaccine="bOPV",
+ created_by=cls.user_non_admin_1,
+ )
+ cls.dashboard_3 = p.PerformanceDashboard.objects.create(
+ account=cls.account_two,
+ country=cls.south,
+ date=datetime.date(2025, 10, 10),
+ status="commented",
+ vaccine="bOPV",
+ created_by=cls.user_with_account2,
+ )
+
+ cls.dashboard_4 = p.PerformanceDashboard.objects.create(
+ account=cls.account_one,
+ country=cls.west,
+ date=datetime.date(2025, 10, 10),
+ status="final",
+ vaccine="nOPV2",
+ created_by=cls.user_read_only_1,
+ )
+
+ cls.dashboard_5 = p.PerformanceDashboard.objects.create(
+ account=cls.account_one,
+ country=cls.west,
+ date=datetime.date(2025, 10, 10),
+ status="final",
+ vaccine="nOPV2",
+ created_by=cls.user_admin_2,
+ )
+
+ cls.dashboard_6 = p.PerformanceDashboard.objects.create(
+ account=cls.account_one,
+ country=cls.west,
+ date=datetime.date(2025, 10, 10),
+ status="final",
+ vaccine="nOPV2",
+ created_by=cls.user_no_permissions_1,
+ )
+ cls.dashboard_7 = p.PerformanceDashboard.objects.create(
+ account=cls.account_one,
+ country=cls.est,
+ date=datetime.date(2025, 10, 10),
+ status="commented",
+ vaccine="nOPV2",
+ created_by=cls.user_non_admin_1,
+ )
+ cls.dashboard_8 = p.PerformanceDashboard.objects.create(
+ account=cls.account_one,
+ country=cls.south,
+ date=datetime.date(2025, 10, 10),
+ status="draft",
+ vaccine="nOPV2",
+ created_by=cls.user_admin_2,
+ )
diff --git a/plugins/polio/tests/api/performance_dashboard/test_filters.py b/plugins/polio/tests/api/performance_dashboard/test_filters.py
new file mode 100644
index 0000000000..a482f3acee
--- /dev/null
+++ b/plugins/polio/tests/api/performance_dashboard/test_filters.py
@@ -0,0 +1,44 @@
+from rest_framework import status
+
+from plugins.polio.models.performance_dashboard import PerformanceDashboard
+
+from .common_test_data import PerformanceDashboardAPIBase
+
+
+class PerformanceDashboardFiltersAPITestCase(PerformanceDashboardAPIBase):
+ """
+ Test cases for the filters of the Performance Dashboard API endpoint.
+ """
+
+ def test_filter_by_country(self):
+ """
+ Test that we can filter dashboards by country.
+ """
+ self.client.force_authenticate(self.user_admin_1)
+
+ response = self.client.get(f"{self.PERFORMANCE_DASHBOARD_API_URL}?country={self.est.id}")
+ response_data = self.assertJSONResponse(response, status.HTTP_200_OK)
+ results = response_data.get("results", [])
+ count = len(results)
+ expected_count = PerformanceDashboard.objects.filter(account=self.account_one, country=self.est).count()
+ self.assertEqual(count, expected_count)
+
+ result_ids = {item["id"] for item in response_data["results"]}
+ self.assertIn(self.dashboard_2.id, result_ids)
+ self.assertIn(self.dashboard_7.id, result_ids)
+
+ def test_filter_by_country_block(self):
+ """
+ Test that we can filter dashboards by the country's parent (block).
+ """
+
+ self.client.force_authenticate(self.user_with_account2)
+
+ response = self.client.get(f"{self.PERFORMANCE_DASHBOARD_API_URL}?country_block={self.north.id}")
+ response_data = self.assertJSONResponse(response, status.HTTP_200_OK)
+ results = response_data.get("results", [])
+ count = len(results)
+ self.assertEqual(count, 1)
+
+ result_ids = {item["id"] for item in response_data["results"]}
+ self.assertCountEqual(result_ids, [self.dashboard_3.id])
diff --git a/plugins/polio/tests/api/performance_dashboard/test_serializers.py b/plugins/polio/tests/api/performance_dashboard/test_serializers.py
new file mode 100644
index 0000000000..08d4b6d82a
--- /dev/null
+++ b/plugins/polio/tests/api/performance_dashboard/test_serializers.py
@@ -0,0 +1,118 @@
+from rest_framework import serializers
+from rest_framework.request import Request
+from rest_framework.test import APIRequestFactory
+
+from plugins.polio.api.perfomance_dashboard.serializers import (
+ PerformanceDashboardListSerializer,
+ PerformanceDashboardWriteSerializer,
+)
+from plugins.polio.models.performance_dashboard import PerformanceDashboard
+
+from .common_test_data import PerformanceDashboardAPIBase
+
+
+class PerformanceDashboardSerializerAPITestCase(PerformanceDashboardAPIBase):
+ """
+ Test cases for the Performance Dashboard serializers.
+ """
+
+ def test_list_serializer_returns_expected_data(self):
+ """
+ Test that the List/Read serializer returns the correct structure and data.
+ """
+ dashboard = self.dashboard_1
+ serializer = PerformanceDashboardListSerializer(instance=dashboard)
+ data = serializer.data
+
+ self.assertIn("id", data)
+ self.assertIn("date", data)
+ self.assertIn("status", data)
+ self.assertIn("vaccine", data)
+
+ self.assertIn("created_by", data)
+
+ # Check that the values are correct
+ self.assertEqual(data["id"], dashboard.id)
+ self.assertEqual(data["status"], dashboard.status)
+
+ self.assertEqual(data["country_name"], dashboard.country.name)
+ self.assertEqual(data["created_by"]["username"], dashboard.created_by.username)
+
+ def test_write_serializer_create_success(self):
+ """
+ Test that the Write serializer can successfully create a new object.
+ """
+ data = {
+ "date": "2023-05-01",
+ "status": "draft",
+ "vaccine": "bOPV",
+ "country_id": self.est.id,
+ }
+ factory = APIRequestFactory()
+ django_request = factory.post(self.PERFORMANCE_DASHBOARD_API_URL, data, format="json")
+
+ drf_request = Request(django_request)
+ drf_request.user = self.user_admin_1
+
+ serializer = PerformanceDashboardWriteSerializer(data=data, context={"request": drf_request})
+
+ self.assertTrue(serializer.is_valid(), serializer.errors)
+
+ new_dashboard = serializer.save()
+
+ self.assertIsInstance(new_dashboard, PerformanceDashboard)
+ self.assertEqual(new_dashboard.status, "draft")
+ self.assertEqual(new_dashboard.country, self.est)
+
+ self.assertEqual(new_dashboard.created_by, self.user_admin_1)
+ self.assertEqual(new_dashboard.account, self.account_one)
+
+ def test_write_serializer_invalid_data_fails(self):
+ """
+ Test that the Write serializer fails with invalid or missing data.
+ """
+ invalid_data = {
+ "date": "2023-06-01",
+ "status": "final",
+ "vaccine": "nOPV2",
+ }
+
+ serializer = PerformanceDashboardWriteSerializer(data=invalid_data)
+
+ self.assertFalse(serializer.is_valid())
+
+ self.assertIn("country_id", serializer.errors)
+ self.assertEqual(serializer.errors["country_id"][0].code, "required")
+
+ def test_create_raises_validation_error_if_request_missing(self):
+ """Test that .save() fails if request is not in serializer context."""
+ data = {
+ "date": "2023-05-01",
+ "status": "draft",
+ "vaccine": "bOPV",
+ "country_id": self.est.id,
+ }
+ serializer = PerformanceDashboardWriteSerializer(data=data, context={})
+ self.assertTrue(serializer.is_valid())
+
+ with self.assertRaises(serializers.ValidationError) as e:
+ serializer.save()
+ self.assertIn("Request context is missing", str(e.exception))
+
+ def test_create_raises_validation_error_if_country_does_not_exist(self):
+ """
+ Test that providing a non-existent country_id returns a 400 Bad Request,
+ not a 500 Server Error.
+ """
+ data = {
+ "date": "2023-05-01",
+ "status": "draft",
+ "vaccine": "bOPV",
+ "country_id": 999999,
+ }
+
+ serializer = PerformanceDashboardWriteSerializer(data=data)
+
+ self.assertFalse(serializer.is_valid(), "Serializer accepted a non-existent country ID!")
+
+ self.assertIn("country_id", serializer.errors)
diff --git a/plugins/polio/tests/api/performance_dashboard/test_views.py b/plugins/polio/tests/api/performance_dashboard/test_views.py
new file mode 100644
index 0000000000..b0e0b2ed34
--- /dev/null
+++ b/plugins/polio/tests/api/performance_dashboard/test_views.py
@@ -0,0 +1,228 @@
+import datetime
+
+from unittest.mock import patch
+
+from django.utils import timezone
+from rest_framework import status
+
+from plugins.polio.models.performance_dashboard import PerformanceDashboard
+
+from .common_test_data import PerformanceDashboardAPIBase
+
+
+class PerformanceDashboardViewsAPITestCase(PerformanceDashboardAPIBase):
+ """
+ Test cases for the main actions of the Performance Dashboard API endpoint (ViewSet).
+ """
+
+ # --- Permissions Tests ---
+
+ def test_list_unauthenticated_returns_401(self):
+ """
+ Unauthenticated users should not be able to access the endpoint.
+ """
+ response = self.client.get(self.PERFORMANCE_DASHBOARD_API_URL)
+ self.assertJSONResponse(response, status.HTTP_401_UNAUTHORIZED)
+
+ def test_list_with_no_perms_returns_403(self):
+ """
+ Authenticated users without the correct permissions should be forbidden.
+ """
+ self.client.force_authenticate(self.user_no_permissions_1)
+ response = self.client.get(self.PERFORMANCE_DASHBOARD_API_URL)
+ self.assertJSONResponse(response, status.HTTP_403_FORBIDDEN)
+
+ def test_read_only_user_permissions(self):
+ """
+ Test that a read-only user can only perform GET requests.
+ """
+ self.client.force_authenticate(self.user_read_only_1)
+
+ response = self.client.get(self.PERFORMANCE_DASHBOARD_API_URL)
+ self.assertJSONResponse(response, status.HTTP_200_OK)
+
+ response = self.client.post(self.PERFORMANCE_DASHBOARD_API_URL, data={}, format="json")
+ self.assertJSONResponse(response, status.HTTP_403_FORBIDDEN)
+
+ response = self.client.patch(
+ f"{self.PERFORMANCE_DASHBOARD_API_URL}{self.dashboard_2.id}/", data={}, format="json"
+ )
+ self.assertJSONResponse(response, status.HTTP_403_FORBIDDEN)
+
+ response = self.client.delete(f"{self.PERFORMANCE_DASHBOARD_API_URL}{self.dashboard_2.id}/")
+ self.assertJSONResponse(response, status.HTTP_403_FORBIDDEN)
+
+ def test_non_admin_user_can_create(self):
+ """
+ Test that a non-admin user can create and update, but not delete.
+ """
+ self.client.force_authenticate(self.user_non_admin_1)
+
+ create_data = {"date": "2023-08-01", "status": "draft", "vaccine": "bOPV", "country_id": self.est.id}
+ response = self.client.post(self.PERFORMANCE_DASHBOARD_API_URL, data=create_data, format="json")
+ self.assertJSONResponse(response, status.HTTP_201_CREATED)
+
+ @patch("django.utils.timezone.now")
+ def test_non_admin_can_update_recent_record(self, mock_now):
+ """
+ Test that a non-admin user CAN update a recently created record
+ """
+ self.client.force_authenticate(self.user_non_admin_1)
+
+ time_of_creation = timezone.make_aware(datetime.datetime(2023, 10, 5))
+ mock_now.return_value = time_of_creation
+ recent_dashboard = PerformanceDashboard.objects.create(
+ account=self.account_one,
+ country=self.est,
+ date="2023-10-05",
+ status="draft",
+ vaccine="bOPV",
+ created_by=self.user_non_admin_1,
+ )
+
+ time_of_update = timezone.make_aware(datetime.datetime(2023, 10, 10))
+ mock_now.return_value = time_of_update
+
+ update_data = {"status": "final"}
+ response = self.client.patch(
+ f"{self.PERFORMANCE_DASHBOARD_API_URL}{recent_dashboard.id}/", data=update_data, format="json"
+ )
+ self.assertJSONResponse(response, status.HTTP_200_OK)
+ recent_dashboard.refresh_from_db()
+ self.assertEqual(recent_dashboard.updated_by, self.user_non_admin_1)
+
+ @patch("django.utils.timezone.now")
+ def test_non_admin_cannot_update_old_record(self, mock_now):
+ """
+ Test that a non-admin user CANNOT update an old record.
+ """
+ self.client.force_authenticate(self.user_non_admin_1)
+
+ time_of_creation = timezone.make_aware(datetime.datetime(2023, 10, 10))
+ mock_now.return_value = time_of_creation
+
+ old_dashboard = PerformanceDashboard.objects.create(
+ account=self.account_one,
+ country=self.est,
+ date="2023-10-10",
+ status="draft",
+ vaccine="bOPV",
+ created_by=self.user_non_admin_1,
+ )
+ time_of_update = timezone.make_aware(datetime.datetime(2023, 10, 20))
+ mock_now.return_value = time_of_update
+
+ update_data = {"status": "final"}
+ response = self.client.patch(
+ f"{self.PERFORMANCE_DASHBOARD_API_URL}{old_dashboard.id}/", data=update_data, format="json"
+ )
+ self.assertJSONResponse(response, status.HTTP_403_FORBIDDEN)
+
+ def test_admin_user_can_delete(self):
+ """
+ Test that an admin user can perform a DELETE request,
+ and that the system correctly logs WHO deleted it.
+ """
+ self.client.force_authenticate(self.user_admin_1)
+
+ dashboard_id = self.dashboard_2.id
+
+ response = self.client.delete(f"{self.PERFORMANCE_DASHBOARD_API_URL}{dashboard_id}/")
+ self.assertJSONResponse(response, status.HTTP_204_NO_CONTENT)
+
+ deleted_dashboard = PerformanceDashboard.objects_include_deleted.get(id=dashboard_id)
+
+ self.assertIsNotNone(deleted_dashboard.deleted_at)
+
+ self.assertEqual(deleted_dashboard.updated_by, self.user_admin_1)
+
+ def test_admin_user_can_update(self):
+ """
+ Test that a PATCH request correctly updates the data and sets the `updated_by` field.
+ """
+ self.client.force_authenticate(self.user_admin_1)
+
+ dashboard_to_update = self.dashboard_1
+
+ update_data = {"status": "final", "vaccine": "nOPV2"}
+
+ response = self.client.patch(
+ f"{self.PERFORMANCE_DASHBOARD_API_URL}{dashboard_to_update.id}/", data=update_data, format="json"
+ )
+
+ self.assertJSONResponse(response, status.HTTP_200_OK)
+
+ dashboard_to_update.refresh_from_db()
+
+ self.assertEqual(dashboard_to_update.status, "final")
+ self.assertEqual(dashboard_to_update.vaccine, "nOPV2")
+
+ self.assertEqual(dashboard_to_update.updated_by, self.user_admin_1)
+
+ def test_perform_destroy_audits_user(self):
+ """
+ Test that perform_destroy correctly sets updated_by and soft-deletes the instance.
+ """
+ # Authenticate a user who can delete (e.g., an admin)
+ self.client.force_authenticate(self.user_admin_1)
+
+ dashboard_to_delete = self.dashboard_1
+
+ response = self.client.delete(f"{self.PERFORMANCE_DASHBOARD_API_URL}{dashboard_to_delete.id}/")
+
+ self.assertJSONResponse(response, status.HTTP_204_NO_CONTENT)
+
+ deleted_dashboard = PerformanceDashboard.objects_include_deleted.get(id=dashboard_to_delete.id)
+
+ self.assertIsNotNone(deleted_dashboard.deleted_at)
+
+ self.assertEqual(deleted_dashboard.updated_by, self.user_admin_1)
+
+ self.assertFalse(PerformanceDashboard.objects.filter(id=dashboard_to_delete.id).exists())
+
+ # --- Data Isolation and Functionality Tests ---
+
+ def test_list_returns_only_own_account_dashboards(self):
+ """
+ Test that a user can only list dashboards from their own account.
+ """
+ self.client.force_authenticate(self.user_admin_1) # User from Hokage account
+ response = self.client.get(self.PERFORMANCE_DASHBOARD_API_URL)
+ self.assertJSONResponse(response, status.HTTP_200_OK)
+
+ response_data = response.json()
+
+ if "results" in response_data:
+ results = response_data["results"]
+ count = len(results)
+ else:
+ self.fail("Response is not paginated as expected")
+
+ expected_count = PerformanceDashboard.objects.filter(account=self.account_one).count()
+ self.assertEqual(count, expected_count)
+
+ result_ids = {item["id"] for item in results}
+
+ self.assertNotIn(
+ self.dashboard_3.id,
+ result_ids,
+ "Dashboard from another account (Akatsuki) should not be listed for Hokage user",
+ )
+
+ def test_create_sets_audit_fields_correctly(self):
+ """
+ Test that on creation, `created_by` and `account` are set automatically by the view/serializer.
+ """
+ self.client.force_authenticate(self.user_with_account2)
+ data = {
+ "date": "2023-09-01",
+ "status": "draft",
+ "vaccine": "nOPV2",
+ "country_id": self.south.id,
+ }
+ response = self.client.post(self.PERFORMANCE_DASHBOARD_API_URL, data=data, format="json")
+ self.assertJSONResponse(response, status.HTTP_201_CREATED)
+
+ new_dashboard = PerformanceDashboard.objects.get(id=response.json()["id"])
+ self.assertEqual(new_dashboard.created_by, self.user_with_account2)
+ self.assertEqual(new_dashboard.account, self.account_two)