diff --git a/app/Http/Controllers/V2/Sites/StoreBulkSiteGeometryController.php b/app/Http/Controllers/V2/Sites/StoreBulkSiteGeometryController.php index b2cf98f96..6740b226a 100644 --- a/app/Http/Controllers/V2/Sites/StoreBulkSiteGeometryController.php +++ b/app/Http/Controllers/V2/Sites/StoreBulkSiteGeometryController.php @@ -3,12 +3,14 @@ namespace App\Http\Controllers\V2\Sites; use App\Http\Controllers\Controller; +use App\Models\V2\PolygonGeometry; use App\Models\V2\Sites\Site; use App\Services\PolygonService; use App\Validators\SitePolygonValidator; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\App; +use Illuminate\Support\Facades\Log; use Illuminate\Validation\ValidationException; class StoreBulkSiteGeometryController extends Controller @@ -16,6 +18,7 @@ class StoreBulkSiteGeometryController extends Controller public const VALIDATIONS = [ PolygonService::OVERLAPPING_CRITERIA_ID => 'NOT_OVERLAPPING', PolygonService::SELF_CRITERIA_ID => 'SELF_INTERSECTION_UUID', + PolygonService::COORDINATE_SYSTEM_CRITERIA_ID => 'FEATURE_BOUNDS_UUID', PolygonService::SIZE_CRITERIA_ID => 'POLYGON_SIZE_UUID', PolygonService::WITHIN_COUNTRY_CRITERIA_ID => 'WITHIN_COUNTRY', PolygonService::SPIKE_CRITERIA_ID => 'SPIKES_UUID', @@ -41,8 +44,9 @@ public function __invoke(Request $request, Site $site): JsonResponse // Do the validation in a separate step so that all of the existing polygons are taken into account // for things like overlapping and estimated area. + $polygonErrors = []; foreach ($polygonUuids as $polygonUuid) { - $data = ['polygon_uuid' => $polygonUuid]; + $data = ['geometry' => $polygonUuid]; foreach (self::VALIDATIONS as $criteriaId => $validation) { $valid = true; @@ -50,13 +54,35 @@ public function __invoke(Request $request, Site $site): JsonResponse SitePolygonValidator::validate($validation, $data); } catch (ValidationException $exception) { $valid = false; + Log::info('ValidationException: ' . $validation . ', ' . $exception->getMessage()); + $polygonErrors[$polygonUuid][] = json_decode($exception->errors()['geometry'][0]); } $service->createCriteriaSite($polygonUuid, $criteriaId, $valid); - // TODO: accumulate errors and add to response + } + + // For these two, the createGeojsonModels already handled creating the site criteria, so we just need to + // report on them if not valid + $polygon = PolygonGeometry::isUuid($polygonUuid)->select('uuid')->first(); + $schemaCriteria = $polygon->criteriaSite()->forCriteria(PolygonService::SCHEMA_CRITERIA_ID)->first(); + if ($schemaCriteria != null && ! $schemaCriteria->valid) { + $polygonErrors[$polygonUuid][] = [ + 'key' => 'TABLE_SCHEMA', + 'message' => 'The properties for the geometry are missing some required values.', + ]; + } else { + // only report data validation if the schema was valid. When the schema is invalid, the data is + // always invalid as well. + $dataCriteria = $polygon->criteriaSite()->forCriteria(PolygonService::DATA_CRITERIA_ID)->first(); + if ($dataCriteria != null && ! $dataCriteria->valid) { + $polygonErrors[$polygonUuid][] = [ + 'key' => 'DATA_COMPLETED', + 'message' => 'The properties for the geometry have some invalid values.', + ]; + } } } - return response()->json(['polygon_uuids' => $polygonUuids]); + return response()->json(['polygon_uuids' => $polygonUuids, 'errors' => $polygonErrors], 201); } } diff --git a/app/Http/Controllers/V2/Terrafund/TerrafundCreateGeometryController.php b/app/Http/Controllers/V2/Terrafund/TerrafundCreateGeometryController.php index 14068ba91..92484e901 100644 --- a/app/Http/Controllers/V2/Terrafund/TerrafundCreateGeometryController.php +++ b/app/Http/Controllers/V2/Terrafund/TerrafundCreateGeometryController.php @@ -9,9 +9,9 @@ use App\Models\V2\WorldCountryGeneralized; use App\Services\PolygonService; use App\Validators\Extensions\Polygons\EstimatedArea; +use App\Validators\Extensions\Polygons\GeometryType; use App\Validators\Extensions\Polygons\NotOverlapping; use App\Validators\Extensions\Polygons\PolygonSize; -use App\Validators\Extensions\Polygons\GeometryType; use App\Validators\Extensions\Polygons\SelfIntersection; use App\Validators\Extensions\Polygons\Spikes; use App\Validators\Extensions\Polygons\WithinCountry; diff --git a/app/Models/V2/PolygonGeometry.php b/app/Models/V2/PolygonGeometry.php index cf6e337d0..7c2e03176 100644 --- a/app/Models/V2/PolygonGeometry.php +++ b/app/Models/V2/PolygonGeometry.php @@ -4,8 +4,11 @@ use App\Models\Traits\HasUuid; use App\Models\V2\Sites\CriteriaSite; +use App\Models\V2\Sites\SitePolygon; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\SoftDeletes; /** @@ -24,11 +27,16 @@ class PolygonGeometry extends Model 'polygon_id', 'geom', ]; - public function criteriaSite() + public function criteriaSite(): HasMany { return $this->hasMany(CriteriaSite::class, 'polygon_id', 'uuid'); } + public function sitePolygon(): BelongsTo + { + return $this->belongsTo(SitePolygon::class, 'uuid', 'poly_id'); + } + public static function getGeoJson(string $uuid): ?array { $geojson_string = PolygonGeometry::isUuid($uuid) @@ -47,9 +55,9 @@ public function getGeoJsonAttribute(): array public static function getGeometryType(string $uuid): ?string { return PolygonGeometry::isUuid($uuid) - ->selectRaw('ST_GeometryType(geom) as geometry_type') + ->selectRaw('ST_GeometryType(geom) as geometry_type_string') ->first() - ?->geometry_type; + ?->geometry_type_string; } public function getGeometryTypeAttribute(): string diff --git a/app/Models/V2/Sites/CriteriaSite.php b/app/Models/V2/Sites/CriteriaSite.php index 7d530b2d1..93c72f370 100644 --- a/app/Models/V2/Sites/CriteriaSite.php +++ b/app/Models/V2/Sites/CriteriaSite.php @@ -39,4 +39,9 @@ class CriteriaSite extends Model 'deleted_at', 'date_created', ]; + + public function scopeForCriteria($query, $criteriaId) + { + return $query->where('criteria_id', $criteriaId); + } } diff --git a/app/Services/PolygonService.php b/app/Services/PolygonService.php index 94d00b6c9..233320b6c 100644 --- a/app/Services/PolygonService.php +++ b/app/Services/PolygonService.php @@ -6,6 +6,7 @@ use App\Models\V2\Sites\CriteriaSite; use App\Models\V2\Sites\SitePolygon; use App\Validators\SitePolygonValidator; +use Illuminate\Support\Carbon; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Log; @@ -13,6 +14,7 @@ class PolygonService { public const OVERLAPPING_CRITERIA_ID = 3; public const SELF_CRITERIA_ID = 4; + public const COORDINATE_SYSTEM_CRITERIA_ID = 5; public const SIZE_CRITERIA_ID = 6; public const WITHIN_COUNTRY_CRITERIA_ID = 7; public const SPIKE_CRITERIA_ID = 8; @@ -98,6 +100,22 @@ private function insertSinglePolygon(array $geometry): array private function insertSitePolygon(string $polygonUuid, array $properties, float $area) { try { + // Avoid trying to store an invalid date string or int in the DB, as that will throw an exception and prevent + // the site polygon from storing. With an invalid date, this will end up reporting schema invalid and data + // invalid, which isn't necessarily correct for the payload given, but it does reflect the status in the DB + try { + $properties['plantstart'] = empty($properties['plantstart']) ? null : Carbon::parse($properties['plantstart']); + } catch (\Exception $e) { + $properties['plantstart'] = null; + } + + try { + $properties['plantend'] = empty($properties['plantend']) ? null : Carbon::parse($properties['plantend']); + } catch (\Exception $e) { + $properties['plantend'] = null; + } + $properties['num_trees'] = is_int($properties['num_trees'] ?? null) ? $properties['num_trees'] : null; + $validationGeojson = ['features' => [ 'feature' => ['properties' => $properties], ]]; @@ -116,12 +134,12 @@ private function insertSitePolygon(string $polygonUuid, array $properties, float $sitePolygon->site_id = $properties['site_id'] ?? null; $sitePolygon->site_name = $properties['site_name'] ?? null; $sitePolygon->poly_label = $properties['poly_label'] ?? null; - $sitePolygon->plantstart = ! empty($properties['plantstart']) ? $properties['plantstart'] : null; - $sitePolygon->plantend = ! empty($properties['plantend']) ? $properties['plantend'] : null; - $sitePolygon->practice = $properties['practice'] ?? null; + $sitePolygon->plantstart = $properties['plantstart'] ?? null; + $sitePolygon->plantend = $properties['plantend']; + $sitePolygon->practice = $properties['practice']; $sitePolygon->target_sys = $properties['target_sys'] ?? null; $sitePolygon->distr = $properties['distr'] ?? null; - $sitePolygon->num_trees = $properties['num_trees'] ?? null; + $sitePolygon->num_trees = $properties['num_trees']; $sitePolygon->est_area = $area ?? null; $sitePolygon->save(); diff --git a/app/Validators/Extensions/Polygons/EstimatedArea.php b/app/Validators/Extensions/Polygons/EstimatedArea.php index 41f53bb01..bff44f84c 100644 --- a/app/Validators/Extensions/Polygons/EstimatedArea.php +++ b/app/Validators/Extensions/Polygons/EstimatedArea.php @@ -10,10 +10,8 @@ class EstimatedArea extends Extension public static $name = 'estimated_area'; public static $message = [ - 'ESTIMATED_AREA', - 'The {{attribute}} field must represent a polygon that matches the site\'s estimated area', - ['attribute' => ':attribute'], - 'The :attribute field must represent a polygon that matches the site\'s estimated area', + 'key' => 'TOTAL_AREA_EXPECTED', + 'message' => 'The project\'s total geometry must match the project\'s estimated area.', ]; public static function passes($attribute, $value, $parameters, $validator): bool diff --git a/app/Validators/Extensions/Polygons/FeatureBounds.php b/app/Validators/Extensions/Polygons/FeatureBounds.php index ff41ab868..4f8bca13c 100644 --- a/app/Validators/Extensions/Polygons/FeatureBounds.php +++ b/app/Validators/Extensions/Polygons/FeatureBounds.php @@ -2,6 +2,7 @@ namespace App\Validators\Extensions\Polygons; +use App\Models\V2\PolygonGeometry; use App\Validators\Extensions\Extension; class FeatureBounds extends Extension @@ -9,19 +10,33 @@ class FeatureBounds extends Extension public static $name = 'polygon_feature_bounds'; public static $message = [ - 'FEATURE_BOUNDS', - 'The {{attribute}} field must have valid feature polygon bounds.', - ['attribute' => ':attribute'], - 'The :attribute field must have valid feature polygon bounds.', + 'key' => 'COORDINATE_SYSTEM', + 'message' => 'The coordinates must have valid lat/lng values.', ]; public static function passes($attribute, $value, $parameters, $validator): bool { - $type = data_get($value, 'geometry.type'); + if (is_string($value)) { + // assume we have a DB UUID + return self::uuidValid($value); + } + + // assume we have a GeoJSON + return self::geoJsonValid($value); + } + + public static function uuidValid($uuid): bool + { + return self::geoJsonValid(PolygonGeometry::getGeoJson($uuid)); + } + + public static function geoJsonValid($geojson): bool + { + $type = data_get($geojson, 'geometry.type'); if ($type === 'Polygon') { - return self::hasValidPolygonBounds(data_get($value, 'geometry.coordinates.0')); + return self::hasValidPolygonBounds(data_get($geojson, 'geometry.coordinates.0')); } elseif ($type === 'MultiPolygon') { - foreach (data_get($value, 'geometry.coordinates') as $coordinates) { + foreach (data_get($geojson, 'geometry.coordinates') as $coordinates) { if (! self::hasValidPolygonBounds($coordinates)) { return false; } diff --git a/app/Validators/Extensions/Polygons/GeometryType.php b/app/Validators/Extensions/Polygons/GeometryType.php index 621ad2ba5..f3361124b 100644 --- a/app/Validators/Extensions/Polygons/GeometryType.php +++ b/app/Validators/Extensions/Polygons/GeometryType.php @@ -11,10 +11,8 @@ class GeometryType extends Extension public static $name = 'geometry_type'; public static $message = [ - 'GEOMETRY_TYPE', - 'The {{attribute}} field must represent geojson that is polygon geometry', - ['attribute' => ':attribute'], - 'The :attribute field must represent geojson that is polygon geometry', + 'key' => 'GEOMETRY_TYPE', + 'message' => 'The geometry must by of polygon type', ]; public const VALID_TYPE = 'POLYGON'; diff --git a/app/Validators/Extensions/Polygons/HasPolygonSite.php b/app/Validators/Extensions/Polygons/HasPolygonSite.php index 1c6c7132a..0c4bfd032 100644 --- a/app/Validators/Extensions/Polygons/HasPolygonSite.php +++ b/app/Validators/Extensions/Polygons/HasPolygonSite.php @@ -10,10 +10,8 @@ class HasPolygonSite extends Extension public static $name = 'has_polygon_site'; public static $message = [ - 'HAS_POLYGON_SITE', - 'The {{attribute}} field must represent a polygon with an attached site', - ['attribute' => ':attribute'], - 'The :attribute field must represent a polygon with an attached site', + 'key' => 'HAS_POLYGON_SITE', + 'message' => 'The geometry must have an attached site', ]; public static function passes($attribute, $value, $parameters, $validator): bool diff --git a/app/Validators/Extensions/Polygons/NotOverlapping.php b/app/Validators/Extensions/Polygons/NotOverlapping.php index f33992d08..79dc3fb0c 100644 --- a/app/Validators/Extensions/Polygons/NotOverlapping.php +++ b/app/Validators/Extensions/Polygons/NotOverlapping.php @@ -11,10 +11,8 @@ class NotOverlapping extends Extension public static $name = 'not_overlapping'; public static $message = [ - 'NOT_OVERLAPPING', - 'The {{attribute}} field must represent a polygon that does not overlap with other site polygons', - ['attribute' => ':attribute'], - 'The :attribute field must represent a polygon that does not overlap with other site polygons', + 'key' => 'OVERLAPPING_POLYGON', + 'message' => 'The geometry must not overlap with other project geometry', ]; public static function passes($attribute, $value, $parameters, $validator): bool diff --git a/app/Validators/Extensions/Polygons/PolygonSize.php b/app/Validators/Extensions/Polygons/PolygonSize.php index 8f500706f..c51fc9abc 100644 --- a/app/Validators/Extensions/Polygons/PolygonSize.php +++ b/app/Validators/Extensions/Polygons/PolygonSize.php @@ -11,10 +11,8 @@ class PolygonSize extends Extension public static $name = 'polygon_size'; public static $message = [ - 'POLYGON_SIZE', - 'The {{attribute}} field must not represent a polygon that is too large.', - ['attribute' => ':attribute'], - 'The :attribute field must not represent a polygon that is too large.', + 'key' => 'SIZE_LIMIT', + 'message' => 'The geometry must not be larger than ' . self::SIZE_LIMIT . 'square kilometers', ]; public const SIZE_LIMIT = 10000000; diff --git a/app/Validators/Extensions/Polygons/SelfIntersection.php b/app/Validators/Extensions/Polygons/SelfIntersection.php index a7db606e6..e0e840754 100644 --- a/app/Validators/Extensions/Polygons/SelfIntersection.php +++ b/app/Validators/Extensions/Polygons/SelfIntersection.php @@ -10,10 +10,8 @@ class SelfIntersection extends Extension public static $name = 'polygon_self_intersection'; public static $message = [ - 'SELF_INTERSECTION', - 'The {{attribute}} geometry field must not self intersect.', - ['attribute' => ':attribute'], - 'The :attribute geometry field must not self intersect.', + 'key' => 'SELF_INTERSECTION', + 'message' => 'The geometry must not self intersect.', ]; public static function passes($attribute, $value, $parameters, $validator): bool diff --git a/app/Validators/Extensions/Polygons/Spikes.php b/app/Validators/Extensions/Polygons/Spikes.php index 65be09afb..be7176b16 100644 --- a/app/Validators/Extensions/Polygons/Spikes.php +++ b/app/Validators/Extensions/Polygons/Spikes.php @@ -10,10 +10,8 @@ class Spikes extends Extension public static $name = 'polygon_spikes'; public static $message = [ - 'POLYGON_SPIKES', - 'The {{attribute}} field must not represent a polygon with spikes', - ['attribute' => ':attribute'], - 'The :attribute field must not represent a polygon with spikes', + 'key' => 'SPIKE', + 'message' => 'The geometry must not have spikes', ]; public static function passes($attribute, $value, $parameters, $validator): bool diff --git a/app/Validators/Extensions/Polygons/WithinCountry.php b/app/Validators/Extensions/Polygons/WithinCountry.php index 52fa1e282..98571c3ba 100644 --- a/app/Validators/Extensions/Polygons/WithinCountry.php +++ b/app/Validators/Extensions/Polygons/WithinCountry.php @@ -12,10 +12,8 @@ class WithinCountry extends Extension public static $name = 'within_country'; public static $message = [ - 'WITHIN_COUNTRY', - 'The {{attribute}} field must represent a polygon that is within its assigned country', - ['attribute' => ':attribute'], - 'The :attribute field must represent a polygon that is within its assigned country', + 'key' => 'WITHIN_COUNTRY', + 'message' => 'The geometry must be within the project\'s assigned country', ]; public const THRESHOLD_PERCENTAGE = 75; diff --git a/app/Validators/SitePolygonValidator.php b/app/Validators/SitePolygonValidator.php index 830aba4fd..1f1bca768 100644 --- a/app/Validators/SitePolygonValidator.php +++ b/app/Validators/SitePolygonValidator.php @@ -9,6 +9,10 @@ class SitePolygonValidator extends Validator 'features.*' => 'polygon_feature_bounds', ]; + public const FEATURE_BOUNDS_UUID = [ + '*' => 'string|uuid||polygon_feature_bounds', + ]; + public const SPIKES = [ 'features' => 'required|array', 'features.*.geometry' => 'polygon_spikes', diff --git a/openapi-src/V2/definitions/SiteGeometryPost.yml b/openapi-src/V2/definitions/SiteGeometryPost.yml index ff984504d..d304c25d4 100644 --- a/openapi-src/V2/definitions/SiteGeometryPost.yml +++ b/openapi-src/V2/definitions/SiteGeometryPost.yml @@ -1,5 +1,39 @@ title: SiteGeometryPost type: object properties: - uuid: - type: string + polygon_uuids: + type: array + items: + type: string + description: + The UUIDs generated by the system for the uploaded polygons. They are in the same order as the polygons in the + request payload. + errors: + type: object + description: + Mapping of geometry UUID to the errors associated with the geometry. The geometry was saved in the DB and must + be updated instead of created once the issues are resolved. + additionalProperties: + type: array + items: + type: object + properties: + key: + type: string + enum: + - OVERLAPPING_POLYGON + - SELF_INTERSECTION + - COORDINATE_SYSTEM + - SIZE_LIMIT + - WITHIN_COUNTRY + - SPIKE + - GEOMETRY_TYPE + - TOTAL_AREA_EXPECTED + - TABLE_SCHEMA + - DATA_COMPLETED + message: + type: string + description: Human readable string in english to describe the error. + + + diff --git a/openapi-src/V2/paths/Sites/post-v2-sites-uuid-geometry.yml b/openapi-src/V2/paths/Sites/post-v2-sites-uuid-geometry.yml index 0834e23fa..d355001a6 100644 --- a/openapi-src/V2/paths/Sites/post-v2-sites-uuid-geometry.yml +++ b/openapi-src/V2/paths/Sites/post-v2-sites-uuid-geometry.yml @@ -17,7 +17,7 @@ parameters: items: $ref: '../../definitions/_index.yml#/GeoJSON' responses: - '200': + '201': description: Created schema: $ref: '../../definitions/_index.yml#/SiteGeometryPost' diff --git a/resources/docs/swagger-v2.yml b/resources/docs/swagger-v2.yml index 523a049ad..b8d72727c 100644 --- a/resources/docs/swagger-v2.yml +++ b/resources/docs/swagger-v2.yml @@ -44054,8 +44054,35 @@ definitions: title: SiteGeometryPost type: object properties: - uuid: - type: string + polygon_uuids: + type: array + items: + type: string + description: The UUIDs generated by the system for the uploaded polygons. They are in the same order as the polygons in the request payload. + errors: + type: object + description: Mapping of geometry UUID to the errors associated with the geometry. The geometry was saved in the DB and must be updated instead of created once the issues are resolved. + additionalProperties: + type: array + items: + type: object + properties: + key: + type: string + enum: + - OVERLAPPING_POLYGON + - SELF_INTERSECTION + - COORDINATE_SYSTEM + - SIZE_LIMIT + - WITHIN_COUNTRY + - SPIKE + - GEOMETRY_TYPE + - TOTAL_AREA_EXPECTED + - TABLE_SCHEMA + - DATA_COMPLETED + message: + type: string + description: Human readable string in english to describe the error. paths: '/v2/tree-species/{entity}/{UUID}': get: @@ -93177,14 +93204,41 @@ paths: items: type: number responses: - '200': + '201': description: Created schema: title: SiteGeometryPost type: object properties: - uuid: - type: string + polygon_uuids: + type: array + items: + type: string + description: The UUIDs generated by the system for the uploaded polygons. They are in the same order as the polygons in the request payload. + errors: + type: object + description: Mapping of geometry UUID to the errors associated with the geometry. The geometry was saved in the DB and must be updated instead of created once the issues are resolved. + additionalProperties: + type: array + items: + type: object + properties: + key: + type: string + enum: + - OVERLAPPING_POLYGON + - SELF_INTERSECTION + - COORDINATE_SYSTEM + - SIZE_LIMIT + - WITHIN_COUNTRY + - SPIKE + - GEOMETRY_TYPE + - TOTAL_AREA_EXPECTED + - TABLE_SCHEMA + - DATA_COMPLETED + message: + type: string + description: Human readable string in english to describe the error. '/v2/site-monitorings/{UUID}': get: summary: View a specific site monitoring