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-1766] add initial changes for supporting entity get #64

Open
wants to merge 37 commits into
base: staging
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
601f7a9
[TM-1766] add initial changes for supporting entity get
pachonjcl Feb 24, 2025
dcbb2d9
add update to site entity
LimberHope Feb 25, 2025
cd2d5b6
add scope to site media
LimberHope Feb 25, 2025
95396fa
Merge branch 'staging' into TM-1766_entity_get_and_index_site
pachonjcl Feb 25, 2025
a9baa60
[TM-1766] update logic to project-manager permissions
LimberHope Feb 25, 2025
d5e8666
[TM-1766] improve logic
LimberHope Feb 25, 2025
c17ac31
[TM-1766] add missing attributes
LimberHope Feb 26, 2025
50917f7
[TM-1766] fix
LimberHope Feb 26, 2025
c531346
remove unused file
pachonjcl Feb 26, 2025
4d68ab1
add new fields
pachonjcl Feb 26, 2025
dabf26b
add test and reduce coverage
pachonjcl Feb 26, 2025
699eada
fix lint
pachonjcl Feb 26, 2025
bdd1572
temporal add basic tests
pachonjcl Feb 26, 2025
090aca0
update site policy
LimberHope Feb 26, 2025
89f3225
Disable usage of NX Cloud
roguenet Feb 26, 2025
d8859d1
Skip NX cloud in deployment.
roguenet Feb 26, 2025
7038534
[TM-1718] First entity association index: Demographics.
roguenet Feb 21, 2025
3bebb48
[TM-1718] Get new endpoint documentation in place.
roguenet Feb 21, 2025
a3c7a55
[TM-1718] Abstract the subquery pattern to a builder.
LimberHope Feb 26, 2025
1fc0ac0
Merge branch 'staging' into TM-1766_entity_get_and_index_site
pachonjcl Feb 26, 2025
72c30e0
[TM-1766] updatee site processor test
LimberHope Feb 27, 2025
6628ff5
[TM-1766] remove entity imported
LimberHope Feb 27, 2025
57fba92
Merge branch 'staging' into TM-1766_entity_get_and_index_site
pachonjcl Feb 27, 2025
9a76910
fix unit test
pachonjcl Feb 27, 2025
fc9cc6e
[TM-1766] add missing attributes to site.dto
LimberHope Feb 27, 2025
672fd08
Merge branch 'staging' into TM-1766_entity_get_and_index_site
pachonjcl Feb 28, 2025
641aa26
fix unit test
pachonjcl Feb 28, 2025
fa25235
Merge branch 'staging' into TM-1766_entity_get_and_index_site
pachonjcl Feb 28, 2025
703102c
[TM-1766] updates of feedback
LimberHope Feb 28, 2025
3f9eead
address comment and increase coverage
pachonjcl Feb 28, 2025
3882513
fix lint
pachonjcl Feb 28, 2025
da5b038
fix test
pachonjcl Feb 28, 2025
7c6dfe4
fix test by using sortUp on test
pachonjcl Mar 1, 2025
4a1fc08
improve branch percentage
pachonjcl Mar 1, 2025
1278b04
fix unit test on search based on external column
pachonjcl Mar 1, 2025
32d2a9d
Merge branch 'staging' into TM-1766_entity_get_and_index_site
pachonjcl Mar 1, 2025
89b51ee
move name from full to light dto site
pachonjcl Mar 1, 2025
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
140 changes: 137 additions & 3 deletions apps/entity-service/src/entities/dto/site.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,7 @@ import { ApiProperty } from "@nestjs/swagger";
import { Site } from "@terramatch-microservices/database/entities";
import { FrameworkKey } from "@terramatch-microservices/database/constants/framework";
import { AdditionalProps, EntityDto } from "./entity.dto";

// TODO: THIS IS A STUB!
import { MediaDto } from "./media.dto";

@JsonApiDto({ type: "sites" })
export class SiteLightDto extends EntityDto {
Expand Down Expand Up @@ -55,6 +54,12 @@ export class SiteLightDto extends EntityDto {
@ApiProperty({ nullable: true })
name: string | null;

@ApiProperty({
nullable: true,
description: "The associated project name"
})
projectName: string | null;

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are lots of fields on the spreadsheet that aren't represented in this lite DTO, and projectName I didn't think was needed except in the full DTO. Is it actually needed here?

@ApiProperty()
createdAt: Date;

Expand All @@ -63,14 +68,14 @@ export class SiteLightDto extends EntityDto {
}

export type AdditionalSiteFullProps = AdditionalProps<SiteFullDto, SiteLightDto, Site>;
export type SiteMedia = Pick<SiteFullDto, keyof typeof Site.MEDIA>;

export class SiteFullDto extends SiteLightDto {
constructor(site: Site, props: AdditionalSiteFullProps) {
super();
this.populate(SiteFullDto, {
...pickApiProperties(site, SiteFullDto),
lightResource: false,
// these two are untyped and marked optional in the base model.
createdAt: site.createdAt as Date,
updatedAt: site.updatedAt as Date,
...props
Expand All @@ -86,4 +91,133 @@ export class SiteFullDto extends SiteLightDto {

@ApiProperty()
totalSiteReports: number;

@ApiProperty()
totalHectaresRestoredSum: number;

@ApiProperty()
seedsPlantedCount: number;

@ApiProperty()
overdueSiteReportsTotal: number;

@ApiProperty()
selfReportedWorkdayCount: number;

@ApiProperty()
treesPlantedCount: number;

@ApiProperty()
regeneratedTreesCount: number;

@ApiProperty()
combinedWorkdayCount: number;

@ApiProperty()
workdayCount: number;

@ApiProperty({ nullable: true })
ppcExternalId: number | null;

@ApiProperty({ nullable: true })
sitingStrategy: string;

@ApiProperty({ nullable: true })
descriptionSitingStrategy: string | null;

@ApiProperty({ nullable: true })
hectaresToRestoreGoal: number;

@ApiProperty({ nullable: true })
description: string | null;

@ApiProperty({ nullable: true })
controlSite: boolean | null;

@ApiProperty({ nullable: true })
history: string | null;

@ApiProperty({ nullable: true })
startDate: Date | null;

@ApiProperty({ nullable: true })
endDate: Date | null;

@ApiProperty({ nullable: true })
landTenures: string[] | null;

@ApiProperty({ nullable: true })
survivalRatePlanted: number | null;

@ApiProperty({ nullable: true })
directSeedingSurvivalRate: number | null;

@ApiProperty({ nullable: true })
aNatRegenerationTreesPerHectare: number | null;

@ApiProperty({ nullable: true })
aNatRegeneration: number | null;

@ApiProperty({ nullable: true })
landscapeCommunityContribution: string | null;

@ApiProperty({ nullable: true })
technicalNarrative: string | null;

@ApiProperty({ nullable: true })
plantingPattern: string | null;

@ApiProperty({ nullable: true })
soilCondition: string | null;

@ApiProperty({ nullable: true })
aimYearFiveCrownCover: number | null;

@ApiProperty({ nullable: true })
aimNumberOfMatureTrees: number | null;

@ApiProperty({ nullable: true })
landUseTypes: string[] | null;

@ApiProperty({ nullable: true })
restorationStrategy: string[] | null;

@ApiProperty({ nullable: true })
feedback: string | null;

@ApiProperty({ nullable: true })
feedbackFields: string[] | null;

@ApiProperty({ nullable: true })
detailedInterventionTypes: string[] | null;

@ApiProperty({ type: () => MediaDto, isArray: true })
media: MediaDto[];

@ApiProperty({ type: () => MediaDto, isArray: true })
socioeconomicBenefits: MediaDto[];

@ApiProperty({ type: () => MediaDto, isArray: true })
file: MediaDto[];

@ApiProperty({ type: () => MediaDto, isArray: true })
otherAdditionalDocuments: MediaDto[];

@ApiProperty({ type: () => MediaDto, isArray: true })
photos: MediaDto[];

@ApiProperty({ type: () => MediaDto, isArray: true })
treeSpecies: MediaDto[];

@ApiProperty({ type: () => MediaDto, isArray: true })
documentFiles: MediaDto[];

@ApiProperty({ type: () => MediaDto, isArray: false })
stratificationForHeterogeneity: MediaDto;

@ApiProperty({
nullable: true,
description: "The associated project uuid"
})
projectUuid: string;
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@ export class EntityAssociationsController {
async associationIndex(@Param() { entity, uuid, association }: EntityAssociationIndexParamsDto) {
const processor = this.entitiesService.createAssociationProcessor(entity, uuid, association);
const baseEntity = await processor.getBaseEntity();

await this.policyService.authorize("read", baseEntity);

const document = buildJsonApi(processor.DTO, { forceDataArray: true });
Expand Down
173 changes: 164 additions & 9 deletions apps/entity-service/src/entities/processors/site.processor.spec.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,31 @@
import { Project } from "@terramatch-microservices/database/entities";
import { Site } from "@terramatch-microservices/database/entities";
import { Test } from "@nestjs/testing";
import { MediaService } from "@terramatch-microservices/common/media/media.service";
import { createMock } from "@golevelup/ts-jest";
import { EntitiesService } from "../entities.service";
import { SiteProcessor } from "./site.processor";
import { reverse, sortBy } from "lodash";
import { EntityQueryDto } from "../dto/entity-query.dto";
import {
ProjectFactory,
ProjectUserFactory,
SiteFactory,
UserFactory
} from "@terramatch-microservices/database/factories";
import { buildJsonApi } from "@terramatch-microservices/common/util";
import { SiteFullDto, SiteLightDto } from "../dto/site.dto";
import { BadRequestException } from "@nestjs/common/exceptions/bad-request.exception";

describe("SiteProcessor", () => {
let processor: SiteProcessor;
let userId: number;

beforeAll(async () => {
userId = (await UserFactory.create()).id;
});

beforeEach(async () => {
await Project.truncate();
await Site.truncate();

const module = await Test.createTestingModule({
providers: [{ provide: MediaService, useValue: createMock<MediaService>() }, EntitiesService]
Expand All @@ -19,21 +35,160 @@ describe("SiteProcessor", () => {
});

describe("findMany", () => {
it("throws", async () => {
await expect(processor.findMany()).rejects.toThrow();
async function expectSites(
expected: Site[],
query: Omit<EntityQueryDto, "field" | "direction" | "size" | "number">,
{
permissions = ["sites-read"],
sortField = "id",
sortUp = true,
total = expected.length
}: { permissions?: string[]; sortField?: string; sortUp?: boolean; total?: number } = {}
) {
const { models, paginationTotal } = await processor.findMany(query as EntityQueryDto, userId, permissions);
expect(models.length).toBe(expected.length);
expect(paginationTotal).toBe(total);

const sorted = sortBy(expected, sortField);
if (!sortUp) reverse(sorted);
expect(models.map(({ id }) => id)).toEqual(sorted.map(({ id }) => id));
}
it("returns sites", async () => {
const project = await ProjectFactory.create();
await ProjectUserFactory.create({ userId, projectId: project.id });
const managedSites = await SiteFactory.createMany(3, { projectId: project.id });
await SiteFactory.createMany(5);
await expectSites(managedSites, {}, { permissions: ["manage-own"] });
});

it("returns managed sites", async () => {
const project = await ProjectFactory.create();
await ProjectUserFactory.create({ userId, projectId: project.id, isMonitoring: false, isManaging: true });
await ProjectFactory.create();
const sites = await SiteFactory.createMany(3, { projectId: project.id });
await SiteFactory.createMany(5);
await expectSites(sites, {}, { permissions: ["projects-manage"] });
});

it("returns framework sites", async () => {
const sites = await SiteFactory.createMany(3, { frameworkKey: "hbf" });
await SiteFactory.createMany(3, { frameworkKey: "ppc" });
for (const p of await SiteFactory.createMany(3, { frameworkKey: "terrafund" })) {
sites.push(p);
}

await expectSites(sites, {}, { permissions: ["framework-hbf", "framework-terrafund"] });
});

it("searches", async () => {
const s1 = await SiteFactory.create({ name: "Foo Bar" });
const s2 = await SiteFactory.create({ name: "Baz Foo" });
await SiteFactory.createMany(3);

await expectSites([s1, s2], { search: "foo" });
});

it("filters", async () => {
const project = await ProjectFactory.create();
await ProjectUserFactory.create({ userId, projectId: project.id });

const first = await SiteFactory.create({
name: "first site",
status: "approved",
updateRequestStatus: "awaiting-approval",
projectId: project.id
});
const second = await SiteFactory.create({
name: "second site",
status: "started",
updateRequestStatus: "awaiting-approval",
projectId: project.id
});
const third = await SiteFactory.create({
name: "third site",
status: "approved",
updateRequestStatus: "awaiting-approval",
projectId: project.id
});

await expectSites([first, second, third], { updateRequestStatus: "awaiting-approval" });
await expectSites([first, second, third], { updateRequestStatus: "awaiting-approval" });
});

it("sorts by name", async () => {
const siteA = await SiteFactory.create({ name: "A Site" });
const siteB = await SiteFactory.create({ name: "B Site" });
const siteC = await SiteFactory.create({ name: "C Site" });
await expectSites([siteA, siteB, siteC], { sort: { field: "name" } }, { sortField: "name" });
await expectSites([siteA, siteB, siteC], { sort: { field: "name", direction: "ASC" } }, { sortField: "name" });
await expectSites(
[siteC, siteB, siteA],
{ sort: { field: "name", direction: "DESC" } },
{ sortField: "name", sortUp: false }
);
});

it("sorts by project name", async () => {
const projectA = await ProjectFactory.create({ name: "A Project" });
const projectB = await ProjectFactory.create({ name: "B Project" });
const projectC = await ProjectFactory.create({ name: "C Project" });
const siteA = await SiteFactory.create({ projectId: projectA.id });
const siteB = await SiteFactory.create({ projectId: projectB.id });
const siteC = await SiteFactory.create({ projectId: projectC.id });
await expectSites([siteA, siteB, siteC], { sort: { field: "projectName" } }, { sortField: "projectName" });
await expectSites(
[siteC, siteB, siteA],
{ sort: { field: "projectName", direction: "DESC" } },
{ sortField: "projectName" }
);
});

it("should not sort by status", async () => {
await SiteFactory.create({ status: "approved" });
await SiteFactory.create({ status: "started" });
await SiteFactory.create({ status: "awaiting-approval" });
await expect(processor.findMany({ sort: { field: "status" } } as EntityQueryDto)).rejects.toThrow(
BadRequestException
);
});
});

describe("findOne", () => {
it("throws", async () => {
await expect(processor.findOne()).rejects.toThrow();
it("returns the requested site", async () => {
const site = await SiteFactory.create();
const result = await processor.findOne(site.uuid);
expect(result.id).toBe(site.id);
});
});

describe("DTOs", () => {
it("throws", async () => {
await expect(processor.addLightDto()).rejects.toThrow();
await expect(processor.addFullDto()).rejects.toThrow();
it("SiteLightDto is a light resource", async () => {
const { uuid } = await SiteFactory.create();
const site = await processor.findOne(uuid);
const document = buildJsonApi(SiteLightDto, { forceDataArray: true });
await processor.addLightDto(document, site);
const attributes = document.serialize().data[0].attributes as SiteLightDto;
expect(attributes).toMatchObject({
uuid,
lightResource: true
});
});
it("includes calculated fields in SiteFullDto", async () => {
const project = await ProjectFactory.create();

const { uuid } = await SiteFactory.create({
projectId: project.id
});

const site = await processor.findOne(uuid);
const document = buildJsonApi(SiteFullDto, { forceDataArray: true });
await processor.addFullDto(document, site);
const attributes = document.serialize().data[0].attributes as SiteFullDto;
expect(attributes).toMatchObject({
uuid,
lightResource: false,
projectUuid: project.uuid
});
});
});
});
Loading