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)