Skip to content

Commit

Permalink
feat: Reports repository
Browse files Browse the repository at this point in the history
  • Loading branch information
quang-le authored Dec 10, 2024
2 parents 13b6896 + 845d2ab commit 58f236e
Show file tree
Hide file tree
Showing 21 changed files with 882 additions and 73 deletions.
26 changes: 26 additions & 0 deletions hat/assets/js/apps/Iaso/routing/common.ts
Original file line number Diff line number Diff line change
@@ -1 +1,27 @@
export const paginationPathParams = ['order', 'pageSize', 'page'];

export const getNonPrefixedParams = (
prefix: string,
params: Record<string, any>,
keysToIgnore: string[] = [],
) => {
const nonPrefixedParams: Record<string, any> = {};
Object.keys(params).forEach(key => {
if (!key.startsWith(prefix) && !keysToIgnore.includes(key)) {
nonPrefixedParams[key] = params[key];
}
});
return nonPrefixedParams;
};
export const getPrefixedParams = (
prefix: string,
params: Record<string, any>,
) => {
const reportParams: Record<string, any> = {};
Object.keys(params).forEach(key => {
if (key.startsWith(prefix)) {
reportParams[key] = params[key];
}
});
return reportParams;
};
9 changes: 7 additions & 2 deletions plugins/polio/api/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,8 @@
from plugins.polio.api.vaccines.vaccine_authorization import VaccineAuthorizationViewSet
from plugins.polio.tasks.api.create_refresh_preparedness_data import RefreshPreparednessLaucherViewSet
from plugins.polio.api.vaccines.supply_chain import VaccineRequestFormViewSet
from plugins.polio.api.vaccines.repository import VaccineRepositoryViewSet
from plugins.polio.api.vaccines.repository_forms import VaccineRepositoryFormsViewSet
from plugins.polio.api.vaccines.repository_reports import VaccineRepositoryReportsViewSet
from plugins.polio.api.vaccines.stock_management import (
VaccineStockManagementViewSet,
OutgoingStockMovementViewSet,
Expand Down Expand Up @@ -88,7 +89,11 @@
router.register(r"polio/tasks/refreshim/hh_ohh", RefreshIMAllDataViewset, basename="refreshimhhohh")
router.register(r"polio/vaccine/request_forms", VaccineRequestFormViewSet, basename="vaccine_request_forms")
router.register(r"polio/vaccine/vaccine_stock", VaccineStockManagementViewSet, basename="vaccine_stocks")
router.register(r"polio/vaccine/repository", VaccineRepositoryViewSet, basename="vaccine_repository")
router.register(r"polio/vaccine/repository", VaccineRepositoryFormsViewSet, basename="vaccine_repository")
router.register(
r"polio/vaccine/repository_reports", VaccineRepositoryReportsViewSet, basename="vaccine_repository_reports"
)

router.register(
r"polio/vaccine/stock/outgoing_stock_movement", OutgoingStockMovementViewSet, basename="outgoing_stock_movement"
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@ def get_form_a_data(self, obj):
]


class VaccineRepositoryViewSet(GenericViewSet, ListModelMixin):
class VaccineRepositoryFormsViewSet(GenericViewSet, ListModelMixin):
"""
ViewSet for retrieving vaccine repository data.
"""
Expand Down
155 changes: 155 additions & 0 deletions plugins/polio/api/vaccines/repository_reports.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
"""API endpoints and serializers for vaccine repository reports."""

from datetime import datetime, timedelta
from django.db.models import OuterRef, Subquery, Q, Value, Case, When, CharField, Exists
from drf_yasg import openapi
from drf_yasg.utils import swagger_auto_schema
from rest_framework import filters, permissions, serializers
from rest_framework.exceptions import ValidationError
from rest_framework.filters import OrderingFilter, SearchFilter
from rest_framework.mixins import ListModelMixin
from rest_framework.viewsets import GenericViewSet
from django.db.models import Prefetch

from iaso.api.common import Paginator
from plugins.polio.models import VaccineStock, DestructionReport, IncidentReport


class VaccineReportingFilterBackend(filters.BaseFilterBackend):
"""Filter backend for vaccine reporting that handles country and file type filtering."""

def filter_queryset(self, request, queryset, view):
# Filter by vaccine name (single)
vaccine_name = request.query_params.get("vaccine_name", None)
if vaccine_name:
queryset = queryset.filter(vaccine=vaccine_name)

# Filter by country block
country_block = request.query_params.get("country_block", None)
if country_block:
try:
country_block_ids = [int(id) for id in country_block.split(",")]
queryset = queryset.filter(country__groups__in=country_block_ids)
except ValueError:
raise ValidationError("country_block must be a comma-separated list of integers")

# Filter by country (multi)
countries = request.query_params.get("countries", None)
if countries:
try:
country_ids = [int(id) for id in countries.split(",")]
queryset = queryset.filter(country__id__in=country_ids)
except ValueError:
raise ValidationError("countries must be a comma-separated list of integers")

# Filter by file type
file_type = request.query_params.get("file_type", None)
if file_type:
try:
filetypes = [tp.strip().upper() for tp in file_type.split(",")]
if "INCIDENT" in filetypes:
queryset = queryset.filter(incidentreport__isnull=False)
if "DESTRUCTION" in filetypes:
queryset = queryset.filter(destructionreport__isnull=False)
except ValueError:
raise ValidationError("file_type must be a comma-separated list of strings")

return queryset.distinct()


class VaccineRepositoryReportSerializer(serializers.Serializer):
country_name = serializers.CharField(source="country.name")
country_id = serializers.IntegerField(source="country.id")
vaccine = serializers.CharField()
incident_report_data = serializers.SerializerMethodField()
destruction_report_data = serializers.SerializerMethodField()

def get_incident_report_data(self, obj):
return [
{
"date": ir.date_of_incident_report,
"file": ir.document.url if ir.document else None,
}
for ir in obj.prefetched_incident_reports
]

def get_destruction_report_data(self, obj):
return [
{
"date": dr.destruction_report_date,
"file": dr.document.url if dr.document else None,
}
for dr in obj.prefetched_destruction_reports
]


class VaccineRepositoryReportsViewSet(GenericViewSet, ListModelMixin):
"""ViewSet for retrieving vaccine repository reports data."""

serializer_class = VaccineRepositoryReportSerializer
pagination_class = Paginator
filter_backends = [OrderingFilter, SearchFilter, VaccineReportingFilterBackend]
ordering_fields = ["country__name", "vaccine"]
ordering = ["country__name"]
search_fields = ["country__name", "vaccine"]
permission_classes = [permissions.IsAuthenticatedOrReadOnly]
default_page_size = 10

def get_queryset(self):
"""Get the queryset for VaccineStock objects."""
base_qs = VaccineStock.objects.select_related(
"country",
).filter(Q(incidentreport__isnull=False) | Q(destructionreport__isnull=False))

incident_qs = IncidentReport.objects.only(
"vaccine_stock_id",
"date_of_incident_report",
"document",
)

destruction_qs = DestructionReport.objects.only(
"vaccine_stock_id",
"destruction_report_date",
"document",
)

return base_qs.prefetch_related(
Prefetch("incidentreport_set", queryset=incident_qs, to_attr="prefetched_incident_reports"),
Prefetch("destructionreport_set", queryset=destruction_qs, to_attr="prefetched_destruction_reports"),
).distinct()

@swagger_auto_schema(
manual_parameters=[
openapi.Parameter(
"file_type",
openapi.IN_QUERY,
description="Filter by file type (IR, DR)",
type=openapi.TYPE_STRING,
),
openapi.Parameter(
"country_block",
openapi.IN_QUERY,
description="Filter by country block (comma separated list of org unit group ids)",
type=openapi.TYPE_STRING,
),
openapi.Parameter(
"countries",
openapi.IN_QUERY,
description="Filter by countries (comma separated list of country ids)",
type=openapi.TYPE_STRING,
),
openapi.Parameter(
"vaccine_name",
openapi.IN_QUERY,
description="Filter by vaccine name",
type=openapi.TYPE_STRING,
),
]
)
def list(self, request, *args, **kwargs):
"""
Vaccine Reports Repository
Return a paginated list of vaccine stocks with their associated incident and destruction reports.
"""
return super().list(request, *args, **kwargs)
1 change: 1 addition & 0 deletions plugins/polio/js/src/constants/translations/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -763,6 +763,7 @@
"iaso.polio.preparednessRoundStarted": "Preparedness can't be edited if round already started",
"iaso.polio.PREPARING": "Preparing",
"iaso.polio.raStatus": "RA Status",
"iaso.polio.reports": "Reports",
"iaso.polio.repository.title": "Vaccine Management Repository",
"iaso.polio.restoreCampaign": "Restore campaign",
"iaso.polio.ROUND1DONE": "Round 1 completed",
Expand Down
1 change: 1 addition & 0 deletions plugins/polio/js/src/constants/translations/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -762,6 +762,7 @@
"iaso.polio.preparednessRoundStarted": "La préparation de campagne ne peut être éditée après le début du round",
"iaso.polio.PREPARING": "En préparation",
"iaso.polio.raStatus": "Statut RA",
"iaso.polio.reports": "Rapports",
"iaso.polio.repository.title": "Archives de la gestion des vaccins",
"iaso.polio.restoreCampaign": "Restaurer la campagne",
"iaso.polio.ROUND1DONE": "Round 1 terminé",
Expand Down
16 changes: 16 additions & 0 deletions plugins/polio/js/src/constants/urls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
extractParams,
extractParamsConfig,
extractUrls,
paginationPathParamsWithPrefix,
} from '../../../../../hat/assets/js/apps/Iaso/constants/urls';
import { paginationPathParams } from '../../../../../hat/assets/js/apps/Iaso/routing/common';
import {
Expand Down Expand Up @@ -91,11 +92,19 @@ export const polioRouteConfigs: Record<string, RouteConfig> = {
url: VACCINE_REPOSITORY_BASE_URL,
params: [
...paginationPathParams,
...paginationPathParamsWithPrefix('report'),
'countries',
'campaignType',
'file_type',
'country_block',
'vaccine_name',
'campaignStatus',
'tab',
'accountId',
'reportCountries',
'reportCountryBlock',
'reportFileType',
'reportVaccineName',
],
},
embeddedCalendar: {
Expand All @@ -112,11 +121,18 @@ export const polioRouteConfigs: Record<string, RouteConfig> = {
url: EMBEDDED_VACCINE_REPOSITORY_URL,
params: [
...paginationPathParams,
...paginationPathParamsWithPrefix('report'),
'countries',
'campaignType',
'file_type',
'country_block',
'vaccine_name',
'campaignStatus',
'tab',
'reportCountries',
'reportCountryBlock',
'reportFileType',
'reportVaccineName',
],
},
lqasCountry: {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,25 +1,20 @@
import { Box, Typography } from '@mui/material';
import { Box, Tab, Tabs, Typography } from '@mui/material';
import {
Column,
MENU_HEIGHT_WITHOUT_TABS,
useRedirectTo,
useSafeIntl,
} from 'bluesquare-components';
import React, { FunctionComponent } from 'react';
import React, { FunctionComponent, useState } from 'react';
import { useLocation } from 'react-router-dom';
import TopBar from '../../../../../../../hat/assets/js/apps/Iaso/components/nav/TopBarComponent';
import { TableWithDeepLink } from '../../../../../../../hat/assets/js/apps/Iaso/components/tables/TableWithDeepLink';
import { OffLineLangSwitch } from '../../../../../../../hat/assets/js/apps/Iaso/domains/home/components/LangSwitch';
import { useParamsObject } from '../../../../../../../hat/assets/js/apps/Iaso/routing/hooks/useParamsObject';
import { SxStyles } from '../../../../../../../hat/assets/js/apps/Iaso/types/general';
import { baseUrls } from '../../../constants/urls';
import {
tableDefaults,
useGetVaccineReporting,
} from './hooks/useGetVaccineReporting';
import { useVaccineRepositoryColumns } from './hooks/useVaccineRepositoryColumns';
import { Forms } from './forms';
import MESSAGES from './messages';
import { Reports } from './reports';
import { VaccineRepositoryParams } from './types';
import { VaccineRepositoryFilters } from './VaccineRepositoryFilters';
import { OffLineLangSwitch } from '../../../../../../../hat/assets/js/apps/Iaso/domains/home/components/LangSwitch';

const baseUrl = baseUrls.vaccineRepository;
const embeddedVaccineRepositoryUrl = baseUrls.embeddedVaccineRepository;
Expand All @@ -37,17 +32,6 @@ const styles: SxStyles = {
// '& td': { padding: 0 },
},
};
const NOPADDING_CELLS_IDS = ['vrf_data', 'pre_alert_data', 'form_a_data'];

const getCellProps = cell => {
const { id } = cell.column as Column;
return {
style: {
padding: NOPADDING_CELLS_IDS.includes(id as string) ? 0 : undefined,
verticalAlign: 'top',
},
};
};

// Campaigns status filter should be on another ticket with better specs
// What about the colors, what does green says ?
Expand All @@ -62,9 +46,17 @@ export const VaccineRepository: FunctionComponent = () => {
const params = useParamsObject(
redirectUrl,
) as unknown as VaccineRepositoryParams;
const redirectTo = useRedirectTo();
const [tab, setTab] = useState(params.tab ?? 'forms');
const { formatMessage } = useSafeIntl();
const { data, isFetching } = useGetVaccineReporting(params);
const columns = useVaccineRepositoryColumns();
const handleChangeTab = (newTab: string) => {
setTab(newTab);
const newParams = {
...params,
tab: newTab,
};
redirectTo(baseUrl, newParams);
};

return (
<>
Expand Down Expand Up @@ -97,26 +89,21 @@ export const VaccineRepository: FunctionComponent = () => {
</Box>
</Box>
)}
<VaccineRepositoryFilters
params={params}
redirectUrl={redirectUrl}
/>
<TableWithDeepLink
marginTop={false}
data={data?.results ?? []}
pages={data?.pages ?? 1}
defaultSorted={[{ id: tableDefaults.order, desc: true }]}
columns={columns}
count={data?.count ?? 0}
baseUrl={redirectUrl}
countOnTop
params={params}
cellProps={getCellProps}
extraProps={{
loading: isFetching,
defaultPageSize: tableDefaults.limit,
}}
/>

<Tabs
textColor="inherit"
indicatorColor="secondary"
value={tab}
onChange={(_, newtab) => handleChangeTab(newtab)}
>
<Tab value="forms" label={formatMessage(MESSAGES.forms)} />
<Tab
value="reports"
label={formatMessage(MESSAGES.reports)}
/>
</Tabs>
{tab === 'forms' && <Forms params={params} />}
{tab === 'reports' && <Reports params={params} />}
</Box>
</>
);
Expand Down
Loading

0 comments on commit 58f236e

Please sign in to comment.