Skip to content

Commit

Permalink
Merge pull request #33 from wri/fix/TM-1581-tree-collections
Browse files Browse the repository at this point in the history
[TM-1581] Make tree related endpoints collection aware.
  • Loading branch information
roguenet authored Dec 19, 2024
2 parents 75566cc + 595fda1 commit 0c89622
Show file tree
Hide file tree
Showing 14 changed files with 239 additions and 145 deletions.
29 changes: 22 additions & 7 deletions apps/entity-service/src/trees/dto/establishment-trees.dto.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { JsonApiAttributes } from "@terramatch-microservices/common/dto/json-api-attributes";
import { JsonApiDto } from "@terramatch-microservices/common/decorators";
import { ApiProperty } from "@nestjs/swagger";
import { Dictionary } from "lodash";

export class PreviousPlantingCountDto {
@ApiProperty({ nullable: true, description: "Taxonomic ID for this tree species row" })
Expand All @@ -17,17 +18,31 @@ export class PreviousPlantingCountDto {
@JsonApiDto({ type: "establishmentTrees", id: "string" })
export class EstablishmentsTreesDto extends JsonApiAttributes<EstablishmentsTreesDto> {
@ApiProperty({
type: [String],
description: "The species that were specified at the establishment of the parent entity."
type: "object",
additionalProperties: { type: "array", items: { type: "string" } },
description: "The species that were specified at the establishment of the parent entity keyed by collection",
example: { "tree-planted": ["Aster Peraliens", "Circium carniolicum"], "non-tree": ["Coffee"] }
})
establishmentTrees: string[];
establishmentTrees: Dictionary<string[]>;

@ApiProperty({
type: "object",
additionalProperties: { $ref: "#/components/schemas/PreviousPlantingCountDto" },
additionalProperties: {
type: "object",
additionalProperties: { $ref: "#/components/schemas/PreviousPlantingCountDto" }
},
nullable: true,
description: "If the entity in this request is a report, the sum totals of previous planting by species.",
example: { "Aster persaliens": { amount: 256 }, "Cirsium carniolicum": { taxonId: "wfo-0000130112", amount: 1024 } }
description:
"If the entity in this request is a report, the sum totals of previous planting by species by collection.",
example: {
"tree-planted": {
"Aster persaliens": { amount: 256 },
"Cirsium carniolicum": { taxonId: "wfo-0000130112", amount: 1024 }
},
"non-tree": {
Coffee: { amount: 2048 }
}
}
})
previousPlantingCounts?: Record<string, PreviousPlantingCountDto>;
previousPlantingCounts?: Dictionary<Dictionary<PreviousPlantingCountDto>>;
}
71 changes: 50 additions & 21 deletions apps/entity-service/src/trees/tree.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,27 +74,42 @@ describe("TreeService", () => {
const nursery = await NurseryFactory.create({ projectId: project.id });
const nurseryReport = await NurseryReportFactory.create({ nurseryId: nursery.id });

const projectTrees = (await TreeSpeciesFactory.forProject.createMany(3, { speciesableId: project.id }))
const projectTreesPlanted = (
await TreeSpeciesFactory.forProjectTreePlanted.createMany(3, { speciesableId: project.id })
)
.map(({ name }) => name)
.sort();
const siteTrees = (await TreeSpeciesFactory.forSite.createMany(2, { speciesableId: site.id }))
const siteTreesPlanted = (await TreeSpeciesFactory.forSiteTreePlanted.createMany(2, { speciesableId: site.id }))
.map(({ name }) => name)
.sort();
const nurseryTrees = (await TreeSpeciesFactory.forNursery.createMany(4, { speciesableId: nursery.id }))
const siteNonTrees = (await TreeSpeciesFactory.forSiteNonTree.createMany(3, { speciesableId: site.id }))
.map(({ name }) => name)
.sort();
const nurserySeedlings = (
await TreeSpeciesFactory.forNurserySeedling.createMany(4, { speciesableId: nursery.id })
)
.map(({ name }) => name)
.sort();

let result = await service.getEstablishmentTrees("project-reports", projectReport.uuid);
expect(result.sort()).toEqual(projectTrees);
console.log("result", result);
expect(Object.keys(result).length).toBe(1);
expect(result["tree-planted"].sort()).toEqual(projectTreesPlanted);
result = await service.getEstablishmentTrees("sites", site.uuid);
expect(result.sort()).toEqual(projectTrees);
expect(Object.keys(result).length).toBe(1);
expect(result["tree-planted"].sort()).toEqual(projectTreesPlanted);
result = await service.getEstablishmentTrees("nurseries", nursery.uuid);
expect(result.sort()).toEqual(projectTrees);
expect(Object.keys(result).length).toBe(1);
expect(result["tree-planted"].sort()).toEqual(projectTreesPlanted);

result = await service.getEstablishmentTrees("site-reports", siteReport.uuid);
expect(result.sort()).toEqual(uniq([...siteTrees, ...projectTrees]).sort());
expect(Object.keys(result).length).toBe(2);
expect(result["tree-planted"].sort()).toEqual(uniq([...siteTreesPlanted, ...projectTreesPlanted]).sort());
expect(result["non-tree"].sort()).toEqual(siteNonTrees);
result = await service.getEstablishmentTrees("nursery-reports", nurseryReport.uuid);
expect(result.sort()).toEqual(uniq([...nurseryTrees, ...projectTrees]).sort());
expect(Object.keys(result).length).toBe(2);
expect(result["tree-planted"].sort()).toEqual(projectTreesPlanted);
expect(result["nursery-seedling"].sort()).toEqual(nurserySeedlings);
});

it("throws with bad inputs to establishment trees", async () => {
Expand Down Expand Up @@ -137,41 +152,55 @@ describe("TreeService", () => {
amount: (counts[tree.name]?.amount ?? 0) + (tree.amount ?? 0)
}
});
const projectReportTrees = await TreeSpeciesFactory.forProjectReport.createMany(3, {
const projectReportTreesPlanted = await TreeSpeciesFactory.forProjectReportTreePlanted.createMany(3, {
speciesableId: projectReport1.id
});
projectReportTrees.push(
await TreeSpeciesFactory.forProjectReport.create({
projectReportTreesPlanted.push(
await TreeSpeciesFactory.forProjectReportTreePlanted.create({
speciesableId: projectReport1.id,
taxonId: "wfo-projectreporttree"
})
);
let result = await service.getPreviousPlanting("project-reports", projectReport2.uuid);
expect(result).toMatchObject(projectReportTrees.reduce(reduceTreeCounts, {}));
expect(Object.keys(result)).toMatchObject(["tree-planted"]);
expect(result).toMatchObject({ "tree-planted": projectReportTreesPlanted.reduce(reduceTreeCounts, {}) });

const siteReport1Trees = await TreeSpeciesFactory.forSiteReport.createMany(3, { speciesableId: siteReport1.id });
siteReport1Trees.push(
await TreeSpeciesFactory.forSiteReport.create({
const siteReport1TreesPlanted = await TreeSpeciesFactory.forSiteReportTreePlanted.createMany(3, {
speciesableId: siteReport1.id
});
siteReport1TreesPlanted.push(
await TreeSpeciesFactory.forSiteReportTreePlanted.create({
speciesableId: siteReport1.id,
taxonId: "wfo-sitereporttree"
})
);
const siteReport2Trees = await TreeSpeciesFactory.forSiteReport.createMany(3, { speciesableId: siteReport2.id });
const siteReport2TreesPlanted = await TreeSpeciesFactory.forSiteReportTreePlanted.createMany(3, {
speciesableId: siteReport2.id
});
const siteReport2NonTrees = await TreeSpeciesFactory.forSiteReportNonTree.createMany(2, {
speciesableId: siteReport2.id
});
result = await service.getPreviousPlanting("site-reports", siteReport1.uuid);
expect(result).toMatchObject({});
result = await service.getPreviousPlanting("site-reports", siteReport2.uuid);
const siteReport1TreesReduced = siteReport1Trees.reduce(reduceTreeCounts, {});
expect(result).toMatchObject(siteReport1TreesReduced);
const siteReport1TreesPlantedReduced = siteReport1TreesPlanted.reduce(reduceTreeCounts, {});
expect(Object.keys(result)).toMatchObject(["tree-planted"]);
expect(result).toMatchObject({ "tree-planted": siteReport1TreesPlantedReduced });
result = await service.getPreviousPlanting("site-reports", siteReport3.uuid);
expect(result).toMatchObject(siteReport2Trees.reduce(reduceTreeCounts, siteReport1TreesReduced));
expect(Object.keys(result).sort()).toMatchObject(["non-tree", "tree-planted"]);
expect(result).toMatchObject({
"tree-planted": siteReport2TreesPlanted.reduce(reduceTreeCounts, siteReport1TreesPlantedReduced),
"non-tree": siteReport2NonTrees.reduce(reduceTreeCounts, {})
});

result = await service.getPreviousPlanting("nursery-reports", nurseryReport2.uuid);
expect(result).toMatchObject({});
const nurseryReportTrees = await TreeSpeciesFactory.forNurseryReport.createMany(5, {
const nurseryReportSeedlings = await TreeSpeciesFactory.forNurseryReportSeedling.createMany(5, {
speciesableId: nurseryReport1.id
});
result = await service.getPreviousPlanting("nursery-reports", nurseryReport2.uuid);
expect(result).toMatchObject(nurseryReportTrees.reduce(reduceTreeCounts, {}));
expect(Object.keys(result)).toMatchObject(["nursery-seedling"]);
expect(result).toMatchObject({ "nursery-seedling": nurseryReportSeedlings.reduce(reduceTreeCounts, {}) });
});

it("handles bad input to get previous planting with undefined or an exception", async () => {
Expand Down
146 changes: 82 additions & 64 deletions apps/entity-service/src/trees/tree.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,12 @@ import {
ProjectReport,
Site,
SiteReport,
TreeSpecies,
TreeSpeciesResearch
} from "@terramatch-microservices/database/entities";
import { Op } from "sequelize";
import { Op, WhereOptions } from "sequelize";
import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common";
import { filter, flatten, uniq } from "lodash";
import { Dictionary, filter, flatten, flattenDeep, groupBy, uniq } from "lodash";
import { PreviousPlantingCountDto } from "./dto/establishment-trees.dto";

export const ESTABLISHMENT_REPORTS = ["project-reports", "site-reports", "nursery-reports"] as const;
Expand All @@ -18,8 +19,23 @@ export type EstablishmentReport = (typeof ESTABLISHMENT_REPORTS)[number];
export const ESTABLISHMENT_ENTITIES = ["sites", "nurseries", ...ESTABLISHMENT_REPORTS] as const;
export type EstablishmentEntity = (typeof ESTABLISHMENT_ENTITIES)[number];

type TreeReportModelType = typeof ProjectReport | typeof SiteReport | typeof NurseryReport;
type TreeModelType = TreeReportModelType | typeof Project | typeof Site | typeof Nursery;

const isReport = (type: EstablishmentEntity): type is EstablishmentReport => type.endsWith("-reports");

const treeAssociations = (model: TreeModelType, attributes: string[], where?: WhereOptions) =>
model.TREE_ASSOCIATIONS.map(association => ({ required: false, association, attributes, where }));

const uniqueTreeNames = (trees: Dictionary<TreeSpecies[]>) =>
Object.keys(trees).reduce(
(dict, collection) => ({
...dict,
[collection]: uniq(filter(trees[collection].map(({ name }) => name)))
}),
{} as Dictionary<string[]>
);

@Injectable()
export class TreeService {
async searchScientificNames(search: string) {
Expand All @@ -39,27 +55,28 @@ export class TreeService {
).map(({ taxonId, scientificName }) => ({ taxonId, scientificName }));
}

async getEstablishmentTrees(entity: EstablishmentEntity, uuid: string): Promise<string[]> {
async getEstablishmentTrees(entity: EstablishmentEntity, uuid: string): Promise<Dictionary<string[]>> {
if (entity === "site-reports" || entity === "nursery-reports") {
// For site and nursery reports, we fetch both the establishment species on the parent entity
// and on the Project
const parentModel = entity === "site-reports" ? Site : Nursery;
const whereOptions = {
where: { uuid },
attributes: [],
include: [
{
model: entity === "site-reports" ? Site : Nursery,
model: parentModel,
// This id isn't necessary for the data we want to fetch, but sequelize requires it for
// the nested includes
attributes: ["id"],
include: [
{ association: "treeSpecies", attributes: ["name"] },
...treeAssociations(parentModel, ["name", "collection"]),
{
model: Project,
// This id isn't necessary for the data we want to fetch, but sequelize requires it for
// the nested includes
attributes: ["id"],
include: [{ association: "treeSpecies", attributes: ["name"] }]
include: treeAssociations(Project, ["name", "collection"])
}
]
}
Expand All @@ -72,9 +89,15 @@ export class TreeService {
if (report == null) throw new NotFoundException();

const parent = report instanceof SiteReport ? report.site : report.nursery;
const parentTrees = parent.treeSpecies.map(({ name }) => name);
const projectTrees = parent.project.treeSpecies.map(({ name }) => name);
return uniq(filter([...parentTrees, ...projectTrees]));
const trees = groupBy(
flattenDeep([
parentModel.TREE_ASSOCIATIONS.map(association => parent[association] as TreeSpecies[]),
Project.TREE_ASSOCIATIONS.map(association => parent.project[association] as TreeSpecies[])
]),
"collection"
);

return uniqueTreeNames(trees);
} else if (["sites", "nurseries", "project-reports"].includes(entity)) {
// for these we simply pull the project's trees
const whereOptions = {
Expand All @@ -86,7 +109,7 @@ export class TreeService {
// This id isn't necessary for the data we want to fetch, but sequelize requires it for
// the nested includes
attributes: ["id"],
include: [{ association: "treeSpecies", attributes: ["name"] }]
include: treeAssociations(Project, ["name", "collection"])
}
]
};
Expand All @@ -98,7 +121,9 @@ export class TreeService {
: ProjectReport.findOne(whereOptions));
if (entityModel == null) throw new NotFoundException();

return filter(entityModel.project.treeSpecies.map(({ name }) => name));
return uniqueTreeNames(
groupBy(flatten(Project.TREE_ASSOCIATIONS.map(association => entityModel.project[association])), "collection")
);
} else {
throw new BadRequestException(`Entity type not supported: [${entity}]`);
}
Expand All @@ -107,73 +132,66 @@ export class TreeService {
async getPreviousPlanting(
entity: EstablishmentEntity,
uuid: string
): Promise<Record<string, PreviousPlantingCountDto>> {
): Promise<Dictionary<Dictionary<PreviousPlantingCountDto>>> {
if (!isReport(entity)) return undefined;

const treeReportWhere = (parentAttribute: string, report: ProjectReport | SiteReport | NurseryReport) => ({
attributes: [],
where: {
[parentAttribute]: report[parentAttribute],
dueAt: { [Op.lt]: report.dueAt }
},
include: [
{
association: "treeSpecies",
attributes: ["taxonId", "name", "amount"],
where: { amount: { [Op.gt]: 0 } }
}
]
});

let records: (SiteReport | ProjectReport | NurseryReport)[];
let model: TreeReportModelType;
switch (entity) {
case "project-reports": {
const report = await ProjectReport.findOne({
where: { uuid },
attributes: ["dueAt", "projectId"]
});
if (report == null) throw new NotFoundException();

records = await ProjectReport.findAll(treeReportWhere("projectId", report));
case "project-reports":
model = ProjectReport;
break;
}

case "site-reports": {
const report = await SiteReport.findOne({
where: { uuid },
attributes: ["dueAt", "siteId"]
});
if (report == null) throw new NotFoundException();

records = await SiteReport.findAll(treeReportWhere("siteId", report));
case "site-reports":
model = SiteReport;
break;
}

case "nursery-reports": {
const report = await NurseryReport.findOne({
where: { uuid },
attributes: ["dueAt", "nurseryId"]
});
if (report == null) throw new NotFoundException();

records = await NurseryReport.findAll(treeReportWhere("nurseryId", report));
case "nursery-reports":
model = NurseryReport;
break;
}

default:
throw new BadRequestException();
}

const trees = flatten(records.map(({ treeSpecies }) => treeSpecies));
return trees.reduce<Record<string, PreviousPlantingCountDto>>(
(counts, tree) => ({
...counts,
[tree.name]: {
taxonId: counts[tree.name]?.taxonId ?? tree.taxonId,
amount: (counts[tree.name]?.amount ?? 0) + (tree.amount ?? 0)
}
// @ts-expect-error Can't narrow the union TreeReportModelType automatically
const report: InstanceType<TreeReportModelType> = await model.findOne({
where: { uuid },
attributes: ["dueAt", model.PARENT_ID]
});
if (report == null) throw new NotFoundException();

// @ts-expect-error Can't narrow the union TreeReportModelType automatically
const records: InstanceType<TreeReportModelType>[] = await model.findAll({
attributes: [],
where: {
[model.PARENT_ID]: report[model.PARENT_ID],
dueAt: { [Op.lt]: report.dueAt }
},
include: treeAssociations(model, ["taxonId", "name", "collection", "amount"], { amount: { [Op.gt]: 0 } })
});

const trees = groupBy(
flattenDeep(
records.map(record => model.TREE_ASSOCIATIONS.map(association => record[association] as TreeSpecies[]))
),
"collection"
);

return Object.keys(trees).reduce(
(dict, collection) => ({
...dict,
[collection]: trees[collection].reduce(
(counts, tree) => ({
...counts,
[tree.name]: {
taxonId: counts[tree.name]?.taxonId ?? tree.taxonId,
amount: (counts[tree.name]?.amount ?? 0) + (tree.amount ?? 0)
}
}),
{} as Dictionary<PreviousPlantingCountDto>
)
}),
{}
{} as Dictionary<Dictionary<PreviousPlantingCountDto>>
);
}
}
Loading

0 comments on commit 0c89622

Please sign in to comment.