Skip to content

Commit

Permalink
Merge pull request #251 from wri/feat/TM-920-greenhouse-point-api
Browse files Browse the repository at this point in the history
[TM-920] Store PointGeometry in the GH API bulk upload geometry endpoint.
  • Loading branch information
roguenet authored Jun 5, 2024
2 parents a749cf2 + 20e78a8 commit 35cb0c0
Show file tree
Hide file tree
Showing 18 changed files with 596 additions and 85 deletions.
10 changes: 9 additions & 1 deletion .github/workflows/pull-request.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,18 @@ jobs:
lintTest:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Checkout repository
uses: actions/checkout@v4
- name: Install deps
run: make composer
- name: Bring up Docker
run: make up
- name: Lint & Test
run: make test
- name: Store logs
uses: actions/upload-artifact@v4
if: always()
with:
name: PHP Logs
path: storage/logs/
if-no-files-found: ignore
42 changes: 38 additions & 4 deletions app/Http/Controllers/V2/Geometry/GeometryController.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,12 @@
namespace App\Http\Controllers\V2\Geometry;

use App\Http\Controllers\Controller;
use App\Http\Requests\V2\Geometry\StoreGeometryRequest;
use App\Models\V2\PolygonGeometry;
use App\Models\V2\Sites\Site;
use App\Services\PolygonService;
use App\Validators\SitePolygonValidator;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\App;
Expand Down Expand Up @@ -36,6 +38,10 @@ class GeometryController extends Controller
'DATA',
];

/**
* @throws AuthorizationException
* @deprecated Use POST /api/v2/geometry (include site id in the properties of each feature)
*/
public function storeSiteGeometry(Request $request, Site $site): JsonResponse
{
$this->authorize('uploadPolygons', $site);
Expand All @@ -44,12 +50,35 @@ public function storeSiteGeometry(Request $request, Site $site): JsonResponse
'geometries' => 'required|array',
]);

$geometries = $request->input('geometries');
data_set($geometries, '*.features.*.properties.site_id', $site->uuid);

return response()->json($this->storeAndValidateGeometries($geometries), 201);
}

/**
* @throws AuthorizationException
* @throws ValidationException
*/
public function storeGeometry(StoreGeometryRequest $request): JsonResponse
{
$request->validateGeometries();
foreach ($request->getSites() as $site) {
$this->authorize('uploadPolygons', $site);
}

return response()->json($this->storeAndValidateGeometries($request->getGeometries()), 201);
}

protected function storeAndValidateGeometries($geometries): array
{
/** @var PolygonService $service */
$service = App::make(PolygonService::class);
$polygonUuids = [];
foreach ($request->input('geometries') as $geometry) {
// We expect single polys on this endpoint, so just pull the first uuid returned
$polygonUuids[] = $service->createGeojsonModels($geometry, ['site_id' => $site->uuid])[0];
foreach ($geometries as $geometry) {
// In this controller we require either single polys or a collection of Points, which get turned into a
// single poly, so just pull the first UUID returned
$polygonUuids[] = $service->createGeojsonModels($geometry)[0];
}

// Do the validation in a separate step so that all of the existing polygons are taken into account
Expand All @@ -62,7 +91,7 @@ public function storeSiteGeometry(Request $request, Site $site): JsonResponse
}
}

return response()->json(['polygon_uuids' => $polygonUuids, 'errors' => $polygonErrors], 201);
return ['polygon_uuids' => $polygonUuids, 'errors' => $polygonErrors];
}

public function validateGeometries(Request $request): JsonResponse
Expand Down Expand Up @@ -151,6 +180,11 @@ public function updateGeometry(Request $request, PolygonGeometry $polygon): Json

protected function runStoredGeometryValidations(string $polygonUuid): array
{
// TODO: remove when the point transformation ticket is complete
if ($polygonUuid == PolygonService::TEMP_FAKE_POLYGON_UUID) {
return [];
}

/** @var PolygonService $service */
$service = App::make(PolygonService::class);
$data = ['geometry' => $polygonUuid];
Expand Down
97 changes: 97 additions & 0 deletions app/Http/Requests/V2/Geometry/StoreGeometryRequest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
<?php

namespace App\Http\Requests\V2\Geometry;

use App\Models\V2\Sites\Site;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\ValidationException;

class StoreGeometryRequest extends FormRequest
{
protected array $geometries;

protected array $siteIds;

protected array $sites;

public function authorize(): bool
{
return true;
}

public function rules(): array
{
return [
'geometries' => 'required|array',
'geometries.*.features' => 'required|array|min:1',
'geometries.*.features.*.geometry.type' => 'required|string|in:Point,Polygon',
];
}

public function getGeometries(): array
{
if (! empty($this->geometries)) {
return $this->geometries;
}

return $this->geometries = $this->input('geometries');
}

public function getSiteIds(): array
{
if (! empty($this->siteIds)) {
return $this->siteIds;
}

return $this->siteIds = collect(
data_get($this->getGeometries(), '*.features.*.properties.site_id')
)->unique()->filter()->toArray();
}

public function getSites(): array
{
if (! empty($this->sites)) {
return $this->sites;
}

return $this->sites = Site::whereIn('uuid', $this->getSiteIds())->get()->all();
}

/**
* @throws ValidationException
*/
public function validateGeometries(): void
{
// Make sure the data is coherent. Since we accept both Polygons and Points on this request, we have to
// validate each geometry individually, rather than in the rules above
foreach ($this->getGeometries() as $geometry) {
$type = data_get($geometry, 'features.0.geometry.type');
if ($type == 'Polygon') {
// Require that we only have one geometry and that it has a site_id specified
Validator::make($geometry, [
'features' => 'required|array|size:1',
'features.0.properties.site_id' => 'required|string',
])->validate();

// This is guaranteed to be Point given the rules specified in rules()
} else {
// Require that all geometries in the collection are valid points, include estimated area, and that the
// collection has exactly one unique site id.
$siteIds = collect(data_get($geometry, 'features.*.properties.site_id'))
->unique()->filter()->toArray();
Validator::make(['geometry' => $geometry, 'site_ids' => $siteIds], [
'geometry.features.*.geometry.type' => 'required|string|in:Point',
'geometry.features.*.geometry.coordinates' => 'required|array|size:2',
'geometry.features.*.properties.est_area' => 'required|numeric|min:1',
'site_ids' => 'required|array|size:1',
])->validate();
}
}

// Structure this as a validation exception just to make the return shape of this endpoint consistent.
Validator::make(['num_sites' => count($this->getSites()), 'num_site_ids' => count($this->getSiteIds())], [
'num_sites' => 'same:num_site_ids',
])->validate();
}
}
50 changes: 50 additions & 0 deletions app/Models/Traits/HasGeometry.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
<?php

namespace App\Models\Traits;

/**
* @method static isUuid($uuid)
* @property mixed $uuid
*/
trait HasGeometry
{
public static function getGeoJson(string $uuid): ?array
{
$geojson_string = static::isUuid($uuid)
->selectRaw('ST_AsGeoJSON(geom) as geojson_string')
->first()
?->geojson_string;

return $geojson_string == null ? null : json_decode($geojson_string, true);
}

public function getGeoJsonAttribute(): array
{
return self::getGeoJson($this->uuid);
}

public static function getGeometryType(string $uuid): ?string
{
return static::isUuid($uuid)
->selectRaw('ST_GeometryType(geom) as geometry_type_string')
->first()
?->geometry_type_string;
}

public function getGeometryTypeAttribute(): string
{
return self::getGeometryType($this->uuid);
}

public static function getDbGeometry(string $uuid)
{
return static::isUuid($uuid)
->selectRaw('ST_Area(geom) AS area, ST_Y(ST_Centroid(geom)) AS latitude')
->first();
}

public function getDbGeometryAttribute()
{
return self::getDbGeometry($this->uuid);
}
}
40 changes: 40 additions & 0 deletions app/Models/V2/PointGeometry.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
<?php

namespace App\Models\V2;

use App\Models\Traits\HasGeometry;
use App\Models\Traits\HasUuid;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Database\Eloquent\SoftDeletes;

class PointGeometry extends Model
{
use HasUuid;
use SoftDeletes;
use HasGeometry;

protected $table = 'point_geometry';

protected $fillable = [
'geom',
'est_area',
'created_by',
'last_modified_by',
];

public function getRouteKeyName()
{
return 'uuid';
}

public function createdBy(): HasOne
{
return $this->hasOne(User::class, 'id', 'created_by');
}

public function lastModifiedBy(): HasOne
{
return $this->hasOne(User::class, 'id', 'last_modified_by');
}
}
46 changes: 2 additions & 44 deletions app/Models/V2/PolygonGeometry.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

namespace App\Models\V2;

use App\Models\Traits\HasGeometry;
use App\Models\Traits\HasUuid;
use App\Models\V2\Sites\CriteriaSite;
use App\Models\V2\Sites\SitePolygon;
Expand All @@ -12,15 +13,12 @@
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Database\Eloquent\SoftDeletes;

/**
* @method static isUuid($uuid)
* @property mixed $uuid
*/
class PolygonGeometry extends Model
{
use HasUuid;
use SoftDeletes;
use HasFactory;
use HasGeometry;

protected $table = 'polygon_geometry';

Expand Down Expand Up @@ -49,44 +47,4 @@ public function createdBy(): HasOne
{
return $this->hasOne(User::class, 'id', 'created_by');
}

public static function getGeoJson(string $uuid): ?array
{
$geojson_string = PolygonGeometry::isUuid($uuid)
->selectRaw('ST_AsGeoJSON(geom) as geojson_string')
->first()
?->geojson_string;

return $geojson_string == null ? null : json_decode($geojson_string, true);
}

public function getGeoJsonAttribute(): array
{
return self::getGeoJson($this->uuid);
}

public static function getGeometryType(string $uuid): ?string
{
return PolygonGeometry::isUuid($uuid)
->selectRaw('ST_GeometryType(geom) as geometry_type_string')
->first()
?->geometry_type_string;
}

public function getGeometryTypeAttribute(): string
{
return self::getGeometryType($this->uuid);
}

public static function getDbGeometry(string $uuid)
{
return PolygonGeometry::isUuid($uuid)
->selectRaw('ST_Area(geom) AS area, ST_Y(ST_Centroid(geom)) AS latitude')
->first();
}

public function getDbGeometryAttribute()
{
return self::getDbGeometry($this->uuid);
}
}
Loading

0 comments on commit 35cb0c0

Please sign in to comment.