diff --git a/README.md b/README.md index fc043e66..41c98ed0 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,10 @@ This report shows data values for data sets `NHWA Module ...`. There are two kin The API endpoint `/dataValueSets` does not provide all the features we need, so we use a custom SQL View instead. +### new variants: + +nhwa-attachments + ## Initial setup ``` diff --git a/i18n/en.pot b/i18n/en.pot index b85033b4..21450941 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -5,8 +5,8 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" -"POT-Creation-Date: 2023-08-28T11:13:31.544Z\n" -"PO-Revision-Date: 2023-08-28T11:13:31.544Z\n" +"POT-Creation-Date: 2023-12-01T16:20:03.972Z\n" +"PO-Revision-Date: 2023-12-01T16:20:03.972Z\n" msgid "" msgstr "" @@ -601,6 +601,21 @@ msgstr "" msgid "Error when trying to unapprove data set" msgstr "" +msgid "NHWA Attachment Report" +msgstr "" + +msgid "File" +msgstr "" + +msgid "Download" +msgstr "" + +msgid "Last updated" +msgstr "" + +msgid "Stored by" +msgstr "" + msgid "Category Option Combo" msgstr "" @@ -633,12 +648,6 @@ msgstr "" msgid "Value" msgstr "" -msgid "Last updated" -msgstr "" - -msgid "Stored by" -msgstr "" - msgid "Toggle filters" msgstr "" diff --git a/i18n/es.po b/i18n/es.po index 7ba14474..d6ba8233 100644 --- a/i18n/es.po +++ b/i18n/es.po @@ -1,7 +1,7 @@ msgid "" msgstr "" "Project-Id-Version: i18next-conv\n" -"POT-Creation-Date: 2023-08-28T11:13:31.544Z\n" +"POT-Creation-Date: 2023-12-01T16:20:03.972Z\n" "PO-Revision-Date: 2018-10-25T09:02:35.143Z\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" @@ -601,6 +601,21 @@ msgstr "" msgid "Error when trying to unapprove data set" msgstr "" +msgid "NHWA Attachment Report" +msgstr "" + +msgid "File" +msgstr "" + +msgid "Download" +msgstr "" + +msgid "Last updated" +msgstr "" + +msgid "Stored by" +msgstr "" + msgid "Category Option Combo" msgstr "" @@ -633,21 +648,14 @@ msgstr "" msgid "Value" msgstr "" -msgid "Last updated" -msgstr "" - -msgid "Stored by" -msgstr "" - msgid "Toggle filters" msgstr "" msgid "No values to update" msgstr "" -#, fuzzy msgid "Occupation" -msgstr "Configuración" +msgstr "" msgid "Practising" msgstr "" diff --git a/src/compositionRoot.ts b/src/compositionRoot.ts index 83b27b83..10915b54 100644 --- a/src/compositionRoot.ts +++ b/src/compositionRoot.ts @@ -80,6 +80,9 @@ import { SaveDataQualityUseCase } from "./domain/reports/data-quality/usecases/S import { LoadDataQualityValidation } from "./domain/reports/data-quality/usecases/loadDataQualityValidation"; import { ResetDataQualityValidation } from "./domain/reports/data-quality/usecases/ResetDataQualityValidation"; import { GetMonitoringDetailsUseCase } from "./domain/reports/mal-data-subscription/usecases/GetMonitoringDetailsUseCase"; +import { NHWAAttachementsDefaultRepository } from "./data/NHWAAttachementsDefaultRepository "; +import { GetAttachementsUseCase } from "./domain/nhwa-attachments/usecases/GetAttachementsUseCase"; +import { ExportAttachmentsUseCase } from "./domain/nhwa-attachments/usecases/ExportAttachmentsUseCase"; export function getCompositionRoot(api: D2Api) { const configRepository = new Dhis2ConfigRepository(api, getReportType()); @@ -95,6 +98,7 @@ export function getCompositionRoot(api: D2Api) { const csySummaryRepository = new CSYSummaryDefaultRepository(api); const csySummaryMortalityRepository = new CSYSummaryMortalityDefaultRepository(api); const orgUnitsRepository = new Dhis2OrgUnitsRepository(api); + const attachementRepository = new NHWAAttachementsDefaultRepository(api); const dataSetRepository = new DataSetD2Repository(api); const dataValuesRepository = new DataValuesD2Repository(api); const autoCompleteComputeSettingsRepository = new AutoCompleteComputeSettingsD2Repository(api); @@ -184,6 +188,10 @@ export function getCompositionRoot(api: D2Api) { config: getExecute({ get: new GetConfig(configRepository), }), + attachments: getExecute({ + get: new GetAttachementsUseCase(attachementRepository), + export: new ExportAttachmentsUseCase(attachementRepository), + }), nhwa: { getAutoCompleteComputeValues: new GetAutoCompleteComputeValuesUseCase( dataSetRepository, diff --git a/src/data/NHWAAttachementsDefaultRepository .ts b/src/data/NHWAAttachementsDefaultRepository .ts new file mode 100644 index 00000000..45a5415a --- /dev/null +++ b/src/data/NHWAAttachementsDefaultRepository .ts @@ -0,0 +1,110 @@ +import _ from "lodash"; +import { DataAttachmentItem } from "../domain/nhwa-attachments/entities/DataAttachmentItem"; +import { + NHWADataAttachmentsRepository, + NHWADataAttachmentsRepositoryGetOptions, +} from "../domain/nhwa-attachments/repositories/NHWADataAttachmentsRepository"; +import { D2Api, PaginatedObjects, Id } from "../types/d2-api"; +import { Dhis2SqlViews } from "./common/Dhis2SqlViews"; +import { CsvWriterDataSource } from "./common/CsvWriterCsvDataSource"; +import { downloadFile } from "./common/utils/download-file"; +import { CsvData } from "./common/CsvDataSource"; +import { getSqlViewId } from "../domain/common/entities/Config"; +import { SQL_VIEW_ATTACHEMENT_NAME } from "./common/Dhis2ConfigRepository"; + +interface Variables { + orgUnitIds: string; + dataSetIds: string; + periods: string; + orderByColumn: SqlField; + orderByDirection: "asc" | "desc"; +} + +type SqlField = "id" | "datasetname" | "link" | "period" | "storedby" | "orgunit" | "lastupdated"; + +const fieldMapping: Record = { + id: "id", + period: "period", + orgUnit: "orgunit", + dataSet: "datasetname", + link: "link", + lastUpdated: "lastupdated", + storedBy: "storedby", +}; + +export class NHWAAttachementsDefaultRepository implements NHWADataAttachmentsRepository { + constructor(private api: D2Api) {} + + async get(options: NHWADataAttachmentsRepositoryGetOptions): Promise> { + const { config, dataSetIds, orgUnitIds, periods } = options; + const { paging, sorting } = options; + + const allDataSetIds = _.values(config.dataSets).map(ds => ds.id); + const dataSetIds2 = _.isEmpty(dataSetIds) ? allDataSetIds : dataSetIds; + + const sqlViews = new Dhis2SqlViews(this.api); + const { pager, rows } = await sqlViews + .query( + getSqlViewId(config, SQL_VIEW_ATTACHEMENT_NAME), + { + orgUnitIds: sqlViewJoinIds(orgUnitIds), + periods: sqlViewJoinIds(_.isEmpty(periods) ? config.years : periods), + dataSetIds: sqlViewJoinIds(dataSetIds2), + orderByColumn: fieldMapping[sorting.field], + orderByDirection: sorting.direction, + }, + paging + ) + .getData(); + + // A data value is not associated to a specific data set, but we can still map it + // through the data element (1 data value -> 1 data element -> N data sets). + + const dataValues: Array = rows.map( + (dv): DataAttachmentItem => ({ + id: [dv.datasetname, dv.period, dv.orgunit].join("-"), + period: dv.period.split("-")[0] ?? "", + orgUnit: { name: dv.orgunit }, + dataSet: { name: dv.datasetname }, + link: this.api.apiPath + "/" + dv.link, + lastUpdated: new Date(dv.lastupdated), + storedBy: dv.storedby, + }) + ); + + return { pager, objects: dataValues }; + } + + async save(filename: string, dataValues: DataAttachmentItem[]): Promise { + const headers = csvFields.map(field => ({ id: field, text: field })); + const rows = dataValues.map( + (dataValue): DataValueRow => ({ + period: dataValue.period, + orgUnit: dataValue.orgUnit.name, + dataSet: dataValue.dataSet.name, + lastUpdated: dataValue.lastUpdated.toISOString(), + storedBy: dataValue.storedBy, + link: dataValue.link, + }) + ); + + const csvDataSource = new CsvWriterDataSource(); + const csvData: CsvData = { headers, rows }; + const csvContents = csvDataSource.toString(csvData); + + await downloadFile(csvContents, filename, "text/csv"); + } +} + +const csvFields = ["dataSet", "period", "orgUnit", "link", "lastUpdated", "storedBy"] as const; + +type CsvField = typeof csvFields[number]; + +type DataValueRow = Record; + +/* From the docs: "The variables must contain alphanumeric, dash, underscore and + whitespace characters only.". Use "-" as id separator and also "-" as empty value. +*/ +function sqlViewJoinIds(ids: Id[]): string { + return ids.join("-") || "-"; +} diff --git a/src/data/common/Dhis2ConfigRepository.ts b/src/data/common/Dhis2ConfigRepository.ts index e14574f8..c23f9f99 100644 --- a/src/data/common/Dhis2ConfigRepository.ts +++ b/src/data/common/Dhis2ConfigRepository.ts @@ -15,11 +15,17 @@ export const SQL_VIEW_OLD_DATA_DUPLICATION_NAME = "MAL Data Approval Status Pre export const SQL_VIEW_MAL_METADATA_NAME = "MAL Data approval header"; export const SQL_VIEW_MAL_DIFF_NAME = "MAL Data Approval Diff"; export const SQL_VIEW_NHWA_SUBNATIONAL_CORRECT = "NHWA Module 1 Subnational correct org unit name"; +export const SQL_VIEW_ATTACHEMENT_NAME = "NHWA attachments"; const base = { nhwa: { dataSets: { namePrefix: "NHWA", nameExcluded: /old$/ }, - sqlViewNames: [SQL_VIEW_DATA_COMMENTS_NAME, SQL_VIEW_DATA_APPROVAL_NAME, SQL_VIEW_NHWA_SUBNATIONAL_CORRECT], + sqlViewNames: [ + SQL_VIEW_DATA_COMMENTS_NAME, + SQL_VIEW_DATA_APPROVAL_NAME, + SQL_VIEW_NHWA_SUBNATIONAL_CORRECT, + SQL_VIEW_ATTACHEMENT_NAME, + ], constantCode: "NHWA_COMMENTS", approvalWorkflows: { namePrefix: "NHWA" }, }, diff --git a/src/domain/nhwa-attachments/entities/DataAttachmentItem.ts b/src/domain/nhwa-attachments/entities/DataAttachmentItem.ts new file mode 100644 index 00000000..3f1d600a --- /dev/null +++ b/src/domain/nhwa-attachments/entities/DataAttachmentItem.ts @@ -0,0 +1,15 @@ +import { Id, Named } from "../../common/entities/Base"; + +export interface DataAttachmentItem { + id: string; + period: string; + orgUnit: Named; + dataSet: Named; + link: string; + lastUpdated: Date; + storedBy: string; +} + +export function getDataAttachmentsItemId(dataValue: DataAttachmentItem): Id { + return [dataValue.dataSet.name, dataValue.period, dataValue.orgUnit.name].join("-"); +} diff --git a/src/domain/nhwa-attachments/repositories/NHWADataAttachmentsRepository.ts b/src/domain/nhwa-attachments/repositories/NHWADataAttachmentsRepository.ts new file mode 100644 index 00000000..b1dbdad4 --- /dev/null +++ b/src/domain/nhwa-attachments/repositories/NHWADataAttachmentsRepository.ts @@ -0,0 +1,18 @@ +import { DataAttachmentItem } from "../entities/DataAttachmentItem"; +import { Id } from "../../common/entities/Base"; +import { Config } from "../../common/entities/Config"; +import { PaginatedObjects, Paging, Sorting } from "../../common/entities/PaginatedObjects"; + +export interface NHWADataAttachmentsRepository { + get(options: NHWADataAttachmentsRepositoryGetOptions): Promise>; + save(filename: string, dataValues: DataAttachmentItem[]): Promise; +} + +export interface NHWADataAttachmentsRepositoryGetOptions { + config: Config; + paging: Paging; + sorting: Sorting; + periods: string[]; + orgUnitIds: Id[]; + dataSetIds: Id[]; +} diff --git a/src/domain/nhwa-attachments/usecases/ExportAttachmentsUseCase.ts b/src/domain/nhwa-attachments/usecases/ExportAttachmentsUseCase.ts new file mode 100644 index 00000000..a109ed9d --- /dev/null +++ b/src/domain/nhwa-attachments/usecases/ExportAttachmentsUseCase.ts @@ -0,0 +1,13 @@ +import { + NHWADataAttachmentsRepository, + NHWADataAttachmentsRepositoryGetOptions, +} from "../repositories/NHWADataAttachmentsRepository"; + +export class ExportAttachmentsUseCase { + constructor(private attachmentRepository: NHWADataAttachmentsRepository) {} + + async execute(options: NHWADataAttachmentsRepositoryGetOptions): Promise { + const attachments = await this.attachmentRepository.get(options); + await this.attachmentRepository.save("data-values.csv", attachments.objects); + } +} diff --git a/src/domain/nhwa-attachments/usecases/GetAttachementsUseCase.ts b/src/domain/nhwa-attachments/usecases/GetAttachementsUseCase.ts new file mode 100644 index 00000000..23c65c16 --- /dev/null +++ b/src/domain/nhwa-attachments/usecases/GetAttachementsUseCase.ts @@ -0,0 +1,16 @@ +import { + NHWADataAttachmentsRepository, + NHWADataAttachmentsRepositoryGetOptions, +} from "../repositories/NHWADataAttachmentsRepository"; +import { DataAttachmentItem } from "../entities/DataAttachmentItem"; +import { PaginatedObjects } from "../../common/entities/PaginatedObjects"; + +type GetAttachementsUseCaseOptions = NHWADataAttachmentsRepositoryGetOptions; + +export class GetAttachementsUseCase { + constructor(private attachmentRepository: NHWADataAttachmentsRepository) {} + + async execute(options: GetAttachementsUseCaseOptions): Promise> { + return this.attachmentRepository.get(options); + } +} diff --git a/src/webapp/reports/Reports.tsx b/src/webapp/reports/Reports.tsx index ce21115a..2f40d08d 100644 --- a/src/webapp/reports/Reports.tsx +++ b/src/webapp/reports/Reports.tsx @@ -14,6 +14,7 @@ import CSYAuditTraumaReport from "./csy-audit-trauma/CSYAuditTraumaReport"; import { NHWAAutoCompleteCompute } from "./nhwa-auto-complete-compute/NHWAAutoCompleteCompute"; import { NHWAFixTotals } from "./nhwa-fix-totals-activity-level/NHWAFixTotals"; import { NHWASubnationalCorrectOrgUnit } from "./nhwa-subnational-correct-orgunit/NHWASubnationalCorrectOrgUnit"; +import NHWAAttachmentReport from "./nhwa-attachments/NHWAAttachmentReport"; const widget = process.env.REACT_APP_REPORT_VARIANT || ""; @@ -34,6 +35,9 @@ const Component: React.FC = () => { case "admin": { return ; } + case "nhwa-attachments": { + return ; + } case "wmr-national-policies": { return ; } diff --git a/src/webapp/reports/nhwa-attachments/DataAttachmentsViewModel.ts b/src/webapp/reports/nhwa-attachments/DataAttachmentsViewModel.ts new file mode 100644 index 00000000..b6a64bd5 --- /dev/null +++ b/src/webapp/reports/nhwa-attachments/DataAttachmentsViewModel.ts @@ -0,0 +1,5 @@ +import { Config } from "../../../domain/common/entities/Config"; +import { + DataAttachmentItem, + getDataAttachmentsItemId, +} from "../../../domain/nhwa-attachments/entities/DataAttachmentItem"; diff --git a/src/webapp/reports/nhwa-attachments/NHWAAttachmentReport.tsx b/src/webapp/reports/nhwa-attachments/NHWAAttachmentReport.tsx new file mode 100644 index 00000000..7582fac1 --- /dev/null +++ b/src/webapp/reports/nhwa-attachments/NHWAAttachmentReport.tsx @@ -0,0 +1,23 @@ +import { Typography, makeStyles } from "@material-ui/core"; +import i18n from "../../../locales"; +import { DataAttachmentsList } from "./data-attachment-list/DataAttachmentsList"; + +const NHWAAttachmentReport: React.FC = () => { + const classes = useStyles(); + + return ( +
+ + {i18n.t("NHWA Attachment Report")} + + + +
+ ); +}; + +const useStyles = makeStyles({ + wrapper: { padding: 20 }, +}); + +export default NHWAAttachmentReport; diff --git a/src/webapp/reports/nhwa-attachments/data-attachment-list/DataAttachmentsList.tsx b/src/webapp/reports/nhwa-attachments/data-attachment-list/DataAttachmentsList.tsx new file mode 100644 index 00000000..75b07b9d --- /dev/null +++ b/src/webapp/reports/nhwa-attachments/data-attachment-list/DataAttachmentsList.tsx @@ -0,0 +1,131 @@ +import { + PaginationOptions, + TableColumn, + TableGlobalAction, + TablePagination, + TableSorting, +} from "@eyeseetea/d2-ui-components"; +import StorageIcon from "@material-ui/icons/Storage"; +import CloudDownload from "@material-ui/icons/CloudDownload"; +import _ from "lodash"; +import React from "react"; +import { sortByName } from "../../../../domain/common/entities/Base"; +import { Config, getMainUserPaths } from "../../../../domain/common/entities/Config"; +import { getOrgUnitIdsFromPaths } from "../../../../domain/common/entities/OrgUnit"; +import { Sorting } from "../../../../domain/common/entities/PaginatedObjects"; +import { DataAttachmentItem } from "../../../../domain/nhwa-attachments/entities/DataAttachmentItem"; +import i18n from "../../../../locales"; +import { TableConfig, useObjectsTable } from "../../../components/objects-list/objects-list-hooks"; +import { ObjectsList } from "../../../components/objects-list/ObjectsList"; +import { useAppContext } from "../../../contexts/app-context"; +import { useSnackbarOnError } from "../../../utils/snackbar"; +import { FiltersBox } from "../../nhwa-comments/data-comments-list/FiltersBox"; +import { DataValuesFilter } from "./../../nhwa-comments/data-comments-list/Filters"; + +export const DataAttachmentsList: React.FC = React.memo(() => { + const { compositionRoot, config } = useAppContext(); + const [filters, setFilters] = React.useState(() => getEmptyDataValuesFilter(config)); + const baseConfig = React.useMemo(getBaseListConfig, []); + const [sorting, setSorting] = React.useState>({ + field: "dataSet", + order: "asc", + }); + + const getRows = React.useMemo( + () => async (paging: TablePagination, sorting: TableSorting) => { + const { pager, objects } = await compositionRoot.attachments.get({ + config, + paging: { page: paging.page, pageSize: paging.pageSize }, + sorting: getSortingFromTableSorting(sorting), + ...getUseCaseOptions(filters), + }); + setSorting(sorting); + return { pager, objects }; + }, + [config, compositionRoot, filters] + ); + + const getRowsWithSnackbarOrError = useSnackbarOnError(getRows); + const tableProps = useObjectsTable(baseConfig, getRowsWithSnackbarOrError); + const filterOptions = React.useMemo(() => getFilterOptions(config, filters), [config, filters]); + + const downloadCsv: TableGlobalAction = { + name: "downloadCsv", + text: "Download CSV", + icon: , + onClick: async () => { + await compositionRoot.attachments.export({ + config, + paging: { page: 1, pageSize: 100000 }, + sorting: getSortingFromTableSorting(sorting), + ...getUseCaseOptions(filters), + }); + }, + }; + + return ( + {...tableProps} globalActions={[downloadCsv]}> + + + ); +}); + +function getUseCaseOptions(filter: DataValuesFilter) { + return { ...filter, orgUnitIds: getOrgUnitIdsFromPaths(filter.orgUnitPaths) }; +} + +function getSortingFromTableSorting(sorting: TableSorting): Sorting { + return { field: sorting.field === "id" ? "period" : sorting.field, direction: sorting.order }; +} + +function getBaseListConfig(): TableConfig { + const paginationOptions: PaginationOptions = { pageSizeOptions: [10, 20, 50], pageSizeInitialValue: 10 }; + + const initialSorting: TableSorting = { field: "dataSet" as const, order: "asc" as const }; + + const columns: TableColumn[] = [ + { name: "dataSet", text: i18n.t("Data set"), sortable: true }, + { name: "period", text: i18n.t("Period"), sortable: true }, + { name: "orgUnit", text: i18n.t("Organisation unit"), sortable: true }, + { + name: "link", + text: i18n.t("File"), + sortable: true, + getValue: model => ( + + + + ), + }, + { name: "lastUpdated", text: i18n.t("Last updated"), sortable: true, hidden: true }, + { name: "storedBy", text: i18n.t("Stored by"), sortable: true, hidden: true }, + ]; + + return { columns, initialSorting, paginationOptions }; +} + +function getFilterOptions(config: Config, filters: DataValuesFilter) { + const { dataSetIds } = filters; + const sections = _(config.sectionsByDataSet) + .at(_.isEmpty(dataSetIds) ? _.keys(config.sectionsByDataSet) : dataSetIds) + .flatten() + .compact() + .uniqBy(section => section.id) + .value(); + + return { + periods: config.years, + dataSets: sortByName(_.values(config.dataSets)), + sections: sortByName(sections), + }; +} + +function getEmptyDataValuesFilter(config: Config): DataValuesFilter { + return { + orgUnitPaths: getMainUserPaths(config), + periods: [], + dataSetIds: [], + sectionIds: [], + showSections: false, + }; +} diff --git a/src/webapp/reports/nhwa-comments/data-comments-list/DataCommentsList.tsx b/src/webapp/reports/nhwa-comments/data-comments-list/DataCommentsList.tsx index d20d1e9d..be25dd21 100644 --- a/src/webapp/reports/nhwa-comments/data-comments-list/DataCommentsList.tsx +++ b/src/webapp/reports/nhwa-comments/data-comments-list/DataCommentsList.tsx @@ -133,5 +133,6 @@ function getEmptyDataValuesFilter(config: Config): DataValuesFilter { periods: [], dataSetIds: [], sectionIds: [], + showSections: true, }; } diff --git a/src/webapp/reports/nhwa-comments/data-comments-list/Filters.tsx b/src/webapp/reports/nhwa-comments/data-comments-list/Filters.tsx index 1c98c780..32011c91 100644 --- a/src/webapp/reports/nhwa-comments/data-comments-list/Filters.tsx +++ b/src/webapp/reports/nhwa-comments/data-comments-list/Filters.tsx @@ -17,6 +17,7 @@ export interface DataValuesFilter { periods: string[]; dataSetIds: Id[]; sectionIds: Id[]; + showSections: boolean; } interface FilterOptions { @@ -56,12 +57,14 @@ export const Filters: React.FC = React.memo(props => { label={i18n.t("Data sets")} /> - onChange({ ...filter, sectionIds })} - label={i18n.t("Sections")} - /> + {filter.showSections && ( + onChange({ ...filter, sectionIds })} + label={i18n.t("Sections")} + /> + )} ); });