diff --git a/src/models/CountryDashboard.ts b/src/models/CountryDashboard.ts index a6958ee5..e807f344 100644 --- a/src/models/CountryDashboard.ts +++ b/src/models/CountryDashboard.ts @@ -33,7 +33,7 @@ import { Condition, DashboardSourceMetadata, } from "./ProjectsListDashboard"; -import { D2Sharing, getD2Access } from "./Sharing"; +import { D2Sharing, getD2EntitiesAccess, fullMetadataAccess } from "./Sharing"; type D2VisualizationPayload = PartialPersistedModel; @@ -241,9 +241,14 @@ export default class CountryDashboard { return d2Table ? { ...d2Table, ...chart.extra } : null; } - getSharing(): Partial { + getSharing(): D2Sharing { + const { userAccesses, userGroupAccesses } = this.country.projectsListDashboard.sharing; + return { - publicAccess: getD2Access({ meta: { read: true, write: true } }), + publicAccess: "--------", + externalAccess: false, + userAccesses: getD2EntitiesAccess(userAccesses, fullMetadataAccess), + userGroupAccesses: getD2EntitiesAccess(userGroupAccesses, fullMetadataAccess), }; } } diff --git a/src/models/ProjectsList.ts b/src/models/ProjectsList.ts index a3cff1cc..2a7d5466 100644 --- a/src/models/ProjectsList.ts +++ b/src/models/ProjectsList.ts @@ -1,5 +1,4 @@ import _ from "lodash"; -import { TableSorting } from "@eyeseetea/d2-ui-components"; import { D2Api, D2OrganisationUnitSchema, SelectedPick, Id, Pager, Ref } from "../types/d2-api"; import { Config } from "./Config"; import moment, { Moment } from "moment"; @@ -399,3 +398,8 @@ function getOrgUnitsFilter(filters: FiltersForList, currentUser: User) { : userCountryIds; return filterCountryIds ? { "parent.id": { in: filterCountryIds } } : {}; } + +export type TableSorting = { + field: keyof T; + order: "asc" | "desc"; +}; diff --git a/src/models/__tests__/data/project-db-metadata.json b/src/models/__tests__/data/project-db-metadata.json index 188f4f34..e2b8f832 100644 --- a/src/models/__tests__/data/project-db-metadata.json +++ b/src/models/__tests__/data/project-db-metadata.json @@ -1210,7 +1210,27 @@ "y": 90 } ], - "publicAccess": "rw------" + "publicAccess": "--------", + "externalAccess": false, + "userAccesses": [ + { + "id": "M5zQapPyTZI", + "displayName": "admin admin", + "access": "rw------" + } + ], + "userGroupAccesses": [ + { + "id": "ywuI2WspUUG", + "displayName": "System Admin", + "access": "rw------" + }, + { + "id": "mKKNXzeIAJs", + "displayName": "Data Management Admin", + "access": "rw------" + } + ] }, { "id": "OiCmorbkHNf", @@ -3112,7 +3132,27 @@ ] } ], - "publicAccess": "rw------" + "publicAccess": "--------", + "externalAccess": false, + "userAccesses": [ + { + "id": "M5zQapPyTZI", + "displayName": "admin admin", + "access": "rw------" + } + ], + "userGroupAccesses": [ + { + "id": "ywuI2WspUUG", + "displayName": "System Admin", + "access": "rw------" + }, + { + "id": "mKKNXzeIAJs", + "displayName": "Data Management Admin", + "access": "rw------" + } + ] }, { "id": "CS7csnUtibF", @@ -3230,7 +3270,27 @@ ] } ], - "publicAccess": "rw------" + "publicAccess": "--------", + "externalAccess": false, + "userAccesses": [ + { + "id": "M5zQapPyTZI", + "displayName": "admin admin", + "access": "rw------" + } + ], + "userGroupAccesses": [ + { + "id": "ywuI2WspUUG", + "displayName": "System Admin", + "access": "rw------" + }, + { + "id": "mKKNXzeIAJs", + "displayName": "Data Management Admin", + "access": "rw------" + } + ] }, { "id": "GqYJDh6asM8", @@ -3378,7 +3438,27 @@ ] } ], - "publicAccess": "rw------" + "publicAccess": "--------", + "externalAccess": false, + "userAccesses": [ + { + "id": "M5zQapPyTZI", + "displayName": "admin admin", + "access": "rw------" + } + ], + "userGroupAccesses": [ + { + "id": "ywuI2WspUUG", + "displayName": "System Admin", + "access": "rw------" + }, + { + "id": "mKKNXzeIAJs", + "displayName": "Data Management Admin", + "access": "rw------" + } + ] }, { "id": "me6p7L8VHXl", @@ -3490,7 +3570,27 @@ ] } ], - "publicAccess": "rw------" + "publicAccess": "--------", + "externalAccess": false, + "userAccesses": [ + { + "id": "M5zQapPyTZI", + "displayName": "admin admin", + "access": "rw------" + } + ], + "userGroupAccesses": [ + { + "id": "ywuI2WspUUG", + "displayName": "System Admin", + "access": "rw------" + }, + { + "id": "mKKNXzeIAJs", + "displayName": "Data Management Admin", + "access": "rw------" + } + ] }, { "id": "uqMTlezGj0I", diff --git a/src/pages/projects-list/ProjectsList.tsx b/src/pages/projects-list/ProjectsList.tsx index 7d8d1cf9..9c58993b 100644 --- a/src/pages/projects-list/ProjectsList.tsx +++ b/src/pages/projects-list/ProjectsList.tsx @@ -1,5 +1,4 @@ import _ from "lodash"; -import { TableSorting } from "@eyeseetea/d2-ui-components"; import React, { useCallback } from "react"; import styled from "styled-components"; import ActionButton from "../../components/action-button/ActionButton"; @@ -15,7 +14,7 @@ import { ObjectsList, ObjectsListProps } from "../../components/objects-list/Obj import { useAppContext } from "../../contexts/api-context"; import i18n from "../../locales"; import Project from "../../models/Project"; -import { FiltersForList, ProjectForList } from "../../models/ProjectsList"; +import { FiltersForList, ProjectForList, TableSorting } from "../../models/ProjectsList"; import { useGoTo } from "../../router"; import { Id } from "../../types/d2-api"; import { ActionName, getComponentConfig, UrlState } from "./ProjectsListConfig"; diff --git a/src/pages/unique-periods/UniquePeriodsForm.tsx b/src/pages/unique-periods/UniquePeriodsForm.tsx index 198ba202..7717754e 100644 --- a/src/pages/unique-periods/UniquePeriodsForm.tsx +++ b/src/pages/unique-periods/UniquePeriodsForm.tsx @@ -10,6 +10,7 @@ import { import i18n from "../../locales"; import { Maybe } from "../../types/utils"; import { getErrors } from "../../domain/entities/generic/Errors"; +import { months } from "../../utils/date"; export type UniquePeriodsFormProps = { existingPeriod?: UniqueBeneficiariesPeriodsAttrs; @@ -17,21 +18,6 @@ export type UniquePeriodsFormProps = { onSubmit: (uniquePeriods: UniqueBeneficiariesPeriod) => void; }; -export const months = [ - { value: "1", text: i18n.t("January") }, - { value: "2", text: i18n.t("February") }, - { value: "3", text: i18n.t("March") }, - { value: "4", text: i18n.t("April") }, - { value: "5", text: i18n.t("May") }, - { value: "6", text: i18n.t("June") }, - { value: "7", text: i18n.t("July") }, - { value: "8", text: i18n.t("August") }, - { value: "9", text: i18n.t("September") }, - { value: "10", text: i18n.t("October") }, - { value: "11", text: i18n.t("November") }, - { value: "12", text: i18n.t("December") }, -]; - function getValueByAttribute( value: string, attribute: keyof UniqueBeneficiariesPeriod diff --git a/src/scripts/fix-country-dashboard-sharing.ts b/src/scripts/fix-country-dashboard-sharing.ts new file mode 100644 index 00000000..6093740e --- /dev/null +++ b/src/scripts/fix-country-dashboard-sharing.ts @@ -0,0 +1,314 @@ +import _ from "lodash"; +import parse from "parse-typed-args"; + +import { D2Api, Id, MetadataPick } from "../types/d2-api"; +import { writeDataFilePath } from "./common"; +import { promiseMap } from "../migrations/utils"; +import { getUid } from "../utils/dhis2"; +import CountryDashboard from "../models/CountryDashboard"; +import { D2Sharing } from "../models/Sharing"; +import { Config, getConfig } from "../models/Config"; + +/* + Update sharing (publicAccess, externalAccess, userAccesses, userGroupAccesses) + on every existing country dashboard and its visualizations, using the sharing + computed by CountryDashboard.getSharing(). + + npx tsx src/scripts/fix-country-dashboard-sharing.ts \ + --url="http://server.com" [--auth=user:pass] [--country-ids=id1,id2] [--persist] +*/ + +type D2Dashboard = MetadataPick<{ + dashboards: { fields: { $owner: true } }; +}>["dashboards"][number]; + +type D2Visualization = MetadataPick<{ + visualizations: { fields: { $owner: true } }; +}>["visualizations"][number]; + +type D2DashboardItem = D2Dashboard["dashboardItems"][number]; + +interface Country { + readonly id: Id; + readonly displayName: string; + readonly dashboardId: Id; +} + +interface SharingComputation { + readonly sharingByCountryId: Readonly>; + readonly failedCountries: Country[]; +} + +main().catch(err => { + console.error(err); + process.exit(1); +}); + +async function main() { + const parser = parse({ + opts: { + url: {}, + auth: {}, + "country-ids": {}, + persist: { switch: true }, + }, + }); + const { opts } = parser(process.argv); + const { url, auth, "country-ids": countryIdsRaw, persist } = opts; + + const usage = + "fix-country-dashboard-sharing --url= [--auth=user:pass] [--country-ids=id1,id2] [--persist]"; + if (!url) { + console.error(usage); + process.exit(1); + } + + const [username, password] = auth ? auth.split(":") : ["", ""]; + const api = new D2Api({ baseUrl: url, auth: { password, username }, agent: {} }); + const config = await getConfig(api); + + const filterIds = countryIdsRaw ? countryIdsRaw.split(",").map(s => s.trim()) : undefined; + + const countries = await getCountries(api, config, filterIds); + console.debug(`Countries: ${countries.length}`); + if (countries.length === 0) return; + + const dashboardIds = countries.map(c => c.dashboardId); + const dashboards = await fetchDashboards(api, dashboardIds); + console.debug(`Existing country dashboards: ${dashboards.length} / ${dashboardIds.length}`); + + const vizIds = extractVizIds(dashboards); + const visualizations = await fetchVisualizations(api, vizIds); + console.debug(`Visualizations: ${visualizations.length} / ${vizIds.length}`); + + const { sharingByCountryId, failedCountries } = await computeSharingPerCountry( + api, + config, + countries + ); + + if (failedCountries.length > 0) { + console.error( + `Sharing computation failed for ${failedCountries.length}/${countries.length} countries: ` + + failedCountries.map(c => `${c.displayName} (${c.id})`).join(", ") + ); + } + + const { updatedDashboards, updatedVisualizations } = applySharing( + dashboards, + visualizations, + countries, + sharingByCountryId + ); + + console.debug( + `Payload: dashboards=${updatedDashboards.length}, visualizations=${updatedVisualizations.length}` + ); + + const payload = { + dashboards: updatedDashboards, + visualizations: updatedVisualizations, + }; + + writeDataFilePath("sharing-payload", payload); + console.debug("payload saved to disk"); + + const res = await api.metadata + .post(payload, { importStrategy: "UPDATE", importMode: persist ? "COMMIT" : "VALIDATE" }) + .getData(); + + console.debug(`Import status: ${JSON.stringify(res.status)}`); + + if (res.status !== "OK") throw new Error(JSON.stringify(res, null, 2)); +} + +async function getCountries( + api: D2Api, + config: Config, + filterIds: Id[] | undefined +): Promise { + const { objects } = await api.models.organisationUnits + .get({ + paging: false, + fields: { + id: true, + displayName: true, + attributeValues: { attribute: { id: true, name: true }, value: true }, + }, + // after org. unit reorganization, some country dashboards might be attached to level 3 org. units + // instead of level 2 + filter: { level: { in: ["2", "3"] } }, + }) + .getData(); + + // exclude org. units created by the app + // and without reference to a country dashboard + const notCreatedByDmApp = _(objects) + .map((ou): Country | undefined => { + const createdByDmApp = ou.attributeValues.find( + av => av.attribute.id === config.attributes.createdByApp.id + ); + + const dashboardId = ou.attributeValues.find( + av => av.attribute.id === config.attributes.projectDashboard.id + ); + + const createdValue = createdByDmApp?.value === "true"; + const dashboardValue = dashboardId?.value; + + if (createdValue) return undefined; + if (!dashboardValue) return undefined; + + return { + id: ou.id, + displayName: ou.displayName, + dashboardId: dashboardValue, + }; + }) + .compact() + .value(); + + const sorted = _(notCreatedByDmApp) + .sortBy(c => c.displayName) + .value(); + + return filterIds ? sorted.filter(c => filterIds.includes(c.id)) : sorted; +} + +async function fetchChunked(ids: Id[], fetchChunk: (chunk: Id[]) => Promise): Promise { + if (ids.length === 0) return []; + const chunks = await promiseMap(_.chunk(ids, 100), fetchChunk); + return chunks.flat(); +} + +async function fetchDashboards(api: D2Api, ids: Id[]): Promise { + return fetchChunked(ids, async chunk => { + const { dashboards } = await api.metadata + .get({ dashboards: { fields: { $owner: true }, filter: { id: { in: chunk } } } }) + .getData(); + return dashboards; + }); +} + +async function fetchVisualizations(api: D2Api, ids: Id[]): Promise { + return fetchChunked(ids, async chunk => { + const { visualizations } = await api.metadata + .get({ visualizations: { fields: { $owner: true }, filter: { id: { in: chunk } } } }) + .getData(); + return visualizations; + }); +} + +function getVizIdFromItem(di: D2DashboardItem): Id | undefined { + const anyDi = di as { + chart?: { id?: Id }; + reportTable?: { id?: Id }; + visualization?: { id?: Id }; + }; + return anyDi.chart?.id ?? anyDi.reportTable?.id ?? anyDi.visualization?.id; +} + +function extractVizIds(dashboards: D2Dashboard[]): Id[] { + return _(dashboards) + .flatMap(d => d.dashboardItems ?? []) + .map(getVizIdFromItem) + .compact() + .uniq() + .value(); +} + +async function computeSharingPerCountry( + api: D2Api, + config: Config, + countries: Country[] +): Promise { + const results = await promiseMap(countries, async country => { + console.debug(`Generating sharing for ${country.displayName} (${country.id})...`); + try { + const cd = await CountryDashboard.build(api, config, country.id); + return { country, sharing: cd.getSharing() }; + } catch (e) { + console.error(`Cannot generate sharing for ${country.displayName} (${country.id}):`, e); + return { country, sharing: null }; + } + }); + + const sharingByCountryId = Object.fromEntries( + results + .filter((r): r is { country: Country; sharing: D2Sharing } => r.sharing !== null) + .map(r => [r.country.id, r.sharing] as const) + ); + const failedCountries = results.filter(r => r.sharing === null).map(r => r.country); + + return { sharingByCountryId, failedCountries }; +} + +function withSharing(obj: T, sharing: D2Sharing): T { + return { + ...obj, + publicAccess: sharing.publicAccess, + externalAccess: sharing.externalAccess, + userAccesses: sharing.userAccesses, + userGroupAccesses: sharing.userGroupAccesses, + }; +} + +function applySharing( + dashboards: D2Dashboard[], + visualizations: D2Visualization[], + countries: Country[], + sharingByCountryId: Readonly> +): { + updatedDashboards: D2Dashboard[]; + updatedVisualizations: D2Visualization[]; +} { + const countryByDashboardId: Readonly> = Object.fromEntries( + countries.map(c => [getUid("country-dashboard", c.id), c.id]) + ); + + const dashboardByVizId: Readonly> = Object.fromEntries( + _(dashboards) + .flatMap(d => + (d.dashboardItems ?? []).map(di => { + const vid = getVizIdFromItem(di); + return vid ? ([vid, d.id] as const) : null; + }) + ) + .compact() + .value() + ); + + const updatedDashboards = _(dashboards) + .map(d => { + const sharing = sharingByCountryId[countryByDashboardId[d.id]]; + if (!sharing) { + console.error(`No sharing computed for dashboard ${d.id} — skipping`); + return undefined; + } + return withSharing(d, sharing); + }) + .compact() + .value(); + + const updatedVisualizations = _(visualizations) + .map(v => { + const dashId = dashboardByVizId[v.id]; + const sharing = sharingByCountryId[countryByDashboardId[dashId]]; + if (!sharing) { + console.error( + `No sharing for visualization ${v.id} (dashboard ${dashId}) — skipping` + ); + return undefined; + } + return withSharing(v, sharing); + }) + .compact() + .value(); + + return { + updatedDashboards: _(updatedDashboards) + .uniqBy(dashboard => dashboard.id) + .value(), + updatedVisualizations, + }; +} diff --git a/src/utils/date.ts b/src/utils/date.ts index 8180d29c..35bb52d7 100644 --- a/src/utils/date.ts +++ b/src/utils/date.ts @@ -1,5 +1,5 @@ import moment, { Moment } from "moment"; -import { months } from "../pages/unique-periods/UniquePeriodsForm"; +import i18n from "../locales"; export function toISOString(date: Moment) { return date.format("YYYY-MM-DDTHH:mm:ss"); @@ -58,3 +58,18 @@ export function buildMonthYearFormatDate(dateIsoString: string): string { export function getMonthNameFromNumber(monthNumber: string | number): string { return months.find(month => month.value === monthNumber.toString())?.text || ""; } + +export const months = [ + { value: "1", text: i18n.t("January") }, + { value: "2", text: i18n.t("February") }, + { value: "3", text: i18n.t("March") }, + { value: "4", text: i18n.t("April") }, + { value: "5", text: i18n.t("May") }, + { value: "6", text: i18n.t("June") }, + { value: "7", text: i18n.t("July") }, + { value: "8", text: i18n.t("August") }, + { value: "9", text: i18n.t("September") }, + { value: "10", text: i18n.t("October") }, + { value: "11", text: i18n.t("November") }, + { value: "12", text: i18n.t("December") }, +];