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-353] [MARXAN-482] [MARXAN-483] geo features: POST and PUT interface #288

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
58 commits
Select commit Hold shift + click to select a range
5288102
WIP ADR for geofeature and related operations
hotzevzl Apr 15, 2021
194659c
more WIP for geofeatures functional design
hotzevzl Apr 16, 2021
6319ba4
WIP clarify/clean up documentation
hotzevzl Apr 16, 2021
e6d6f7b
add draft job status
hotzevzl Apr 16, 2021
9cb4c29
add basic types for feature set specifications
hotzevzl Apr 16, 2021
551f1c6
WIP DTO for feature set spec for scenarios
hotzevzl Apr 16, 2021
47d5263
rename DTO, delete unused DTO
hotzevzl Apr 16, 2021
affadfa
WIP add GeoFeatureSet entity
hotzevzl Apr 16, 2021
2ad4f9a
WIP add stub support for creation of feature sets for scenarios
hotzevzl Apr 16, 2021
1885c13
lint
hotzevzl Apr 19, 2021
9ec14f0
swap misnamed migration files
hotzevzl May 3, 2021
1519b06
update entity/DTO classes for TS strict settings
hotzevzl May 3, 2021
e1a11e8
update endpoint URL and related documentation; rebase on current develop
hotzevzl May 3, 2021
a78a4ab
import module
hotzevzl Jun 7, 2021
42ac5a4
rename adr to match next available id
hotzevzl Jun 7, 2021
09849da
(wip) add endpoint
hotzevzl Jun 21, 2021
9f5ac0c
(wip) add migration for featuresets
hotzevzl Jun 21, 2021
1201880
(wip) add create DTO
hotzevzl Jun 21, 2021
60433ea
(wip) geofeatures geoprocessing recipes validation
hotzevzl Jun 22, 2021
f602888
reflow source
hotzevzl Jun 22, 2021
8fe6403
add stub PUT endpoint
hotzevzl Jun 22, 2021
4129cf8
update documentation
hotzevzl Jun 22, 2021
4d6b960
(wip) remove narrowing of select to features within project bbox
hotzevzl Jun 25, 2021
178b88c
pull items from bbox in the correct order
hotzevzl Jun 25, 2021
06ab009
provide admin area codes when creating test project
hotzevzl Jun 25, 2021
f1a9bec
fix test description
hotzevzl Jun 25, 2021
446abfa
remove redundant function call in chain
hotzevzl Jun 25, 2021
7717993
add processing details to design document
hotzevzl Jun 25, 2021
ae5c010
wip: prepare to filter features by bbox
hotzevzl Jun 30, 2021
f324f9c
add GeoFeatureSet service and serializer
hotzevzl Jul 5, 2021
10c42bc
sync update DTO to create DTO
hotzevzl Jul 5, 2021
33eccb1
prepare to move from entity to non-entity class
hotzevzl Jul 5, 2021
791e065
update module config
hotzevzl Jul 5, 2021
ac50889
(wip) persist feature set recipe to Scenario entity
hotzevzl Jul 5, 2021
4a42dae
(wip) get geo feature set for scenario
hotzevzl Jul 5, 2021
793aed6
update post/put endpoints; add get endpoint
hotzevzl Jul 5, 2021
a0017b8
temporary workaround: use hardcoded values
hotzevzl Jul 5, 2021
7904059
add missing type hint for nested validation
hotzevzl Jul 5, 2021
7d0890e
update properties
hotzevzl Jul 6, 2021
e7c5171
make splits optional in stratification v1 ops
hotzevzl Jul 6, 2021
fd79487
add function docs
hotzevzl Jul 6, 2021
8573394
extend geofeatures recipe response with geofeatures metadata
hotzevzl Jul 6, 2021
15710e2
extend GET responses for feature set specifications
hotzevzl Jul 6, 2021
497debe
return geofeature set specification as object rather than a one-eleme…
hotzevzl Jul 6, 2021
d1d70c7
keep discriminator property
hotzevzl Jul 6, 2021
3657a62
(wip) persist geofeatures used as plain ones
hotzevzl Jul 6, 2021
4f2d950
(wip) add sync setup for geofeature calculation from specification
hotzevzl Jul 8, 2021
f3176ac
return empty, draft specification if none has been defined yet
hotzevzl Jul 8, 2021
e335ff8
(refactor): break down lookup and linkage of properties into simpler …
hotzevzl Jul 8, 2021
666da21
look up properties of features directly
hotzevzl Jul 8, 2021
5a41e3d
(wip): remove call to wip code
hotzevzl Jul 8, 2021
feb4f9f
add filtering of geofeatures by project bbox
hotzevzl Jul 8, 2021
f6d46a6
properly filter geofeatures by project bbox
hotzevzl Jul 8, 2021
ece5a92
clean up names
hotzevzl Jul 8, 2021
a22ed5a
apply lint fixes
hotzevzl Jul 8, 2021
1fcd6de
clean up typing
hotzevzl Jul 8, 2021
0c272bc
fetch metadata for base features used in stratification/v1 ops
hotzevzl Jul 9, 2021
7aeeb75
split concerns off of single service
hotzevzl Jul 9, 2021
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
Original file line number Diff line number Diff line change
@@ -1,29 +1,20 @@
import { MigrationInterface, QueryRunner } from 'typeorm';

export class AllowToLinkCustomGeoFeaturesToProjects1618248224000
export class AddDraftStatusToJobStatusEnum1618241224000
implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE features
ADD COLUMN project_id uuid references projects(id);
ALTER TABLE features
ALTER COLUMN created_by DROP NOT NULL,
ALTER COLUMN created_at DROP NOT NULL,
ALTER COLUMN last_modified_at DROP NOT NULL;
ALTER TYPE job_status ADD VALUE 'draft';
`);
}

public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE features
DROP COLUMN project_id;
ALTER TABLE features
-- not adding back NOT NULL to the created_by column as we wouldn't know
-- which user(s) to set this to, at least until we implement full event
-- logging for create/update/delete, and even then it may be overkill to
-- use the event log to populate this field in the down side of a migration
ALTER COLUMN created_at SET NOT NULL,
ALTER COLUMN last_modified_at SET NOT NULL;
`);
public async down(_queryRunner: QueryRunner): Promise<void> {
/**
* Not dropping here the enum value that we add on the up side of the
* migration. In practice, this will involve just too many steps (altering
* references in all the columns that use this type before to switch over
* to a new type without the 'draft' status) for it to be useful at this
* stage of development.
*/
}
}
Original file line number Diff line number Diff line change
@@ -1,20 +1,29 @@
import { MigrationInterface, QueryRunner } from 'typeorm';

export class AddDraftStatusToJobStatusEnum1618241224000
export class AllowToLinkCustomGeoFeaturesToProjects1618248224000
implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TYPE job_status ADD VALUE 'draft';
ALTER TABLE features
ADD COLUMN project_id uuid references projects(id);
ALTER TABLE features
ALTER COLUMN created_by DROP NOT NULL,
ALTER COLUMN created_at DROP NOT NULL,
ALTER COLUMN last_modified_at DROP NOT NULL;
`);
}

public async down(_queryRunner: QueryRunner): Promise<void> {
/**
* Not dropping here the enum value that we add on the up side of the
* migration. In practice, this will involve just too many steps (altering
* references in all the columns that use this type before to switch over
* to a new type without the 'draft' status) for it to be useful at this
* stage of development.
*/
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE features
DROP COLUMN project_id;
ALTER TABLE features
-- not adding back NOT NULL to the created_by column as we wouldn't know
-- which user(s) to set this to, at least until we implement full event
-- logging for create/update/delete, and even then it may be overkill to
-- use the event log to populate this field in the down side of a migration
ALTER COLUMN created_at SET NOT NULL,
ALTER COLUMN last_modified_at SET NOT NULL;
`);
}
}
17 changes: 17 additions & 0 deletions api/apps/api/src/migrations/api/1620040322000-GeoFeatureSets.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { MigrationInterface, QueryRunner } from 'typeorm';

export class GeoFeatureSets1620040322000 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE scenarios
ADD COLUMN feature_set jsonb;
`);
}

public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE scenarios
DROP COLUMN feature_set;
`);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { GeoFeatureSetSpecification } from './geo-feature-set-specification.dto';

export class CreateGeoFeatureSetDTO extends GeoFeatureSetSpecification {}

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { SimpleJobStatus } from '@marxan-api/modules/scenarios/scenario.api.entity';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { IsEnum, IsUUID, ValidateNested } from 'class-validator';
import { GeoFeature } from '../geo-feature.api.entity';
import {
GeoprocessingOp,
GeoprocessingOpSplitV1,
GeoprocessingOpStratificationV1,
} from '../types/geo-feature.geoprocessing-operations.type';
import { MarxanSettingsForGeoFeature } from '../types/geo-feature.marxan-settings.type';

export abstract class SpecForGeofeature {
@IsUUID()
@ApiProperty()
featureId!: string;

@IsEnum(['plain', 'withGeoprocessing'])
@ApiProperty()
kind!: 'plain' | 'withGeoprocessing';
}

export class SpecForPlainGeoFeature extends SpecForGeofeature {
@ValidateNested()
@Type(() => MarxanSettingsForGeoFeature)
@ApiProperty()
marxanSettings!: MarxanSettingsForGeoFeature;

geoprocessingOperations?: never;
}

export class SpecForPlainGeoFeatureWithFeatureMetadata extends SpecForPlainGeoFeature {
@ApiProperty()
metadata!: GeoFeature;
}

export class SpecForGeoFeatureWithGeoprocessing extends SpecForGeofeature {
@IsUUID()
@ApiProperty()
featureId!: string;

@ValidateNested({ each: true })
@Type(() => GeoprocessingOp, {
keepDiscriminatorProperty: true,
discriminator: {
property: 'kind',
subTypes: [
{ value: GeoprocessingOpSplitV1, name: 'split/v1' },
{ value: GeoprocessingOpStratificationV1, name: 'stratification/v1' },
],
},
})
@ApiPropertyOptional()
geoprocessingOperations?: Array<
GeoprocessingOpSplitV1 | GeoprocessingOpStratificationV1
>;

marxanSettings?: never;
}

export class SpecForGeoFeatureWithGeoprocessingWithFeatureMetadata extends SpecForPlainGeoFeature {
@ApiProperty()
metadata!: GeoFeature;
}

export class GeoFeatureSetSpecification {
@ApiProperty()
// @IsEnum(Object.keys(SimpleJobStatus))
@IsEnum(['draft', 'created'])
status!: SimpleJobStatus;

@ApiProperty()
@ValidateNested({ each: true })
@Type(() => SpecForGeofeature, {
keepDiscriminatorProperty: true,
discriminator: {
property: 'kind',
subTypes: [
{ value: SpecForPlainGeoFeature, name: 'plain' },
{
value: SpecForGeoFeatureWithGeoprocessing,
name: 'withGeoprocessing',
},
],
},
})
features!: Array<SpecForPlainGeoFeature | SpecForGeoFeatureWithGeoprocessing>;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { GeoFeatureSetSpecification } from './geo-feature-set-specification.dto';

export class UpdateGeoFeatureSetDTO extends GeoFeatureSetSpecification {}

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import { apiConnections } from '@marxan-api/ormconfig';
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { flatten } from 'lodash';
import { In, Repository } from 'typeorm';
import { inspect } from 'util';
import { Project } from '../projects/project.api.entity';
import { Scenario } from '../scenarios/scenario.api.entity';
import { GeoFeatureSetSpecification } from './dto/geo-feature-set-specification.dto';
import { GeoFeature } from './geo-feature.api.entity';
import { GeoFeaturePropertySet } from './geo-feature.geo.entity';

@Injectable()
export class GeoFeaturePropertySetService {
constructor(
@InjectRepository(
GeoFeaturePropertySet,
apiConnections.geoprocessingDB.name,
)
private readonly geoFeaturePropertySetsRepository: Repository<GeoFeaturePropertySet>,
@InjectRepository(GeoFeature)
private readonly geoFeaturesRepository: Repository<GeoFeature>,
@InjectRepository(Project)
private readonly projectRepository: Repository<Project>,
) {}

getFeaturePropertySetsForFeatures(
geoFeatureIds: string[],
forProject?: Project | null | undefined,
): Promise<GeoFeaturePropertySet[]> {
const query = this.geoFeaturePropertySetsRepository
.createQueryBuilder('propertySets')
.distinct(true)
.where(`propertySets.featureId IN (:...ids)`, { ids: geoFeatureIds });

if (forProject) {
query.andWhere(
`st_intersects(
st_makeenvelope(:xmin, :ymin, :xmax, :ymax, 4326),
"propertySets".bbox
)`,
{
xmin: forProject.bbox[1],
ymin: forProject.bbox[3],
xmax: forProject.bbox[0],
ymax: forProject.bbox[2],
},
);
}
return query.getMany();
}

extendGeoFeaturesWithPropertiesFromPropertySets(
geoFeatures: GeoFeature[],
propertySet: GeoFeaturePropertySet[],
) {
return geoFeatures.map((i) => {
const propertySetForFeature = propertySet.filter(
(ps) => ps.featureId === i.id,
);
return {
...i,
properties: propertySetForFeature.reduce((acc, cur) => {
return {
...acc,
[cur.key]: [...(acc[cur.key] || []), cur.value[0]],
};
}, {} as Record<string, Array<string | number>>),
};
});
}

/**
* Add feature metadata to features in a geofeatures processing specification.
*/
async extendGeoFeatureProcessingSpecification(
specification: GeoFeatureSetSpecification,
scenario: Scenario,
): Promise<any> {
const project = await this.projectRepository.findOne(scenario.projectId);
const idsOfFeaturesInGeoprocessingOperations = new Set(
flatten(
specification.features
.map((feature) =>
feature.geoprocessingOperations
?.map((op) => {
if (op.kind === 'stratification/v1') {
return op.intersectWith.featureId;
}
})
.filter((id): id is string => !!id),
)
.filter((id): id is string[] => !!id),
),
);
const idsOfTopLevelFeaturesInSpecification = new Set(
specification.features.map((feature) => feature.featureId),
);
const idsOfFeaturesInSpecification = Array.from(
new Set([
...idsOfTopLevelFeaturesInSpecification,
...idsOfFeaturesInGeoprocessingOperations,
]),
);
const featuresInSpecification = await this.geoFeaturesRepository.find({
id: In(idsOfFeaturesInSpecification),
});
Logger.debug(inspect(featuresInSpecification));
const metadataForFeaturesInSpecification = await this.getFeaturePropertySetsForFeatures(
idsOfFeaturesInSpecification,
project,
);
const featuresInSpecificationWithPropertiesMetadata = this.extendGeoFeaturesWithPropertiesFromPropertySets(
featuresInSpecification,
metadataForFeaturesInSpecification,
);
return {
status: specification.status,
features: specification.features.map((feature) => {
return {
...feature,
metadata: featuresInSpecificationWithPropertiesMetadata.find(
(f) => f.id === feature.featureId,
),
};
}),
};
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { ApiProperty } from '@nestjs/swagger';
import { BaseServiceResource } from '@marxan-api/types/resource.interface';
import { GeoFeatureSetSpecification } from './dto/geo-feature-set-specification.dto';

export const geoFeatureResource: BaseServiceResource = {
className: 'GeoFeature',
name: {
singular: 'geo_feature',
plural: 'geo_features',
},
moduleControllerPrefix: 'geo-features',
};

export enum FeatureTags {
bioregional = 'bioregional',
species = 'species',
}

export interface GeoFeatureCategory {
key: string;
distinctValues: string[];
}
export class JSONAPIGeoFeatureSetsData {
@ApiProperty()
type = geoFeatureResource.name.plural;

@ApiProperty()
id!: string;

@ApiProperty()
attributes!: GeoFeatureSetSpecification;
}

export class GeoFeatureSetResult {
@ApiProperty()
data!: JSONAPIGeoFeatureSetsData;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { Injectable } from '@nestjs/common';
import { PaginationMeta } from '@marxan-api/utils/app-base.service';
import { GeoFeatureSetService } from './geo-feature-set.service';
import { GeoFeatureSetSpecification } from './dto/geo-feature-set-specification.dto';

@Injectable()
export class GeoFeatureSetSerializer {
constructor(private readonly geoFeatureSetsService: GeoFeatureSetService) {}

async serialize(
entities:
| Partial<GeoFeatureSetSpecification>
| undefined
| (Partial<GeoFeatureSetSpecification> | undefined)[],
paginationMeta?: PaginationMeta,
): Promise<any> {
return this.geoFeatureSetsService.serialize(entities, paginationMeta);
}
}
Loading