Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[TM-1581] Make tree related endpoints collection aware. #33

Merged
merged 1 commit into from
Dec 19, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading