diff --git a/README.md b/README.md index 78cde7ab..77e17e96 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,7 @@ Available levels: 'debug' | 'info' | 'warn' | 'error' - [Revert data values](#revert-data-values) - [Delete duplicated event data values](#delete-duplicated-event-data-values) - [Email notification for data values](#email-notification-for-data-values) + - [Move data value set to a new org unit](#move-data-value-set-to-a-new-org-unit) - [Notifications](#notifications) - [Send user info email](#send-user-info-email) - [Load testing](#load-testing) @@ -438,6 +439,38 @@ FLAGS: --help, -h - show help ``` + +### Move data value set to a new org unit + +Move a data set values from a source org unit to a target org unit. +The values can be limited to a date range via the start/end date option. If no start date is provided the script uses _1970-01-01_. If no end date is provided the script uses either the last day of the current year or the last day of the year of start date if its in the future. +Its possible to delete the values in the target org unit before moving the values and to delete the values in the source org unit after moving the values. + +Usage example: +```shell +$ yarn start datavalues change-orgunit \ + --url "http://localhost:8080" --auth "USER:PASSWORD" \ + --dataset-id='Tu81BTLUuCT' --source-orgunit-id='XKKI1hhyFxk' --target-orgunit-id='XKKI1hhyFxK' --start-date='2026-01-01' --end-date='2026-01-31' --delete-target --delete-source --dry-run +``` + +#### Options +```shell +OPTIONS: + --url - http[s]://[USERNAME:PASSWORD@]HOST:PORT + --auth - USERNAME:PASSWORD [optional] + --dataset-id - Data set ID + --source-orgunit-id - Source org unit ID + --target-orgunit-id - Target org unit ID + --start-date - Start date (YYYY-MM-DD) [optional] + --end-date - End date (YYYY-MM-DD) [optional] + +FLAGS: + --delete-source - Delete the source data values after changing the org unit + --delete-target - Delete the target data values before changing the org unit + --dry-run - Perform the operation in dry run mode + --help, -h - show help +``` + ## Notifications ### Send user info email diff --git a/src/data/DataValuesD2Repository.ts b/src/data/DataValuesD2Repository.ts index bd226b30..aab8cc40 100644 --- a/src/data/DataValuesD2Repository.ts +++ b/src/data/DataValuesD2Repository.ts @@ -63,9 +63,10 @@ export class DataValuesD2Repository implements DataValuesRepository { } } - async post(options: { dataValues: DataValueToPost[] }): Async { + async post(options: { dataValues: DataValueToPost[]; dryRun?: boolean }): Async { return this.postDataValueSet({ dataValues: options.dataValues, + postParams: { dryRun: options.dryRun }, }); } diff --git a/src/domain/repositories/DataValuesRepository.ts b/src/domain/repositories/DataValuesRepository.ts index 99bfa048..c07418f4 100644 --- a/src/domain/repositories/DataValuesRepository.ts +++ b/src/domain/repositories/DataValuesRepository.ts @@ -5,7 +5,7 @@ import { DataValueAudit } from "domain/entities/DataValueAudit"; export interface DataValuesRepository { get(options: DataValuesSelector): Async; - post(options: { dataValues: DataValueToPost[] }): Async; + post(options: { dataValues: DataValueToPost[]; dryRun?: boolean }): Async; delete(options: { dataValues: DataValueToPost[]; dryRun: boolean }): Async; getMetadata(options: { dataValues: DataValue[] }): Async; getAudits(options: DataValueAuditsSelector): Async; diff --git a/src/domain/usecases/ChangeDataValuesOrgUnitUseCase.ts b/src/domain/usecases/ChangeDataValuesOrgUnitUseCase.ts new file mode 100644 index 00000000..9ed34ce9 --- /dev/null +++ b/src/domain/usecases/ChangeDataValuesOrgUnitUseCase.ts @@ -0,0 +1,138 @@ +import { Logger } from "domain/logger/Logger"; + +import { OrgUnitRepository } from "domain/repositories/OrgUnitRepository"; +import { DataSetsRepository } from "domain/repositories/DataSetsRepository"; +import { DataValuesRepository, DataValuesSelector } from "domain/repositories/DataValuesRepository"; + +interface ChangeDataValuesOrgUnitOptions { + dataSetId: string; + sourceOrgUnitId: string; + targetOrgUnitId: string; + startDate?: string; + endDate?: string; + deleteSourceDataValues: boolean; + deleteTargetDataValues: boolean; + dryRun: boolean; +} + +export class ChangeDataValuesOrgUnitUseCase { + constructor( + private logger: Logger, + private orgUnitRepository: OrgUnitRepository, + private dataSetsRepository: DataSetsRepository, + private dataValuesRepository: DataValuesRepository + ) {} + + async execute(options: ChangeDataValuesOrgUnitOptions) { + const { + dataSetId, + sourceOrgUnitId, + targetOrgUnitId, + startDate, + endDate, + deleteSourceDataValues, + deleteTargetDataValues, + dryRun, + } = options; + + const dataSetsResults = await this.dataSetsRepository.get([dataSetId]); + const dataSet = dataSetsResults[dataSetId]; + + if (!dataSet) { + throw new Error(`Data set with ID ${dataSetId} not found.`); + } + + const orgUnits = await this.orgUnitRepository.getByIdentifiables([sourceOrgUnitId, targetOrgUnitId]); + if (!orgUnits.some(orgUnit => orgUnit.id === sourceOrgUnitId)) { + throw new Error(`Source org unit with ID ${sourceOrgUnitId} not found.`); + } + if (!orgUnits.some(orgUnit => orgUnit.id === targetOrgUnitId)) { + throw new Error(`Target org unit with ID ${targetOrgUnitId} not found.`); + } + + this.logger.info( + `Changing org unit from ${sourceOrgUnitId} to ${targetOrgUnitId} for data set: ${dataSetId}` + ); + + const dataValuesSelector: DataValuesSelector = { + dataSetIds: [dataSetId], + orgUnitIds: [sourceOrgUnitId], + startDate: this.getDefaultDate(startDate, endDate, "start"), + endDate: this.getDefaultDate(startDate, endDate, "end"), + }; + + this.logger.info(`Data values selector: ${JSON.stringify(dataValuesSelector, null, 4)}`); + if (dryRun) { + this.logger.info("Dry run mode enabled. No data values will be posted or deleted."); + } + + const dataValues = await this.dataValuesRepository.get(dataValuesSelector); + + if (dataValues.length === 0) { + this.logger.info("No data values found."); + return; + } + + this.logger.info(`Found ${dataValues.length} data values to change org unit.`); + this.logger.debug(`Data values: ${JSON.stringify(dataValues, null, 4)}`); + + if (deleteTargetDataValues) { + this.logger.info("Deleting target org unit data values..."); + const targetDataValuesSelector: DataValuesSelector = { + dataSetIds: [dataSetId], + orgUnitIds: [targetOrgUnitId], + startDate: dataValuesSelector.startDate, + endDate: dataValuesSelector.endDate, + }; + const targetDataValues = await this.dataValuesRepository.get(targetDataValuesSelector); + if (targetDataValues.length > 0) { + this.logger.info(`Deleting ${targetDataValues.length} target org unit data values.`); + await this.dataValuesRepository.delete({ + dataValues: targetDataValues, + dryRun: dryRun, + }); + } else { + this.logger.info("No target org unit data values found to delete."); + } + } + + this.logger.info("Posting data values to target org unit."); + const updatedDataValues = dataValues.map(dataValue => ({ + ...dataValue, + orgUnit: targetOrgUnitId, + })); + + await this.dataValuesRepository.post({ + dataValues: updatedDataValues, + dryRun: dryRun, + }); + + if (deleteSourceDataValues) { + this.logger.info("Deleting source org unit data values..."); + await this.dataValuesRepository.delete({ + dataValues: dataValues, + dryRun: dryRun, + }); + } + } + + private getDefaultDate( + startDate: string | undefined, + endDate: string | undefined, + type: "start" | "end" + ): string { + if (type === "start") { + return startDate || "1970-01-01"; + } else { + const now = new Date(); + const endOfCurrentYear = now.getFullYear(); + if (!startDate) { + return `${endOfCurrentYear}-12-31`; + } else { + const startYear = new Date(startDate).getFullYear(); + const defaultEndYear = endOfCurrentYear < startYear ? startYear : endOfCurrentYear; + return endDate || `${defaultEndYear}-12-31`; + } + } + } +} diff --git a/src/scripts/commands/dataValues.ts b/src/scripts/commands/dataValues.ts index a60d863b..6d8cf71b 100644 --- a/src/scripts/commands/dataValues.ts +++ b/src/scripts/commands/dataValues.ts @@ -28,6 +28,7 @@ import { ExecutionJsonRepository } from "data/DataSetExecutionJsonRepository"; import { TimeZoneD2Repository } from "data/TimeZoneD2Repository"; import { BulkDeleteDataValuesUseCase } from "domain/usecases/BulkDeleteDataValuesUseCase"; import { Ref } from "domain/entities/Base"; +import { ChangeDataValuesOrgUnitUseCase } from "domain/usecases/ChangeDataValuesOrgUnitUseCase"; const SEND_EMAIL_AFTER_MINUTES = 5; const BULK_DELETE_DEFAULT_BATCH_SIZE = 30000; @@ -41,6 +42,7 @@ export function getCommand() { "post-dangling-values": postDanglingValuesCmd, "monitoring-values": monitoringDataValues, "bulk-delete": bulkDeleteDataValuesCmd, + "change-orgunit": changeDataValueSetOrgUnitCmd, }, }); } @@ -320,6 +322,73 @@ const bulkDeleteDataValuesCmd = command({ }, }); +const changeDataValueSetOrgUnitCmd = command({ + name: "change-orgunit", + description: "Change the org unit of a data value set", + args: { + ...getApiUrlOptions(), + dataSetId: option({ + type: string, + long: "dataset-id", + description: "Data set ID", + }), + sourceOrgUnitId: option({ + type: string, + long: "source-orgunit-id", + description: "Source org unit ID", + }), + targetOrgUnitId: option({ + type: string, + long: "target-orgunit-id", + description: "Target org unit ID", + }), + startDate: option({ + type: optional(string), + long: "start-date", + description: "Start date (YYYY-MM-DD)", + }), + endDate: option({ + type: optional(string), + long: "end-date", + description: "End date (YYYY-MM-DD)", + }), + deleteSourceDataValues: flag({ + long: "delete-source", + description: "Delete the source data values after changing the org unit", + }), + deleteTargetDataValues: flag({ + long: "delete-target", + description: "Delete the target data values before changing the org unit", + }), + dryRun: flag({ + long: "dry-run", + description: "Perform the operation in dry run mode", + }), + }, + handler: async args => { + try { + if (args.sourceOrgUnitId === args.targetOrgUnitId) { + throw new Error("Source and target org unit IDs are the same."); + } + + const api = getD2ApiFromArgs(args); + const orgUnitRepository = new OrgUnitD2Repository(api); + const dataSetsRepository = new DataSetsD2Repository(api); + const dataValuesRepository = new DataValuesD2Repository(api); + + await new ChangeDataValuesOrgUnitUseCase( + new TerminalLogger(), + orgUnitRepository, + dataSetsRepository, + dataValuesRepository + ).execute(args); + } catch (error) { + console.error((error as Error).message); + process.exit(1); + } + }, +}); + async function readDataElementsFile(csvPath: string): Promise { if (!fs.existsSync(csvPath) || !fs.statSync(csvPath).isFile()) { throw new Error(`Can't find file: ${csvPath}`);