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

MARXAN - 448 Cost surface - scenarioId guard #241

Merged
merged 4 commits into from
Jun 8, 2021
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
6 changes: 3 additions & 3 deletions api/apps/api/src/modules/projects/projects-crud.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { Project } from './project.api.entity';
import { CreateProjectDTO } from './dto/create.project.dto';
import { UpdateProjectDTO } from './dto/update.project.dto';
import { UsersService } from '@marxan-api/modules/users/users.service';
import { ScenariosService } from '@marxan-api/modules/scenarios/scenarios.service';
import { ScenariosCrudService } from '@marxan-api/modules/scenarios/scenarios-crud.service';
import { PlanningUnitsService } from '@marxan-api/modules/planning-units/planning-units.service';
import {
AppBaseService,
Expand Down Expand Up @@ -41,8 +41,8 @@ export class ProjectsCrudService extends AppBaseService<
constructor(
@InjectRepository(Project)
protected readonly repository: Repository<Project>,
@Inject(forwardRef(() => ScenariosService))
protected readonly scenariosService: ScenariosService,
@Inject(forwardRef(() => ScenariosCrudService))
protected readonly scenariosService: ScenariosCrudService,
@Inject(UsersService) protected readonly usersService: UsersService,
@Inject(AdminAreasService)
protected readonly adminAreasService: AdminAreasService,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { Injectable } from '@nestjs/common';
import { PaginationMeta } from '@marxan-api/utils/app-base.service';
import { ScenarioFeaturesService } from '@marxan-api/modules/scenarios-features';
import { GeoFeature } from '@marxan-api/modules/geo-features/geo-feature.api.entity';

@Injectable()
export class ScenarioFeatureSerializer {
constructor(private readonly featuresCrud: ScenarioFeaturesService) {}

async serialize(
entities: Partial<GeoFeature> | (Partial<GeoFeature> | undefined)[],
paginationMeta?: PaginationMeta,
): Promise<any> {
return this.featuresCrud.serialize(entities, paginationMeta);
}
}
16 changes: 16 additions & 0 deletions api/apps/api/src/modules/scenarios/dto/scenario.serializer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { Injectable } from '@nestjs/common';
import { PaginationMeta } from '@marxan-api/utils/app-base.service';
import { Scenario } from '../scenario.api.entity';
import { ScenariosCrudService } from '../scenarios-crud.service';

@Injectable()
export class ScenarioSerializer {
constructor(private readonly scenariosCrudService: ScenariosCrudService) {}

async serialize(
entities: Partial<Scenario> | (Partial<Scenario> | undefined)[],
paginationMeta?: PaginationMeta,
): Promise<any> {
return this.scenariosCrudService.serialize(entities, paginationMeta);
}
}
280 changes: 280 additions & 0 deletions api/apps/api/src/modules/scenarios/scenarios-crud.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,280 @@
import { forwardRef, Inject, Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { AppInfoDTO } from '@marxan-api/dto/info.dto';
import { Repository, SelectQueryBuilder } from 'typeorm';
import { CreateScenarioDTO } from './dto/create.scenario.dto';
import { UpdateScenarioDTO } from './dto/update.scenario.dto';
import { JobStatus, Scenario, ScenarioType } from './scenario.api.entity';

import * as faker from 'faker';
import { UsersService } from '@marxan-api/modules/users/users.service';
import {
AppBaseService,
JSONAPISerializerConfig,
} from '@marxan-api/utils/app-base.service';
import { Project } from '@marxan-api/modules/projects/project.api.entity';
import { ProtectedAreasService } from '@marxan-api/modules/protected-areas/protected-areas.service';
import { ProjectsCrudService } from '@marxan-api/modules/projects/projects-crud.service';
import { concat } from 'lodash';
import { AppConfig } from '@marxan-api/utils/config.utils';
import { WdpaAreaCalculationService } from './wdpa-area-calculation.service';
import { CommandBus } from '@nestjs/cqrs';
import { CalculatePlanningUnitsProtectionLevel } from '../planning-units-protection-level';

const scenarioFilterKeyNames = ['name', 'type', 'projectId', 'status'] as const;
type ScenarioFilterKeys = keyof Pick<
Scenario,
typeof scenarioFilterKeyNames[number]
>;
type ScenarioFilters = Record<ScenarioFilterKeys, string[]>;

@Injectable()
export class ScenariosCrudService extends AppBaseService<
Scenario,
CreateScenarioDTO,
UpdateScenarioDTO,
AppInfoDTO
> {
constructor(
@InjectRepository(Scenario)
protected readonly repository: Repository<Scenario>,
@InjectRepository(Project)
protected readonly projectRepository: Repository<Project>,
@Inject(UsersService) protected readonly usersService: UsersService,
@Inject(ProtectedAreasService)
protected readonly protectedAreasService: ProtectedAreasService,
@Inject(forwardRef(() => ProjectsCrudService))
protected readonly projectsService: ProjectsCrudService,
private readonly wdpaCalculationsDetector: WdpaAreaCalculationService,
private readonly commandBus: CommandBus,
) {
super(repository, 'scenario', 'scenarios', {
logging: { muteAll: AppConfig.get<boolean>('logging.muteAll', false) },
});
}

async actionAfterCreate(
model: Scenario,
createModel: CreateScenarioDTO,
_?: AppInfoDTO,
): Promise<void> {
if (this.wdpaCalculationsDetector.shouldTrigger(model, createModel)) {
await this.commandBus.execute(
new CalculatePlanningUnitsProtectionLevel(model.id),
);
}
}

async actionAfterUpdate(
model: Scenario,
updateModel: UpdateScenarioDTO,
_?: AppInfoDTO,
): Promise<void> {
if (this.wdpaCalculationsDetector.shouldTrigger(model, updateModel)) {
await this.commandBus.execute(
new CalculatePlanningUnitsProtectionLevel(model.id),
);
}
}

get serializerConfig(): JSONAPISerializerConfig<Scenario> {
return {
attributes: [
'name',
'description',
'type',
'protectedAreaFilterByIds',
'wdpaIucnCategories',
'wdpaThreshold',
'numberOfRuns',
'boundaryLengthModifier',
'metadata',
'status',
'projectId',
'project',
'users',
'createdAt',
'createdByUser',
'lastModifiedAt',
],
keyForAttribute: 'camelCase',
project: {
ref: 'id',
attributes: [
'name',
'description',
'countryId',
'adminAreaLevel1Id',
'adminAreaLevel2Id',
'planningUnitGridShape',
'planningUnitAreakm2',
'createdAt',
'lastModifiedAt',
],
},
users: {
ref: 'id',
attributes: ['fname', 'lname', 'email'],
projectRoles: {
ref: 'name',
attributes: ['name'],
},
},
};
}

async importLegacyScenario(_file: Express.Multer.File): Promise<Scenario> {
return new Scenario();
}

async fakeFindOne(_id: string): Promise<Scenario> {
const scenario = {
...new Scenario(),
id: faker.random.uuid(),
name: faker.lorem.words(5),
description: faker.lorem.sentence(),
type: ScenarioType.marxan,
extent: {},
wdpaFilter: {},
wdpaThreshold: faker.random.number(100),
adminRegionId: faker.random.uuid(),
numberOfRuns: faker.random.number(100),
boundaryLengthModifier: faker.random.number(100),
metadata: {},
status: JobStatus.created,
users: await Promise.all(
Array.from({ length: 10 }).map(
async (_userId) =>
await this.usersService.fakeFindOne(faker.random.uuid()),
),
),
};
return scenario;
}

/**
* Apply service-specific filters.
*/
setFilters(
query: SelectQueryBuilder<Scenario>,
filters: ScenarioFilters,
_info?: AppInfoDTO,
): SelectQueryBuilder<Scenario> {
query = this._processBaseFilters<ScenarioFilters>(
query,
filters,
scenarioFilterKeyNames,
);
return query;
}

async setDataCreate(
create: CreateScenarioDTO,
info?: AppInfoDTO,
): Promise<Scenario> {
const model = await super.setDataCreate(create, info);
/**
* We always compute the list of protected areas to associate to a scenario
* from the list of IUCN categories and the list of project-specific protected
* areas supplied in the request. Users should not set the list of actual
* protected areas directly (and in fact we don't even expose this property
* in DTOs).
*/
if (create.wdpaIucnCategories || create.customProtectedAreaIds) {
const wdpaAreaIds = await this.getWDPAAreasWithinProjectByIUCNCategory(
create,
);
model.protectedAreaFilterByIds = [
...new Set(
concat(wdpaAreaIds, create.customProtectedAreaIds).filter(
(i): i is string => !!i,
),
),
];
}
model.createdBy = info?.authenticatedUser?.id!;
return model;
}

async setDataUpdate(
model: Scenario,
update: UpdateScenarioDTO,
info?: AppInfoDTO,
): Promise<Scenario> {
model = await super.setDataUpdate(model, update, info);
/**
* We always compute the list of protected areas to associate to a scenario
* from the list of IUCN categories and the list of project-specific protected
* areas supplied in the request. Users should not set the list of actual
* protected areas directly (and in fact we don't even expose this property
* in DTOs).
*/
if (update.wdpaIucnCategories || update.customProtectedAreaIds) {
const wdpaAreaIds = await this.getWDPAAreasWithinProjectByIUCNCategory(
update,
);
model.protectedAreaFilterByIds = [
...new Set(
concat(wdpaAreaIds, update.customProtectedAreaIds).filter(
(i): i is string => !!i,
),
),
];
}
return model;
}

/**
* Link protected areas to the scenario.
*/
async getWDPAAreasWithinProjectByIUCNCategory(
{
projectId,
wdpaIucnCategories,
}:
| Pick<CreateScenarioDTO, 'projectId' | 'wdpaIucnCategories'>
| Pick<UpdateScenarioDTO, 'projectId' | 'wdpaIucnCategories'>,
_info?: AppInfoDTO,
): Promise<string[] | undefined> {
/**
* If no IUCN categories were supplied, we're done.
*/
if (!wdpaIucnCategories) {
return;
}

/**
* We need to get the parent project's metadata first
*/
const parentProject = await this.projectRepository.findOneOrFail(projectId);

/**
* We can then check if project boundaries are set (either a GADM admin area
* or a custom geometry).
*
* @todo Custom project planning area geometries are not implemented yet; once
* users can upload and select these, we should add selection of protected
* areas in custom geometries here. In practice this should all be handled in
* Project.getPlanningArea(), but we'll need to check that things work as
* expected.
*/
const planningAreaId = await this.projectsService
.getPlanningArea(parentProject)
.then((r) => r?.id);

/**
* If project boundaries are set, we can then retrieve WDPA protected areas
* that intersect the boundaries, via the list of user-supplied IUCN
* categories they want to use as selector for protected areas.
*/
const wdpaAreaIdsWithinPlanningArea = planningAreaId
? await this.protectedAreasService
.findAllWDPAProtectedAreasInPlanningAreaByIUCNCategory(
planningAreaId,
wdpaIucnCategories,
)
.then((r) => r.map((i) => i.id))
: undefined;
return wdpaAreaIdsWithinPlanningArea;
}
}
Loading