From 9937a614c48c3a282cd96ce34b8e0ed75f40bbd0 Mon Sep 17 00:00:00 2001 From: Nathan Curtis Date: Wed, 17 Apr 2024 14:20:00 -0700 Subject: [PATCH 01/30] [TM-803] Make the model interface binding middleware a bit more generically useful. --- .../ModelInterfaceBindingMiddleware.php | 54 +++++++++++++++---- routes/api_v2.php | 43 ++++++--------- 2 files changed, 60 insertions(+), 37 deletions(-) diff --git a/app/Http/Middleware/ModelInterfaceBindingMiddleware.php b/app/Http/Middleware/ModelInterfaceBindingMiddleware.php index fddbf277f..b4c884840 100644 --- a/app/Http/Middleware/ModelInterfaceBindingMiddleware.php +++ b/app/Http/Middleware/ModelInterfaceBindingMiddleware.php @@ -2,14 +2,23 @@ namespace App\Http\Middleware; +use App\Models\V2\Forms\Form; +use App\Models\V2\Forms\FormQuestionOption; +use App\Models\V2\FundingProgramme; use App\Models\V2\Nurseries\Nursery; use App\Models\V2\Nurseries\NurseryReport; +use App\Models\V2\Organisation; +use App\Models\V2\ProjectPitch; use App\Models\V2\Projects\Project; +use App\Models\V2\Projects\ProjectMonitoring; use App\Models\V2\Projects\ProjectReport; use App\Models\V2\Sites\Site; +use App\Models\V2\Sites\SiteMonitoring; use App\Models\V2\Sites\SiteReport; use Closure; use Illuminate\Http\Request; +use Illuminate\Routing\RouteRegistrar; +use Illuminate\Support\Facades\Route; /** * Implicit binding doesn't work for interfaces, so we need to figure out the concrete model class and @@ -17,27 +26,52 @@ */ class ModelInterfaceBindingMiddleware { - public const ENTITY_TYPES_PLURAL = ['projects', 'project-reports', 'sites', 'site-reports', 'nurseries', 'nursery-reports']; - public const ENTITY_TYPES_SINGULAR = ['project', 'project-report', 'site', 'site-report', 'nursery', 'nursery-report']; - private const CONCRETE_MODELS = [ - // EntityModel concrete classes + // EntityModel and MediaModel concrete classes 'projects' => Project::class, 'project' => Project::class, - 'sites' => Site::class, - 'site' => Site::class, - 'nurseries' => Nursery::class, - 'nursery' => Nursery::class, - - // ReportModel (which extends EntityModel) concrete classes 'project-reports' => ProjectReport::class, 'project-report' => ProjectReport::class, + 'sites' => Site::class, + 'site' => Site::class, 'site-reports' => SiteReport::class, 'site-report' => SiteReport::class, + 'nurseries' => Nursery::class, + 'nursery' => Nursery::class, 'nursery-reports' => NurseryReport::class, 'nursery-report' => NurseryReport::class, + + // MediaModel concrete classes + 'organisation' => Organisation::class, + 'project-pitch' => ProjectPitch::class, + 'funding-programme' => FundingProgramme::class, + 'form' => Form::class, + 'form-question-option' => FormQuestionOption::class, + 'project-monitoring' => ProjectMonitoring::class, + 'site-monitoring' => SiteMonitoring::class, ]; + private static array $typeSlugsCache = []; + + public static function with(string $interface, callable $routeGroup): RouteRegistrar + { + $typeSlugs = self::$typeSlugsCache[$interface] ?? []; + if (empty($typeSlugs)) { + foreach (self::CONCRETE_MODELS as $slug => $concrete) { + if (is_a($concrete, $interface, true)) { + $typeSlugs[] = $slug; + } + } + + self::$typeSlugsCache[$interface] = $typeSlugs; + } + + return Route::prefix('/{modelSlug}') + ->whereIn('modelSlug', $typeSlugs) + ->middleware('modelInterface') + ->group($routeGroup); + } + public function handle(Request $request, Closure $next) { $route = $request->route(); diff --git a/routes/api_v2.php b/routes/api_v2.php index 10445c3a5..cdd8c9c27 100644 --- a/routes/api_v2.php +++ b/routes/api_v2.php @@ -189,6 +189,7 @@ use App\Http\Controllers\V2\Workdays\StoreWorkdayController; use App\Http\Controllers\V2\Workdays\UpdateWorkdayController; use App\Http\Middleware\ModelInterfaceBindingMiddleware; +use App\Models\V2\EntityModel; use Illuminate\Support\Facades\Route; /* @@ -292,13 +293,10 @@ Route::get('/{entity}/export/{framework}', ExportAllMonitoredEntitiesController::class); - Route::prefix('{modelSlug}') - ->whereIn('modelSlug', ModelInterfaceBindingMiddleware::ENTITY_TYPES_PLURAL) - ->middleware('modelInterface') - ->group(function () { - Route::put('/{entity}/{status}', AdminStatusEntityController::class); - Route::delete('/{entity}', AdminSoftDeleteEntityController::class); - }); + ModelInterfaceBindingMiddleware::with(EntityModel::class, function () { + Route::put('/{entity}/{status}', AdminStatusEntityController::class); + Route::delete('/{entity}', AdminSoftDeleteEntityController::class); + }); Route::get('nursery-reports', AdminIndexNurseryReportsController::class); Route::get('site-reports', AdminIndexSiteReportsController::class); @@ -405,14 +403,11 @@ Route::get('/', IndexFormController::class); Route::get('/{form}', ViewFormController::class)->middleware('i18n'); - Route::prefix('{modelSlug}') - ->whereIn('modelSlug', ModelInterfaceBindingMiddleware::ENTITY_TYPES_PLURAL) - ->middleware('modelInterface') - ->group(function () { - Route::get('/{entity}', ViewEntityWithFormController::class)->middleware('i18n'); - Route::put('/{entity}', UpdateEntityWithFormController::class); - Route::put('/{entity}/submit', SubmitEntityWithFormController::class); - }); + ModelInterfaceBindingMiddleware::with(EntityModel::class, function () { + Route::get('/{entity}', ViewEntityWithFormController::class)->middleware('i18n'); + Route::put('/{entity}', UpdateEntityWithFormController::class); + Route::put('/{entity}/submit', SubmitEntityWithFormController::class); + }); Route::prefix('projects')->group(function () { Route::post('', CreateProjectWithFormController::class); @@ -545,12 +540,9 @@ Route::put('/{report}/nothing-to-report', NothingToReportReportController::class); }); -Route::prefix('{modelSlug}') - ->whereIn('modelSlug', ModelInterfaceBindingMiddleware::ENTITY_TYPES_PLURAL) - ->middleware('modelInterface') - ->group(function () { - Route::get('/{entity}', ViewEntityController::class); - }); +ModelInterfaceBindingMiddleware::with(EntityModel::class, function () { + Route::get('/{entity}', ViewEntityController::class); +}); Route::prefix('project-reports')->group(function () { Route::get('/{projectReport}/files', ViewProjectReportGalleryController::class); @@ -606,12 +598,9 @@ Route::get('/{updateRequest}', AdminViewUpdateRequestController::class); Route::delete('/{updateRequest}', AdminSoftDeleteUpdateRequestController::class); - Route::prefix('/{modelSlug}') - ->whereIn('modelSlug', ModelInterfaceBindingMiddleware::ENTITY_TYPES_SINGULAR) - ->middleware('modelInterface') - ->group(function () { - Route::get('/{entity}', EntityUpdateRequestsController::class); - }); + ModelInterfaceBindingMiddleware::with(EntityModel::class, function () { + Route::get('/{entity}', EntityUpdateRequestsController::class); + }); }); Route::get('/funding-programme', [FundingProgrammeController::class, 'index'])->middleware('i18n'); From e9a01408d671413b96bd79524797c32f0a0cffe9 Mon Sep 17 00:00:00 2001 From: Nathan Curtis Date: Wed, 17 Apr 2024 14:47:58 -0700 Subject: [PATCH 02/30] [TM-803] Refactor UploadController to use the model binding middleware. --- .../Controllers/V2/Files/UploadController.php | 99 ++----------------- .../ModelInterfaceBindingMiddleware.php | 16 +-- app/Models/V2/Forms/Form.php | 4 +- app/Models/V2/Forms/FormQuestionOption.php | 4 +- app/Models/V2/FundingProgramme.php | 3 +- app/Models/V2/MediaModel.php | 14 +++ app/Models/V2/Nurseries/Nursery.php | 4 +- app/Models/V2/Nurseries/NurseryReport.php | 4 +- app/Models/V2/Organisation.php | 3 +- app/Models/V2/ProjectPitch.php | 3 +- app/Models/V2/Projects/Project.php | 4 +- app/Models/V2/Projects/ProjectMonitoring.php | 4 +- app/Models/V2/Projects/ProjectReport.php | 4 +- app/Models/V2/Sites/Site.php | 4 +- app/Models/V2/Sites/SiteMonitoring.php | 4 +- app/Models/V2/Sites/SiteReport.php | 4 +- routes/api_v2.php | 10 +- 17 files changed, 66 insertions(+), 122 deletions(-) create mode 100644 app/Models/V2/MediaModel.php diff --git a/app/Http/Controllers/V2/Files/UploadController.php b/app/Http/Controllers/V2/Files/UploadController.php index fc070a0c1..376c133fc 100644 --- a/app/Http/Controllers/V2/Files/UploadController.php +++ b/app/Http/Controllers/V2/Files/UploadController.php @@ -5,36 +5,21 @@ use App\Http\Controllers\Controller; use App\Http\Requests\V2\File\UploadRequest; use App\Http\Resources\V2\Files\FileResource; -use App\Models\V2\Forms\Form; -use App\Models\V2\Forms\FormQuestionOption; -use App\Models\V2\FundingProgramme; -use App\Models\V2\Nurseries\Nursery; -use App\Models\V2\Nurseries\NurseryReport; -use App\Models\V2\Organisation; -use App\Models\V2\ProjectPitch; -use App\Models\V2\Projects\Project; -use App\Models\V2\Projects\ProjectMonitoring; -use App\Models\V2\Projects\ProjectReport; -use App\Models\V2\Sites\Site; -use App\Models\V2\Sites\SiteMonitoring; -use App\Models\V2\Sites\SiteReport; -use Illuminate\Database\Eloquent\Model; -use Illuminate\Database\Eloquent\ModelNotFoundException; +use App\Models\V2\MediaModel; use Illuminate\Support\Arr; use Illuminate\Support\Facades\Validator; use mysql_xdevapi\Exception; class UploadController extends Controller { - public function __invoke(UploadRequest $request, $model, $collection, $uuid) + public function __invoke(UploadRequest $request, string $collection, MediaModel $mediaModel) { - $entity = $this->getEntity($model, $uuid); - $this->authorize('uploadFiles', $entity); - $config = $this->getConfiguration($entity, $collection); + $this->authorize('uploadFiles', $mediaModel); + $config = $this->getConfiguration($mediaModel, $collection); $this->validateFile($request, $config); - $qry = $entity->addMediaFromRequest('upload_file'); - $this->prepHandler($qry, $request->all(), $entity, $config, $collection); + $qry = $mediaModel->addMediaFromRequest('upload_file'); + $this->prepHandler($qry, $request->all(), $mediaModel, $config, $collection); $details = $this->executeHandler($qry, $collection); if (Arr::has($request->all(), ['lat', 'lng'])) { @@ -46,73 +31,9 @@ public function __invoke(UploadRequest $request, $model, $collection, $uuid) return new FileResource($details); } - private function getEntity($model, $uuid): Model + private function getConfiguration(MediaModel $mediaModel, $collection): array { - switch ($model) { - case 'organisation': - $entity = Organisation::isUuid($uuid)->first(); - - break; - case 'project-pitch': - $entity = ProjectPitch::isUuid($uuid)->first(); - - break; - case 'funding-programme': - $entity = FundingProgramme::isUuid($uuid)->first(); - - break; - case 'form': - $entity = Form::isUuid($uuid)->first(); - - break; - case 'form-question-option': - $entity = FormQuestionOption::isUuid($uuid)->first(); - - break; - case 'project': - $entity = Project::isUuid($uuid)->first(); - - break; - case 'site': - $entity = Site::isUuid($uuid)->first(); - - break; - case 'nursery': - $entity = Nursery::isUuid($uuid)->first(); - - break; - case 'project-report': - $entity = ProjectReport::isUuid($uuid)->first(); - - break; - case 'site-report': - $entity = SiteReport::isUuid($uuid)->first(); - - break; - case 'nursery-report': - $entity = NurseryReport::isUuid($uuid)->first(); - - break; - case 'project-monitoring': - $entity = ProjectMonitoring::isUuid($uuid)->first(); - - break; - case 'site-monitoring': - $entity = SiteMonitoring::isUuid($uuid)->first(); - - break; - } - - if (empty($entity)) { - throw new ModelNotFoundException(); - } - - return $entity; - } - - private function getConfiguration($entity, $collection): array - { - $config = $entity->fileConfiguration[$collection]; + $config = $mediaModel->fileConfiguration[$collection]; if (empty($config)) { throw new Exception('Collection is unknown to this model.'); @@ -136,14 +57,14 @@ private function validateFile($request, $config): void $validator->validate(); } - private function prepHandler($qry, $data, $entity, $config, $collection): void + private function prepHandler($qry, $data, MediaModel $mediaModel, $config, $collection): void { if (data_get($data, 'title', false)) { $qry->usingName(data_get($data, 'title')); } if (! data_get($config, 'multiple', true)) { - $entity->clearMediaCollection($collection); + $mediaModel->clearMediaCollection($collection); } } diff --git a/app/Http/Middleware/ModelInterfaceBindingMiddleware.php b/app/Http/Middleware/ModelInterfaceBindingMiddleware.php index b4c884840..87118f1f4 100644 --- a/app/Http/Middleware/ModelInterfaceBindingMiddleware.php +++ b/app/Http/Middleware/ModelInterfaceBindingMiddleware.php @@ -53,7 +53,7 @@ class ModelInterfaceBindingMiddleware private static array $typeSlugsCache = []; - public static function with(string $interface, callable $routeGroup): RouteRegistrar + public static function with(string $interface, callable $routeGroup, string $prefix = null, string $modelParameter = null): RouteRegistrar { $typeSlugs = self::$typeSlugsCache[$interface] ?? []; if (empty($typeSlugs)) { @@ -66,13 +66,15 @@ public static function with(string $interface, callable $routeGroup): RouteRegis self::$typeSlugsCache[$interface] = $typeSlugs; } - return Route::prefix('/{modelSlug}') + $middleware = $modelParameter == null ? 'modelInterface' : "modelInterface:$modelParameter"; + + return Route::prefix("$prefix/{modelSlug}") ->whereIn('modelSlug', $typeSlugs) - ->middleware('modelInterface') + ->middleware($middleware) ->group($routeGroup); } - public function handle(Request $request, Closure $next) + public function handle(Request $request, Closure $next, $modelParameter = null) { $route = $request->route(); $parameterKeys = array_keys($route->parameters); @@ -85,8 +87,10 @@ public function handle(Request $request, Closure $next) $concreteClass = self::CONCRETE_MODELS[$modelSlug]; abort_unless($concreteClass, 404, "Concrete class not found for model interface $modelSlug"); - // assume the model key (e.g. "report") is the next param down the list from the interface name. - $modelParameter = $parameterKeys[$modelSlugIndex + 1]; + if ($modelParameter == null) { + // assume the model key (e.g. "report") is the next param down the list from the interface name. + $modelParameter = $parameterKeys[$modelSlugIndex + 1]; + } $modelId = $route->parameter($modelParameter); abort_unless($modelId, 404, "Model ID not found for $concreteClass"); diff --git a/app/Models/V2/Forms/Form.php b/app/Models/V2/Forms/Form.php index 3e75188f1..ff9199cb1 100644 --- a/app/Models/V2/Forms/Form.php +++ b/app/Models/V2/Forms/Form.php @@ -8,6 +8,7 @@ use App\Models\Traits\HasUuid; use App\Models\Traits\HasV2MediaCollections; use App\Models\V2\I18n\I18nItem; +use App\Models\V2\MediaModel; use App\Models\V2\Stages\Stage; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; @@ -15,11 +16,10 @@ use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\SoftDeletes; use Laravel\Scout\Searchable; -use Spatie\MediaLibrary\HasMedia; use Spatie\MediaLibrary\InteractsWithMedia; use Spatie\MediaLibrary\MediaCollections\Models\Media; -class Form extends Model implements HasMedia +class Form extends Model implements MediaModel { use HasFactory; use SoftDeletes; diff --git a/app/Models/V2/Forms/FormQuestionOption.php b/app/Models/V2/Forms/FormQuestionOption.php index c4856d23a..9255b84d6 100644 --- a/app/Models/V2/Forms/FormQuestionOption.php +++ b/app/Models/V2/Forms/FormQuestionOption.php @@ -6,16 +6,16 @@ use App\Models\Traits\HasUuid; use App\Models\Traits\HasV2MediaCollections; use App\Models\V2\I18n\I18nItem; +use App\Models\V2\MediaModel; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Support\Str; -use Spatie\MediaLibrary\HasMedia; use Spatie\MediaLibrary\InteractsWithMedia; use Spatie\MediaLibrary\MediaCollections\Models\Media; -class FormQuestionOption extends Model implements HasMedia +class FormQuestionOption extends Model implements MediaModel { use HasFactory; use SoftDeletes; diff --git a/app/Models/V2/FundingProgramme.php b/app/Models/V2/FundingProgramme.php index e4a7c18a0..2afb3b51d 100644 --- a/app/Models/V2/FundingProgramme.php +++ b/app/Models/V2/FundingProgramme.php @@ -15,11 +15,10 @@ use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasManyThrough; use Illuminate\Database\Eloquent\SoftDeletes; -use Spatie\MediaLibrary\HasMedia; use Spatie\MediaLibrary\InteractsWithMedia; use Spatie\MediaLibrary\MediaCollections\Models\Media; -class FundingProgramme extends Model implements HasMedia +class FundingProgramme extends Model implements MediaModel { use HasFactory; use HasStatus; diff --git a/app/Models/V2/MediaModel.php b/app/Models/V2/MediaModel.php new file mode 100644 index 000000000..8689e01f1 --- /dev/null +++ b/app/Models/V2/MediaModel.php @@ -0,0 +1,14 @@ +middleware('i18n'); Route::get('/funding-programme/{fundingProgramme}', [FundingProgrammeController::class, 'show']); -Route::post('file/upload/{model}/{collection}/{uuid}', UploadController::class); +ModelInterfaceBindingMiddleware::with( + MediaModel::class, + function () { + Route::post('/{collection}/{mediaModel}', UploadController::class); + }, + prefix: 'file/upload', + modelParameter: 'mediaModel' +); Route::resource('files', FilePropertiesController::class); //Route::put('file/{uuid}', [FilePropertiesController::class, 'update']); From 44f28cdfef0708815b1fa7bf94d66530d6895f60 Mon Sep 17 00:00:00 2001 From: Nathan Curtis Date: Wed, 17 Apr 2024 14:49:02 -0700 Subject: [PATCH 03/30] [TM-803] Ignore the .zip files generated during unit test runs. --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 96c7b63ef..4bb685369 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ /public/hot /public/storage /storage/*.key +/storage/*.zip /vendor .env /.phpunit.cache From 7e76b8eca4c1d18cc2d5a856e1725c79bd5f9e8e Mon Sep 17 00:00:00 2001 From: Jose Carlos Laura Ramirez Date: Thu, 18 Apr 2024 12:22:58 -0400 Subject: [PATCH 04/30] [TM-710, 647, 757, 758, 759, 760, 761, 762, 764, 765, 766] polygon validation endpoints (#145) * polygon validation endpoints * move code from migration to seeder * remove unused code * move code from migration to seeder * add missing pieces in docker file for php * fix lint * implement some suggestion on the model creation and uuid usage * change memory limit * merge single polygon of geo and dashboard * keep merging * add country in site_polygon migration and log error of site polygon * add Eloquent and clear query * add function to call area and latitude of polygon in model * add relation with polygon for site polygon * move to variable for clarity * fix: lint --------- Co-authored-by: JORGE Co-authored-by: cesarLima1 --- .../TerrafundCreateGeometryController.php | 719 ++++++ .../TerrafundEditGeometryController.php | 124 ++ app/Models/V2/PolygonGeometry.php | 39 + app/Models/V2/Sites/CodeCriteria.php | 22 + app/Models/V2/Sites/CriteriaSite.php | 42 + app/Models/V2/Sites/SitePolygon.php | 46 + app/Models/V2/WorldCountryGeneralized.php | 14 + ...2024_03_08_161030_create_code_criteria.php | 32 + ...3_153432_create_polygon_geometry_table.php | 31 + .../2024_03_8_100330_create_criteria_site.php | 37 + ...2024_03_8_101800_create_indicator_site.php | 39 + ...2024_03_8_101930_create_code_indicator.php | 39 + ...2024_04_2_121238_create_sites_polygons.php | 54 + database/seeders/CodeCriteria.php | 151 ++ database/seeders/CodeIndicator.php | 63 + docker/php.Dockerfile | 6 +- docker/php.ini | 1972 +++++++++++++++++ routes/api_v2.php | 29 + storage/logs/.gitignore | 0 19 files changed, 3458 insertions(+), 1 deletion(-) create mode 100644 app/Http/Controllers/V2/Terrafund/TerrafundCreateGeometryController.php create mode 100644 app/Http/Controllers/V2/Terrafund/TerrafundEditGeometryController.php create mode 100644 app/Models/V2/PolygonGeometry.php create mode 100644 app/Models/V2/Sites/CodeCriteria.php create mode 100644 app/Models/V2/Sites/CriteriaSite.php create mode 100644 app/Models/V2/Sites/SitePolygon.php create mode 100644 app/Models/V2/WorldCountryGeneralized.php create mode 100644 database/migrations/2024_03_08_161030_create_code_criteria.php create mode 100644 database/migrations/2024_03_13_153432_create_polygon_geometry_table.php create mode 100644 database/migrations/2024_03_8_100330_create_criteria_site.php create mode 100644 database/migrations/2024_03_8_101800_create_indicator_site.php create mode 100644 database/migrations/2024_03_8_101930_create_code_indicator.php create mode 100644 database/migrations/2024_04_2_121238_create_sites_polygons.php create mode 100644 database/seeders/CodeCriteria.php create mode 100644 database/seeders/CodeIndicator.php create mode 100644 docker/php.ini mode change 100644 => 100755 storage/logs/.gitignore diff --git a/app/Http/Controllers/V2/Terrafund/TerrafundCreateGeometryController.php b/app/Http/Controllers/V2/Terrafund/TerrafundCreateGeometryController.php new file mode 100644 index 000000000..c346905f5 --- /dev/null +++ b/app/Http/Controllers/V2/Terrafund/TerrafundCreateGeometryController.php @@ -0,0 +1,719 @@ +select(DB::raw('ST_AsGeoJSON(geom) AS geojson')) + ->first(); + + $geojson = $geometry->geojson; + + if ($geojson) { + return response()->json(['geometry' => $geojson], 200); + } else { + return response()->json(['error' => 'Geometry not found'], 404); + } + } + + public function storeGeometry(Request $request) + { + $request->validate([ + 'geometry' => 'required|json', + ]); + + $geometry = json_decode($request->input('geometry')); + $geom = DB::raw("ST_GeomFromGeoJSON('" . json_encode($geometry) . "')"); + + $polygonGeometry = PolygonGeometry::create([ + 'geom' => $geom, + ]); + + return response()->json(['uuid' => $polygonGeometry->uuid], 200); + } + + private function validatePolygonBounds(array $geometry): bool + { + if ($geometry['type'] !== 'Polygon') { + return false; + } + $coordinates = $geometry['coordinates'][0]; + foreach ($coordinates as $coordinate) { + $latitude = $coordinate[1]; + $longitude = $coordinate[0]; + if ($latitude < -90 || $latitude > 90) { + return false; + } + if ($longitude < -180 || $longitude > 180) { + return false; + } + + return true; + } + } + + private function insertSinglePolygon(array $geometry, int $srid) + { + try { + // Convert geometry to GeoJSON string with specified SRID + $geojson = json_encode(['type' => 'Feature', 'geometry' => $geometry, 'crs' => ['type' => 'name', 'properties' => ['name' => "EPSG:$srid"]]]); + + // Insert GeoJSON data into the database + $geom = DB::raw("ST_GeomFromGeoJSON('$geojson')"); + $areaSqDegrees = DB::selectOne("SELECT ST_Area(ST_GeomFromGeoJSON('$geojson')) AS area")->area; + $latitude = DB::selectOne("SELECT ST_Y(ST_Centroid(ST_GeomFromGeoJSON('$geojson'))) AS latitude")->latitude; + // 111320 is the length of one degree of latitude in meters at the equator + $unitLatitude = 111320; + $areaSqMeters = $areaSqDegrees * pow($unitLatitude * cos(deg2rad($latitude)), 2); + + $areaHectares = $areaSqMeters / 10000; + + $polygonGeometry = PolygonGeometry::create([ + 'geom' => $geom, + ]); + + return ['uuid' => $polygonGeometry->uuid, 'id' => $polygonGeometry->id, 'area' => $areaHectares]; + } catch (\Exception $e) { + echo $e; + + return $e->getMessage(); + } + } + + public function insertGeojsonToDB(string $geojsonFilename) + { + $srid = 4326; + $geojsonData = Storage::get("public/geojson_files/{$geojsonFilename}"); + $geojson = json_decode($geojsonData, true); + if (! isset($geojson['features'])) { + return ['error' => 'GeoJSON file does not contain features']; + } + $uuids = []; + foreach ($geojson['features'] as $feature) { + if ($feature['geometry']['type'] === 'Polygon') { + if (! $this->validatePolygonBounds($feature['geometry'])) { + return ['error' => 'Invalid polygon bounds']; + } + $data = $this->insertSinglePolygon($feature['geometry'], $srid); + $uuids[] = $data['uuid']; + $returnSite = $this->insertSitePolygon($data['uuid'], $feature['properties'], $data['area']); + if ($returnSite) { + Log::info($returnSite) ; + } + } elseif ($feature['geometry']['type'] === 'MultiPolygon') { + foreach ($feature['geometry']['coordinates'] as $polygon) { + $singlePolygon = ['type' => 'Polygon', 'coordinates' => $polygon]; + if (! $this->validatePolygonBounds($singlePolygon)) { + return ['error' => 'Invalid polygon bounds']; + } + $data = $this->insertSinglePolygon($singlePolygon, $srid); + $uuids[] = $data['uuid']; + $returnSite = $this->insertSitePolygon($data['uuid'], $feature['properties'], $data['area']); + if ($returnSite) { + Log::info($returnSite) ; + } + } + } + } + + return $uuids; + } + + private function validateSchema(array $properties, array $fields): bool + { + foreach ($fields as $field) { + if (! array_key_exists($field, $properties)) { + return false; + } + } + + return true; + } + + public function validateDataInDB(Request $request) + { + $polygonUuid = $request->input('uuid'); + $fieldsToValidate = ['poly_name', 'plantstart', 'plantend', 'practice', 'target_sys', 'distr', 'num_trees']; + $DATA_CRITERIA_ID = 14; + // Check if the polygon with the specified poly_id exists + $polygonExists = SitePolygon::where('poly_id', $polygonUuid) + ->exists(); + + if (! $polygonExists) { + return response()->json(['valid' => false, 'message' => 'No site polygon found with the specified poly_id.']); + } + + // Proceed with validation of attribute values + $whereConditions = []; + foreach ($fieldsToValidate as $field) { + $whereConditions[] = "(IFNULL($field, '') = '' OR $field IS NULL)"; + } + + $sitePolygonData = SitePolygon::where('poly_id', $polygonUuid) + ->where(function ($query) use ($whereConditions) { + foreach ($whereConditions as $condition) { + $query->orWhereRaw($condition); + } + }) + ->first(); + $this->insertCriteriaSite($polygonUuid, $DATA_CRITERIA_ID, false); + if ($sitePolygonData) { + return response()->json(['valid' => false, 'message' => 'Some attributes of the site polygon are invalid.']); + } + + $valid = true; + $this->insertCriteriaSite($polygonUuid, $DATA_CRITERIA_ID, $valid); + + return response()->json(['valid' => true]); + } + + private function validateData(array $properties, array $fields): bool + { + foreach ($fields as $field) { + $value = $properties[$field]; + if ($value === null || strtoupper($value) === 'NULL' || $value === '') { + return false; + } + } + + return true; + } + + private function insertSitePolygon(string $polygonUuid, array $properties, float $area) + { + try { + $fieldsToValidate = ['poly_name', 'plantstart', 'plantend', 'practice', 'target_sys', 'distr', 'num_trees']; + $SCHEMA_CRITERIA_ID = 13; + $validSchema = true; + $DATA_CRITERIA_ID = 14; + $validData = true; + if (! $this->validateSchema($properties, $fieldsToValidate)) { + $validSchema = false; + $validData = false; + } elseif (! $this->validateData($properties, $fieldsToValidate)) { + $validData = false; + } + $insertionSchemaSuccess = $this->insertCriteriaSite($polygonUuid, $SCHEMA_CRITERIA_ID, $validSchema); + $insertionDataSuccess = $this->insertCriteriaSite($polygonUuid, $DATA_CRITERIA_ID, $validData); + + $sitePolygon = new SitePolygon(); + $sitePolygon->project_id = $properties['project_id'] ?? null; + $sitePolygon->proj_name = $properties['proj_name'] ?? null; + $sitePolygon->org_name = $properties['org_name'] ?? null; + $sitePolygon->country = $properties['country'] ?? null; + $sitePolygon->poly_id = $polygonUuid ?? null; + $sitePolygon->poly_name = $properties['poly_name'] ?? null; + $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->target_sys = $properties['target_sys'] ?? null; + $sitePolygon->distr = $properties['distr'] ?? null; + $sitePolygon->num_trees = $properties['num_trees'] ?? null; + $sitePolygon->est_area = $area ?? null; + $sitePolygon->save(); + + return null; + } catch (\Exception $e) { + return $e->getMessage(); + } + } + + public function getGeometryProperties(string $geojsonFilename) + { + $geojsonData = Storage::get("public/geojson_files/{$geojsonFilename}"); + $geojson = json_decode($geojsonData, true); + if (! isset($geojson['features'])) { + return ['error' => 'GeoJSON file does not contain features']; + } + + $propertiesList = []; + foreach ($geojson['features'] as $feature) { + $properties = $feature['properties']; + $geometryType = $feature['geometry']['type']; + + if ($geometryType === 'Polygon' || $geometryType === 'MultiPolygon') { + $propertiesList[] = $properties; + } + } + + return $propertiesList; + } + + public function uploadKMLFile(Request $request) + { + if ($request->hasFile('file')) { + $kmlfile = $request->file('file'); + $directory = storage_path('app/public/kml_files'); + if (! file_exists($directory)) { + mkdir($directory, 0755, true); + } + $filename = uniqid('kml_file_') . '.' . $kmlfile->getClientOriginalExtension(); + $kmlfile->move($directory, $filename); + $geojsonFilename = Str::replaceLast('.kml', '.geojson', $filename); + $geojsonPath = storage_path("app/public/geojson_files/{$geojsonFilename}"); + $kmlPath = storage_path("app/public/kml_files/{$filename}"); + $process = new Process(['ogr2ogr', '-f', 'GeoJSON', $geojsonPath, $kmlPath]); + $process->run(); + if (! $process->isSuccessful()) { + Log::error('Error converting KML to GeoJSON: ' . $process->getErrorOutput()); + + return response()->json(['error' => 'Failed to convert KML to GeoJSON', 'message' => $process->getErrorOutput()], 500); + } + $uuid = $this->insertGeojsonToDB($geojsonFilename); + if (isset($uuid['error'])) { + return response()->json(['error' => 'Geometry not inserted into DB', 'message' => $uuid['error']], 500); + } + + return response()->json(['message' => 'KML file processed and inserted successfully', 'uuid' => $uuid], 200); + } else { + return response()->json(['error' => 'KML file not provided'], 400); + } + } + + private function findShpFile($directory) + { + Log::info('find shp: ' . $directory); + + $shpFile = null; + $files = scandir($directory); + foreach ($files as $file) { + if (pathinfo($file, PATHINFO_EXTENSION) === 'shp') { + $shpFile = "{$directory}/{$file}"; + + break; + } + } + + return $shpFile; + } + + public function uploadShapefile(Request $request) + { + Log::debug('Upload Shape file data', ['request' => $request->all()]); + if ($request->hasFile('file')) { + $file = $request->file('file'); + if ($file->getClientOriginalExtension() !== 'zip') { + return response()->json(['error' => 'Only ZIP files are allowed'], 400); + } + $directory = storage_path('app/public/shapefiles/' . uniqid('shapefile_')); + mkdir($directory, 0755, true); + + // Extract the contents of the ZIP file + $zip = new \ZipArchive(); + if ($zip->open($file->getPathname()) === true) { + $zip->extractTo($directory); + $zip->close(); + $shpFile = $this->findShpFile($directory); + if (! $shpFile) { + return response()->json(['error' => 'Shapefile (.shp) not found in the ZIP file'], 400); + } + $geojsonFilename = Str::replaceLast('.shp', '.geojson', basename($shpFile)); + $geojsonPath = storage_path("app/public/geojson_files/{$geojsonFilename}"); + $process = new Process(['ogr2ogr', '-f', 'GeoJSON', $geojsonPath, $shpFile]); + $process->run(); + if (! $process->isSuccessful()) { + Log::error('Error converting Shapefile to GeoJSON: ' . $process->getErrorOutput()); + + return response()->json(['error' => 'Failed to convert Shapefile to GeoJSON', 'message' => $process->getErrorOutput()], 500); + } + $uuid = $this->insertGeojsonToDB($geojsonFilename); + if (isset($uuid['error'])) { + return response()->json(['error' => 'Geometry not inserted into DB', 'message' => $uuid['error']], 500); + } + + return response()->json(['message' => 'Shape file processed and inserted successfully', 'uuid' => $uuid], 200); + } else { + return response()->json(['error' => 'Failed to open the ZIP file'], 400); + } + } else { + + return response()->json(['error' => 'No file uploaded'], 400); + } + } + + public function checkSelfIntersection(Request $request) + { + $uuid = $request->query('uuid'); + $geometry = PolygonGeometry::where('uuid', $uuid)->first(); + + if (! $geometry) { + return response()->json(['error' => 'Geometry not found'], 404); + } + + $isSimple = DB::selectOne('SELECT ST_IsSimple(geom) AS is_simple FROM polygon_geometry WHERE uuid = :uuid', ['uuid' => $uuid])->is_simple; + $SELF_CRITERIA_ID = 4; + $message = $isSimple ? 'The geometry is valid' : 'The geometry has self-intersections'; + $insertionSuccess = $this->insertCriteriaSite($uuid, $SELF_CRITERIA_ID, $isSimple); + + return response()->json(['selfintersects' => $message, 'geometry_id' => $geometry->id, 'insertion_success' => $insertionSuccess, 'valid' => $isSimple ? true : false], 200); + } + + public function calculateDistance($point1, $point2) + { + $lat1 = $point1[1]; + $lon1 = $point1[0]; + $lat2 = $point2[1]; + $lon2 = $point2[0]; + + $theta = $lon1 - $lon2; + $dist = sin(deg2rad($lat1)) * sin(deg2rad($lat2)) + cos(deg2rad($lat1)) * cos(deg2rad($lat2)) * cos(deg2rad($theta)); + $dist = acos($dist); + $dist = rad2deg($dist); + $miles = $dist * 60 * 1.1515; + + return $miles * 1.609344; + } + + public function detectSpikes($geometry) + { + $spikes = []; + + if ($geometry['type'] === 'Polygon' || $geometry['type'] === 'MultiPolygon') { + $coordinates = $geometry['type'] === 'Polygon' ? $geometry['coordinates'][0] : $geometry['coordinates'][0][0]; // First ring of the polygon or the first polygon in the MultiPolygon + $numVertices = count($coordinates); + $totalDistance = 0; + + for ($i = 0; $i < $numVertices - 1; $i++) { + $totalDistance += $this->calculateDistance($coordinates[$i], $coordinates[$i + 1]); + } + + for ($i = 0; $i < $numVertices - 1; $i++) { + $distance1 = $this->calculateDistance($coordinates[$i], $coordinates[($i + 1) % $numVertices]); + $distance2 = $this->calculateDistance($coordinates[($i + 1) % $numVertices], $coordinates[($i + 2) % $numVertices]); + $combinedDistance = $distance1 + $distance2; + + if ($combinedDistance > 0.6 * $totalDistance) { + // Vertex and its adjacent vertices contribute more than 25% of the total boundary path distance + $spikes[] = $coordinates[($i + 1) % $numVertices]; + } + } + } + + return $spikes; + } + + public function insertCriteriaSite($polygonId, $criteriaId, $valid) + { + $criteriaSite = new CriteriaSite(); + $criteriaSite->polygon_id = $polygonId; + $criteriaSite->criteria_id = $criteriaId; + $criteriaSite->valid = $valid; + + try { + $criteriaSite->save(); + + return true; + } catch (\Exception $e) { + return $e->getMessage(); + } + } + + public function checkBoundarySegments(Request $request) + { + $uuid = $request->query('uuid'); + $geometry = PolygonGeometry::where('uuid', $uuid)->first(); + + if (! $geometry) { + return response()->json(['error' => 'Geometry not found'], 404); + } + $geojson = DB::selectOne('SELECT ST_AsGeoJSON(geom) AS geojson FROM polygon_geometry WHERE uuid = :uuid', ['uuid' => $uuid])->geojson; + $geojsonArray = json_decode($geojson, true); + $spikes = $this->detectSpikes($geojsonArray); + $SPIKE_CRITERIA_ID = 8; + $valid = count($spikes) === 0; + $insertionSuccess = $this->insertCriteriaSite($uuid, $SPIKE_CRITERIA_ID, $valid); + + return response()->json(['spikes' => $spikes, 'geometry_id' => $uuid, 'insertion_success' => $insertionSuccess, 'valid' => $valid], 200); + } + + public function validatePolygonSize(Request $request) + { + $uuid = $request->query('uuid'); + $geometry = PolygonGeometry::where('uuid', $uuid)->first(); + + if (! $geometry) { + return response()->json(['error' => 'Geometry not found'], 404); + } + $areaAndLatitude = $geometry->getDbGeometryAttribute(); + + $areaSqDegrees = $areaAndLatitude->area; + $latitude = $areaAndLatitude->latitude; + $areaSqMeters = $areaSqDegrees * pow(111320 * cos(deg2rad($latitude)), 2); + $SIZE_CRITERIA_ID = 6; + $valid = $areaSqMeters <= 10000000; + $insertionSuccess = $this->insertCriteriaSite($uuid, $SIZE_CRITERIA_ID, $valid); + + return response()->json([ + 'area_hectares' => $areaSqMeters / 10000, // Convert to hectares + 'area_sqmeters' => $areaSqMeters, + 'geometry_id' => $geometry->id, + 'insertion_success' => $insertionSuccess, + 'valid' => $valid, + ], 200); + } + + public function checkWithinCountry(Request $request) + { + $polygonUuid = $request->input('uuid'); + + if ($polygonUuid === null || $polygonUuid === '') { + return response()->json(['error' => 'UUID not provided'], 200); + } + + $geometry = PolygonGeometry::where('uuid', $polygonUuid)->first(); + + if (! $geometry) { + return response()->json(['error' => 'Geometry not found'], 404); + } + + $totalArea = PolygonGeometry::where('uuid', $polygonUuid) + ->selectRaw('ST_Area(geom) AS area') + ->first()->area; + + // Find site_polygon_id and project_id using the polygonUuid + $sitePolygonData = SitePolygon::where('poly_id', $polygonUuid) + ->select('id', 'project_id') + ->first(); + + if (! $sitePolygonData) { + return response()->json(['error' => 'Site polygon data not found for the specified polygonUuid'], 404); + } + + $countryIso = $sitePolygonData->project->country; + if (! $countryIso) { + return response()->json(['error' => 'Country ISO not found for the specified project_id'], 404); + } + + $intersectionData = WorldCountryGeneralized::where('iso', $countryIso) + ->selectRaw('world_countries_generalized.country AS country, ST_Area(ST_Intersection(world_countries_generalized.geometry, (SELECT geom FROM polygon_geometry WHERE uuid = ?))) AS area', [$polygonUuid]) + ->first(); + + $intersectionArea = $intersectionData->area; + $countryName = $intersectionData->country; + + $insidePercentage = $intersectionArea / $totalArea * 100; + + $insideThreshold = 75; + $insideViolation = $insidePercentage < $insideThreshold; + $WITHIN_COUNTRY_CRITERIA_ID = 7; + $insertionSuccess = $this->insertCriteriaSite($polygonUuid, $WITHIN_COUNTRY_CRITERIA_ID, ! $insideViolation); + + return response()->json([ + 'country_name' => $countryName, + 'inside_percentage' => $insidePercentage, + 'valid' => ! $insideViolation, + 'geometry_id' => $geometry->id, + 'insertion_success' => $insertionSuccess, + ]); + + } + + public function getGeometryType(Request $request) + { + $uuid = $request->input('uuid'); + + // Fetch the geometry type based on the UUID using SQL query + $query = 'SELECT ST_GeometryType(geom) AS geometry_type FROM polygon_geometry WHERE uuid = ?'; + $result = DB::selectOne($query, [$uuid]); + + if ($result) { + $geometryType = $result->geometry_type; + $valid = $geometryType === 'POLYGON'; + $GEOMETRY_TYPE_CRITERIA_ID = 10; + $insertionSuccess = $this->insertCriteriaSite($uuid, $GEOMETRY_TYPE_CRITERIA_ID, $valid); + + return response()->json(['uuid' => $uuid, 'geometry_type' => $geometryType, 'valid' => $valid, 'insertion_success' => $insertionSuccess]); + } else { + return response()->json(['error' => 'Geometry not found for the given UUID'], 404); + } + } + + public function getCriteriaData(Request $request) + { + $uuid = $request->input('uuid'); + + // Find the ID of the polygon based on the UUID + $polygonIdQuery = 'SELECT id FROM polygon_geometry WHERE uuid = ?'; + $polygonIdResult = DB::selectOne($polygonIdQuery, [$uuid]); + + if (! $polygonIdResult) { + return response()->json(['error' => 'Polygon not found for the given UUID'], 404); + } + + // Fetch data from criteria_site with distinct criteria_id based on the latest created_at + $criteriaDataQuery = 'SELECT criteria_id, MAX(created_at) AS latest_created_at + FROM criteria_site + WHERE polygon_id = ? + GROUP BY criteria_id'; + + $criteriaData = DB::select($criteriaDataQuery, [$uuid]); + + if (empty($criteriaData)) { + return response()->json(['error' => 'Criteria data not found for the given polygon ID'], 404); + } + + // Determine the validity of each criteria + $criteriaList = []; + foreach ($criteriaData as $criteria) { + $criteriaId = $criteria->criteria_id; + $valid = CriteriaSite::where(['polygon_id' => $uuid, 'criteria_id' => $criteriaId])->select('valid')->first()?->valid; + $criteriaList[] = [ + 'criteria_id' => $criteriaId, + 'latest_created_at' => $criteria->latest_created_at, + 'valid' => $valid, + ]; + } + + return response()->json(['polygon_id' => $uuid, 'criteria_list' => $criteriaList]); + } + + public function uploadGeoJSONFile(Request $request) + { + if ($request->hasFile('file')) { + $file = $request->file('file'); + $directory = storage_path('app/public/geojson_files'); + if (! file_exists($directory)) { + mkdir($directory, 0755, true); + } + $filename = uniqid('geojson_file_') . '.' . $file->getClientOriginalExtension(); + $file->move($directory, $filename); + $uuid = $this->insertGeojsonToDB($filename); + if (is_array($uuid) && isset($uuid['error'])) { + return response()->json(['error' => 'Failed to insert GeoJSON data into the database', 'message' => $uuid['error']], 500); + } + + return response()->json(['message' => 'Geojson file processed and inserted successfully', 'uuid' => $uuid], 200); + } else { + return response()->json(['error' => 'GeoJSON file not provided in request'], 400); + } + } + + public function validateOverlapping(Request $request) + { + $uuid = $request->input('uuid'); + $sitePolygon = SitePolygon::where('poly_id', $uuid) + ->first(); + + if (! $sitePolygon) { + return response()->json(['error' => 'Site polygon not found for the given polygon ID'], 200); + } + + $projectId = $sitePolygon->project_id; + if(! $projectId) { + return response()->json(['error' => 'Project ID not found for the given polygon ID'], 200); + } + $relatedPolyIds = SitePolygon::where('project_id', $projectId) + ->where('poly_id', '!=', $uuid) + ->pluck('poly_id'); + + $intersects = PolygonGeometry::whereIn('uuid', $relatedPolyIds) + ->selectRaw('ST_Intersects(geom, (SELECT geom FROM polygon_geometry WHERE uuid = ?)) as intersects', [$uuid]) + ->get() + ->pluck('intersects'); + + $intersects = in_array(1, $intersects->toArray()); + $valid = ! $intersects; + $OVERLAPPING_CRITERIA_ID = 3; + $insertionSuccess = $this->insertCriteriaSite($uuid, $OVERLAPPING_CRITERIA_ID, $valid); + + return response()->json(['intersects' => $intersects, 'project_id' => $projectId, 'uuid' => $uuid, 'valid' => $valid, 'creteria_succes' => $insertionSuccess], 200); + } + + public function validateEstimatedArea(Request $request) + { + $uuid = $request->input('uuid'); + $sitePolygon = SitePolygon::where('poly_id', $uuid) + ->first(); + + if (! $sitePolygon) { + return response()->json(['error' => 'Site polygon not found for the given polygon ID'], 200); + } + + $projectId = $sitePolygon->project_id; + + $sumEstArea = SitePolygon::where('project_id', $projectId) + ->sum('est_area'); + + $project = Project::where('uuid', $projectId) + ->first(); + + if (! $project) { + return response()->json(['error' => 'Project not found for the given project ID', 'projectId' => $projectId], 200); + } + + $totalHectaresRestoredGoal = $project->total_hectares_restored_goal; + if ($totalHectaresRestoredGoal === null || $totalHectaresRestoredGoal === 0) { + return response()->json(['error' => 'Total hectares restored goal not set for the project'], 400); + } + $lowerBound = 0.75 * $totalHectaresRestoredGoal; + $upperBound = 1.25 * $totalHectaresRestoredGoal; + $valid = false; + if ($sumEstArea >= $lowerBound && $sumEstArea <= $upperBound) { + $valid = true; + } + $ESTIMATED_AREA_CRITERIA_ID = 12; + $insertionSuccess = $this->insertCriteriaSite($uuid, $ESTIMATED_AREA_CRITERIA_ID, $valid); + + return response()->json(['valid' => $valid, 'sum_area_project' => $sumEstArea, 'total_area_project' => $totalHectaresRestoredGoal, 'insertionSuccess' => $insertionSuccess], 200); + } + + public function getPolygonsAsGeoJSON() + { + $limit = 2; + $polygons = PolygonGeometry::select(DB::raw('ST_AsGeoJSON(geom) AS geojson')) + ->orderBy('created_at', 'desc') + ->whereNotNull('geom') + ->limit($limit) + ->get(); + $features = []; + + foreach ($polygons as $polygon) { + $coordinates = json_decode($polygon->geojson)->coordinates; + $feature = [ + 'type' => 'Feature', + 'geometry' => [ + 'type' => 'Polygon', + 'coordinates' => $coordinates, + ], + 'properties' => [], + ]; + $features[] = $feature; + } + $geojson = [ + 'type' => 'FeatureCollection', + 'features' => $features, + ]; + + // Return the GeoJSON data + return response()->json($geojson); + } + + public function getAllCountryNames() + { + $countries = WorldCountryGeneralized::select('country') + ->distinct() + ->orderBy('country') + ->pluck('country'); + + return response()->json(['countries' => $countries]); + } +} diff --git a/app/Http/Controllers/V2/Terrafund/TerrafundEditGeometryController.php b/app/Http/Controllers/V2/Terrafund/TerrafundEditGeometryController.php new file mode 100644 index 000000000..b9c57ecf5 --- /dev/null +++ b/app/Http/Controllers/V2/Terrafund/TerrafundEditGeometryController.php @@ -0,0 +1,124 @@ +first(); + + if (! $sitePolygon) { + return response()->json(['message' => 'No site polygons found for the given UUID.'], 404); + } + + return response()->json(['site_polygon' => $sitePolygon]); + } catch (\Exception $e) { + return response()->json(['message' => $e->getMessage()], 500); + } + } + + public function updateGeometry(string $uuid, Request $request) + { + $polygonGeometry = PolygonGeometry::where('uuid', $uuid)->first(); + if (! $polygonGeometry) { + return response()->json(['message' => 'No polygon geometry found for the given UUID.'], 404); + } + $geometry = json_decode($request->input('geometry')); + $geom = DB::raw("ST_GeomFromGeoJSON('" . json_encode($geometry) . "')"); + $polygonGeometry->geom = $geom; + $polygonGeometry->save(); + + return response()->json(['message' => 'Geometry updated successfully.', 'geometry' => $geometry, 'uuid' => $uuid]); + } + + public function getPolygonGeojson(string $uuid) + { + $geometryQuery = PolygonGeometry::isUuid($uuid); + if (! $geometryQuery->exists()) { + return response()->json(['message' => 'No polygon geometry found for the given UUID.'], 404); + } + $geojsonData = json_decode($geometryQuery->select(DB::raw('ST_AsGeoJSON(geom) as geojson'))->first()->geojson, true); + + return response()->json([ + 'geojson' => $geojsonData, + ]); + } + + public function updateSitePolygon(string $uuid, Request $request) + { + try { + $sitePolygon = SitePolygon::where('uuid', $uuid)->first(); + if (! $sitePolygon) { + return response()->json(['message' => 'No site polygons found for the given UUID.'], 404); + } + $validatedData = $request->validate([ + 'poly_name' => 'nullable|string', + 'plantstart' => 'nullable|date', + 'plantend' => 'nullable|date', + 'practice' => 'nullable|string', + 'distr' => 'nullable|string', + 'num_trees' => 'nullable|integer', + 'est_area' => 'nullable|numeric', + 'target_sys' => 'nullable|string', + ]); + + $sitePolygon->update($validatedData); + + return response()->json(['message' => 'Site polygon updated successfully'], 200); + } catch (\Exception $e) { + // Handle other exceptions + return response()->json(['error' => 'An error occurred: ' . $e->getMessage()], 500); + } + } + + public function createSitePolygon(string $uuid, Request $request) + { + try { + $validatedData = $request->validate([ + 'poly_name' => 'nullable|string', + 'plantstart' => 'nullable|date', + 'plantend' => 'nullable|date', + 'practice' => 'nullable|string', + 'distr' => 'nullable|string', + 'num_trees' => 'nullable|integer', + 'target_sys' => 'nullable|string', + ]); + + $polygonGeometry = PolygonGeometry::where('uuid', $uuid)->first(); + if (! $polygonGeometry) { + return response()->json(['message' => 'No polygon geometry found for the given UUID.'], 404); + } + $areaSqDegrees = DB::selectOne('SELECT ST_Area(geom) AS area FROM polygon_geometry WHERE uuid = :uuid', ['uuid' => $uuid])->area; + $latitude = DB::selectOne('SELECT ST_Y(ST_Centroid(geom)) AS latitude FROM polygon_geometry WHERE uuid = :uuid', ['uuid' => $uuid])->latitude; + $areaSqMeters = $areaSqDegrees * pow(111320 * cos(deg2rad($latitude)), 2); + $areaHectares = $areaSqMeters / 10000; + $sitePolygon = new SitePolygon([ + 'poly_name' => $validatedData['poly_name'], + 'plantstart' => $validatedData['plantstart'], + 'plantend' => $validatedData['plantend'], + 'practice' => $validatedData['practice'], + 'distr' => $validatedData['distr'], + 'num_trees' => $validatedData['num_trees'], + 'est_area' => $areaHectares, // Assign the calculated area + 'target_sys' => $validatedData['target_sys'], + ]); + $sitePolygon->poly_id = $uuid; + $sitePolygon->uuid = Str::uuid(); + $sitePolygon->save(); + + return response()->json(['message' => 'Site polygon created successfully', 'uuid' => $sitePolygon, 'area' => $areaHectares], 201); + } catch (\Exception $e) { + // Handle other exceptions + return response()->json(['error' => 'An error occurred: ' . $e->getMessage()], 500); + } + } +} diff --git a/app/Models/V2/PolygonGeometry.php b/app/Models/V2/PolygonGeometry.php new file mode 100644 index 000000000..2e2dc8763 --- /dev/null +++ b/app/Models/V2/PolygonGeometry.php @@ -0,0 +1,39 @@ +hasMany(CriteriaSite::class, 'polygon_id', 'polygon_id'); + } + + public function getDbGeometryAttribute() + { + $result = DB::selectOne( + ' + SELECT ST_Area(geom) AS area, ST_Y(ST_Centroid(geom)) AS latitude + FROM polygon_geometry + WHERE uuid = :uuid', + ['uuid' => $this->uuid] + ); + + return $result; + } +} diff --git a/app/Models/V2/Sites/CodeCriteria.php b/app/Models/V2/Sites/CodeCriteria.php new file mode 100644 index 000000000..28d3e20bb --- /dev/null +++ b/app/Models/V2/Sites/CodeCriteria.php @@ -0,0 +1,22 @@ +hasMany(CriteriaSite::class, 'criteria_id', 'id'); + } +} diff --git a/app/Models/V2/Sites/CriteriaSite.php b/app/Models/V2/Sites/CriteriaSite.php new file mode 100644 index 000000000..7d530b2d1 --- /dev/null +++ b/app/Models/V2/Sites/CriteriaSite.php @@ -0,0 +1,42 @@ +belongsTo(PolygonGeometry::class, 'poly_id', 'uuid'); + } + + public function project() + { + return $this->belongsTo(Project::class, 'project_id', 'uuid'); + } +} diff --git a/app/Models/V2/WorldCountryGeneralized.php b/app/Models/V2/WorldCountryGeneralized.php new file mode 100644 index 000000000..13670a0ab --- /dev/null +++ b/app/Models/V2/WorldCountryGeneralized.php @@ -0,0 +1,14 @@ +id(); + $table->uuid('uuid')->unique(); + $table->string('uuid_primary'); + $table->string('name'); + $table->string('description'); + $table->integer('is_active'); + $table->softDeletes(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('code_criteria'); + } +}; diff --git a/database/migrations/2024_03_13_153432_create_polygon_geometry_table.php b/database/migrations/2024_03_13_153432_create_polygon_geometry_table.php new file mode 100644 index 000000000..4bbd26d7a --- /dev/null +++ b/database/migrations/2024_03_13_153432_create_polygon_geometry_table.php @@ -0,0 +1,31 @@ +id(); + $table->uuid('uuid')->unique(); + $table->geometry('geom')->nullable(); + ; + $table->softDeletes(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('polygon_geometry'); + } +}; diff --git a/database/migrations/2024_03_8_100330_create_criteria_site.php b/database/migrations/2024_03_8_100330_create_criteria_site.php new file mode 100644 index 000000000..2364e8db9 --- /dev/null +++ b/database/migrations/2024_03_8_100330_create_criteria_site.php @@ -0,0 +1,37 @@ +id(); + $table->uuid('uuid')->unique(); + $table->integer('criteria_id')->nullable(); + $table->string('polygon_id')->nullable(); + $table->integer('valid')->nullable(); + $table->date('date_created')->nullable(); + $table->softDeletes(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('criteria_site'); + } +} diff --git a/database/migrations/2024_03_8_101800_create_indicator_site.php b/database/migrations/2024_03_8_101800_create_indicator_site.php new file mode 100644 index 000000000..5eba2d9e5 --- /dev/null +++ b/database/migrations/2024_03_8_101800_create_indicator_site.php @@ -0,0 +1,39 @@ +id(); + $table->uuid('uuid')->unique(); + $table->integer('indicator_id'); + $table->integer('polygon_id'); + $table->integer('year'); + $table->float('value'); + $table->date('date_created'); + $table->softDeletes(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('indicator_site'); + } +} diff --git a/database/migrations/2024_03_8_101930_create_code_indicator.php b/database/migrations/2024_03_8_101930_create_code_indicator.php new file mode 100644 index 000000000..12605555d --- /dev/null +++ b/database/migrations/2024_03_8_101930_create_code_indicator.php @@ -0,0 +1,39 @@ +id(); + $table->uuid('uuid')->unique(); + $table->string('uuid_primary'); + $table->string('name'); + $table->string('unit'); + $table->string('description'); + $table->integer('is_active'); + $table->softDeletes(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('code_indicator'); + } +} diff --git a/database/migrations/2024_04_2_121238_create_sites_polygons.php b/database/migrations/2024_04_2_121238_create_sites_polygons.php new file mode 100644 index 000000000..8b5a227e1 --- /dev/null +++ b/database/migrations/2024_04_2_121238_create_sites_polygons.php @@ -0,0 +1,54 @@ +id(); + $table->uuid('uuid')->unique(); + $table->string('project_id')->nullable(); + $table->string('proj_name')->nullable(); + $table->string('site_id')->nullable(); + $table->string('site_name')->nullable(); + $table->string('org_name')->nullable(); + $table->string('poly_id')->nullable(); + $table->string('poly_name')->nullable(); + $table->string('poly_label')->nullable(); + $table->date('plantstart')->nullable(); + $table->date('plantend')->nullable(); + $table->string('practice')->nullable(); + $table->string('target_sys')->nullable(); + $table->string('distr')->nullable(); + $table->integer('num_trees')->nullable(); + $table->float('est_area')->nullable(); + $table->date('date_modified')->nullable(); + $table->string('country')->nullable(); + $table->string('status')->nullable(); + $table->string('created_by')->nullable(); + $table->string('last_modified_by')->nullable(); + $table->softDeletes(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('site_polygon'); + } +} diff --git a/database/seeders/CodeCriteria.php b/database/seeders/CodeCriteria.php new file mode 100644 index 000000000..3a45fa2ce --- /dev/null +++ b/database/seeders/CodeCriteria.php @@ -0,0 +1,151 @@ + $uuid1 = Str::uuid(), + 'uuid_primary' => $uuid1, + 'name' => 'Format GeoJSON', + 'description' => 'Flag: Output format is not GeoJSON', + 'is_active' => 1, + 'created_at' => $now, + 'updated_at' => $now, + ], + [ + 'uuid' => $uuid2 = Str::uuid(), + 'uuid_primary' => $uuid2, + 'name' => 'Projection WGS-84', + 'description' => 'Flag: CRS is not WGS-84 (EPSG 4326)', + 'is_active' => 1, + 'created_at' => $now, + 'updated_at' => $now, + ], + [ + 'uuid' => $uuid3 = Str::uuid(), + 'uuid_primary' => $uuid3, + 'name' => 'Overlapping Polygons', + 'description' => 'Flag: Overlapping polygons', + 'is_active' => 1, + 'created_at' => $now, + 'updated_at' => $now, + ], + [ + 'uuid' => $uuid4 = Str::uuid(), + 'uuid_primary' => $uuid4, + 'name' => 'Self-Intersection Flag', + 'description' => 'Flag: Self-intersecting polygon', + 'is_active' => 1, + 'created_at' => $now, + 'updated_at' => $now, + ], + [ + 'uuid' => $uuid5 = Str::uuid(), + 'uuid_primary' => $uuid5, + 'name' => 'Coordinate System Flag', + 'description' => 'Flag: Polygon bounding box (envelope) is outside (-/+ 180 , -/+ 90)', + 'is_active' => 1, + 'created_at' => $now, + 'updated_at' => $now, + ], + [ + 'uuid' => $uuid6 = Str::uuid(), + 'uuid_primary' => $uuid6, + 'name' => 'Size Limit Flag', + 'description' => 'Flag: Polygon area greater than 1000 ha', + 'is_active' => 1, + 'created_at' => $now, + 'updated_at' => $now, + ], + [ + 'uuid' => $uuid7 = Str::uuid(), + 'uuid_primary' => $uuid7, + 'name' => 'Within Country Flag', + 'description' => 'Flag: Polygon does not sit more than 25% outside of the expected country', + 'is_active' => 1, + 'created_at' => $now, + 'updated_at' => $now, + ], + [ + 'uuid' => $uuid8 = Str::uuid(), + 'uuid_primary' => $uuid8, + 'name' => 'Spike Flag', + 'description' => 'Flag: If the polygon boundary is composed of over 100 line segments and two adjoining boundary line segments contribute more than 25% of the total boundary path distance', + 'is_active' => 1, + 'created_at' => $now, + 'updated_at' => $now, + ], + [ + 'uuid' => $uuid9 = Str::uuid(), + 'uuid_primary' => $uuid9, + 'name' => 'Geometry Type Flag', + 'description' => 'Flag: The file’s geometry type is not one of: Polygon MultiPolygon 3D Polygon 3D MultiPolygon', + 'is_active' => 1, + 'created_at' => $now, + 'updated_at' => $now, + ], + [ + 'uuid' => $uuid10 = Str::uuid(), + 'uuid_primary' => $uuid10, + 'name' => 'Polygon Flag', + 'description' => 'Flag: A feature’s geometry type is not one of: Polygon MultiPolygon', + 'is_active' => 1, + 'created_at' => $now, + 'updated_at' => $now, + ], + [ + 'uuid' => $uuid11 = Str::uuid(), + 'uuid_primary' => $uuid11, + 'name' => '2-Dimension Flag', + 'description' => 'Flag: A feature’s geometry contains Z coordinates (3d points)', + 'is_active' => 1, + 'created_at' => $now, + 'updated_at' => $now, + ], + [ + 'uuid' => $uuid12 = Str::uuid(), + 'uuid_primary' => $uuid12, + 'name' => 'Total Area Expected Flag', + 'description' => 'Flag: Total polygon area (at site level) not between 75% and 125% of proposed restoration area', + 'is_active' => 1, + 'created_at' => $now, + 'updated_at' => $now, + ], + [ + 'uuid' => $uuid13 = Str::uuid(), + 'uuid_primary' => $uuid13, + 'name' => 'Table Schema Flag', + 'description' => 'Flag: Attribute table does not match schema', + 'is_active' => 1, + 'created_at' => $now, + 'updated_at' => $now, + ], + [ + 'uuid' => $uuid14 = Str::uuid(), + 'uuid_primary' => $uuid14, + 'name' => 'Data Completed Flag', + 'description' => 'Flag: Attribute table matches schema on a feature-by-feature level', + 'is_active' => 1, + 'created_at' => $now, + 'updated_at' => $now, + ], + ]; + + foreach ($criteria as $criterion) { + DB::table('code_criteria')->insert($criterion); + } + } +} diff --git a/database/seeders/CodeIndicator.php b/database/seeders/CodeIndicator.php new file mode 100644 index 000000000..3a6c0c963 --- /dev/null +++ b/database/seeders/CodeIndicator.php @@ -0,0 +1,63 @@ + 'Tree cover (TTC)', + 'unit' => 'Percent', + 'description' => 'Percent tree cover of each site polygon', + 'is_active' => 1, + ], + [ + 'name' => 'Tree cover loss', + 'unit' => 'Ha', + 'description' => 'Tree cover loss in hectares', + 'is_active' => 1, + ], + [ + 'name' => 'Tree cover loss from fires', + 'unit' => 'Ha', + 'description' => 'Tree cover loss from fires in hectares', + 'is_active' => 1, + ], + [ + 'name' => 'Hectares under restoration by WWF ecoregion', + 'unit' => 'Ha', + 'description' => 'Area value for each ecoregion type', + 'is_active' => 1, + ], + [ + 'name' => 'Hectares under restoration by intervention type', + 'unit' => 'Ha', + 'description' => 'Area value for each intervention type', + 'is_active' => 1, + ], + [ + 'name' => 'Tree count', + 'unit' => 'Count', + 'description' => 'Tree count number and confidence value', + 'is_active' => 1, + ], + ]; + $now = now(); + foreach ($indicators as $indicator) { + $indicator['uuid'] = Str::uuid(); + $indicator['uuid_primary'] = $indicator['uuid']; + $indicator['created_at'] = $now; + $indicator['updated_at'] = $now; + DB::table('code_indicator')->insert($indicator); + } + } +} diff --git a/docker/php.Dockerfile b/docker/php.Dockerfile index a36ce8bd9..e9ee0576d 100644 --- a/docker/php.Dockerfile +++ b/docker/php.Dockerfile @@ -9,7 +9,10 @@ RUN apt-get install -y \ libfreetype6-dev \ libmagickwand-dev \ mariadb-client \ - libzip-dev + libzip-dev \ + gdal-bin \ + libgdal-dev + RUN docker-php-ext-configure gd --with-freetype --with-jpeg RUN docker-php-ext-install \ bcmath \ @@ -33,3 +36,4 @@ RUN mv "$PHP_INI_DIR/php.ini-development" "$PHP_INI_DIR/php.ini" ## APACHE RUN a2enmod rewrite COPY docker/000-default.conf /etc/apache2/sites-available/000-default.conf +COPY docker/php.ini /usr/local/etc/php/php.ini diff --git a/docker/php.ini b/docker/php.ini new file mode 100644 index 000000000..8d9996370 --- /dev/null +++ b/docker/php.ini @@ -0,0 +1,1972 @@ +[PHP] + +;;;;;;;;;;;;;;;;;;; +; About php.ini ; +;;;;;;;;;;;;;;;;;;; +; PHP's initialization file, generally called php.ini, is responsible for +; configuring many of the aspects of PHP's behavior. + +; PHP attempts to find and load this configuration from a number of locations. +; The following is a summary of its search order: +; 1. SAPI module specific location. +; 2. The PHPRC environment variable. +; 3. A number of predefined registry keys on Windows +; 4. Current working directory (except CLI) +; 5. The web server's directory (for SAPI modules), or directory of PHP +; (otherwise in Windows) +; 6. The directory from the --with-config-file-path compile time option, or the +; Windows directory (usually C:\windows) +; See the PHP docs for more specific information. +; https://php.net/configuration.file + +; The syntax of the file is extremely simple. Whitespace and lines +; beginning with a semicolon are silently ignored (as you probably guessed). +; Section headers (e.g. [Foo]) are also silently ignored, even though +; they might mean something in the future. + +; Directives following the section heading [PATH=/www/mysite] only +; apply to PHP files in the /www/mysite directory. Directives +; following the section heading [HOST=www.example.com] only apply to +; PHP files served from www.example.com. Directives set in these +; special sections cannot be overridden by user-defined INI files or +; at runtime. Currently, [PATH=] and [HOST=] sections only work under +; CGI/FastCGI. +; https://php.net/ini.sections + +; Directives are specified using the following syntax: +; directive = value +; Directive names are *case sensitive* - foo=bar is different from FOO=bar. +; Directives are variables used to configure PHP or PHP extensions. +; There is no name validation. If PHP can't find an expected +; directive because it is not set or is mistyped, a default value will be used. + +; The value can be a string, a number, a PHP constant (e.g. E_ALL or M_PI), one +; of the INI constants (On, Off, True, False, Yes, No and None) or an expression +; (e.g. E_ALL & ~E_NOTICE), a quoted string ("bar"), or a reference to a +; previously set variable or directive (e.g. ${foo}) + +; Expressions in the INI file are limited to bitwise operators and parentheses: +; | bitwise OR +; ^ bitwise XOR +; & bitwise AND +; ~ bitwise NOT +; ! boolean NOT + +; Boolean flags can be turned on using the values 1, On, True or Yes. +; They can be turned off using the values 0, Off, False or No. + +; An empty string can be denoted by simply not writing anything after the equal +; sign, or by using the None keyword: + +; foo = ; sets foo to an empty string +; foo = None ; sets foo to an empty string +; foo = "None" ; sets foo to the string 'None' + +; If you use constants in your value, and these constants belong to a +; dynamically loaded extension (either a PHP extension or a Zend extension), +; you may only use these constants *after* the line that loads the extension. + +;;;;;;;;;;;;;;;;;;; +; About this file ; +;;;;;;;;;;;;;;;;;;; +; PHP comes packaged with two INI files. One that is recommended to be used +; in production environments and one that is recommended to be used in +; development environments. + +; php.ini-production contains settings which hold security, performance and +; best practices at its core. But please be aware, these settings may break +; compatibility with older or less security conscience applications. We +; recommending using the production ini in production and testing environments. + +; php.ini-development is very similar to its production variant, except it is +; much more verbose when it comes to errors. We recommend using the +; development version only in development environments, as errors shown to +; application users can inadvertently leak otherwise secure information. + +; This is the php.ini-development INI file. + +;;;;;;;;;;;;;;;;;;; +; Quick Reference ; +;;;;;;;;;;;;;;;;;;; + +; The following are all the settings which are different in either the production +; or development versions of the INIs with respect to PHP's default behavior. +; Please see the actual settings later in the document for more details as to why +; we recommend these changes in PHP's behavior. + +; display_errors +; Default Value: On +; Development Value: On +; Production Value: Off + +; display_startup_errors +; Default Value: On +; Development Value: On +; Production Value: Off +;hola +; error_reporting +; Default Value: E_ALL +; Development Value: E_ALL +; Production Value: E_ALL & ~E_DEPRECATED & ~E_STRICT + +; log_errors +; Default Value: Off +; Development Value: On +; Production Value: On + +; max_input_time +; Default Value: -1 (Unlimited) +; Development Value: 60 (60 seconds) +; Production Value: 60 (60 seconds) + +; output_buffering +; Default Value: Off +; Development Value: 4096 +; Production Value: 4096 + +; register_argc_argv +; Default Value: On +; Development Value: Off +; Production Value: Off + +; request_order +; Default Value: None +; Development Value: "GP" +; Production Value: "GP" + +; session.gc_divisor +; Default Value: 100 +; Development Value: 1000 +; Production Value: 1000 + +; session.sid_bits_per_character +; Default Value: 4 +; Development Value: 5 +; Production Value: 5 + +; short_open_tag +; Default Value: On +; Development Value: Off +; Production Value: Off + +; variables_order +; Default Value: "EGPCS" +; Development Value: "GPCS" +; Production Value: "GPCS" + +; zend.exception_ignore_args +; Default Value: Off +; Development Value: Off +; Production Value: On + +; zend.exception_string_param_max_len +; Default Value: 15 +; Development Value: 15 +; Production Value: 0 + +;;;;;;;;;;;;;;;;;;;; +; php.ini Options ; +;;;;;;;;;;;;;;;;;;;; +; Name for user-defined php.ini (.htaccess) files. Default is ".user.ini" +;user_ini.filename = ".user.ini" + +; To disable this feature set this option to an empty value +;user_ini.filename = + +; TTL for user-defined php.ini files (time-to-live) in seconds. Default is 300 seconds (5 minutes) +;user_ini.cache_ttl = 300 + +;;;;;;;;;;;;;;;;;;;; +; Language Options ; +;;;;;;;;;;;;;;;;;;;; + +; Enable the PHP scripting language engine under Apache. +; https://php.net/engine +engine = On + +; This directive determines whether or not PHP will recognize code between +; tags as PHP source which should be processed as such. It is +; generally recommended that should be used and that this feature +; should be disabled, as enabling it may result in issues when generating XML +; documents, however this remains supported for backward compatibility reasons. +; Note that this directive does not control the would work. +; https://php.net/syntax-highlighting +;highlight.string = #DD0000 +;highlight.comment = #FF9900 +;highlight.keyword = #007700 +;highlight.default = #0000BB +;highlight.html = #000000 + +; If enabled, the request will be allowed to complete even if the user aborts +; the request. Consider enabling it if executing long requests, which may end up +; being interrupted by the user or a browser timing out. PHP's default behavior +; is to disable this feature. +; https://php.net/ignore-user-abort +;ignore_user_abort = On + +; Determines the size of the realpath cache to be used by PHP. This value should +; be increased on systems where PHP opens many files to reflect the quantity of +; the file operations performed. +; Note: if open_basedir is set, the cache is disabled +; https://php.net/realpath-cache-size +;realpath_cache_size = 4096k + +; Duration of time, in seconds for which to cache realpath information for a given +; file or directory. For systems with rarely changing files, consider increasing this +; value. +; https://php.net/realpath-cache-ttl +;realpath_cache_ttl = 120 + +; Enables or disables the circular reference collector. +; https://php.net/zend.enable-gc +zend.enable_gc = On + +; If enabled, scripts may be written in encodings that are incompatible with +; the scanner. CP936, Big5, CP949 and Shift_JIS are the examples of such +; encodings. To use this feature, mbstring extension must be enabled. +;zend.multibyte = Off + +; Allows to set the default encoding for the scripts. This value will be used +; unless "declare(encoding=...)" directive appears at the top of the script. +; Only affects if zend.multibyte is set. +;zend.script_encoding = + +; Allows to include or exclude arguments from stack traces generated for exceptions. +; In production, it is recommended to turn this setting on to prohibit the output +; of sensitive information in stack traces +; Default Value: Off +; Development Value: Off +; Production Value: On +zend.exception_ignore_args = Off + +; Allows setting the maximum string length in an argument of a stringified stack trace +; to a value between 0 and 1000000. +; This has no effect when zend.exception_ignore_args is enabled. +; Default Value: 15 +; Development Value: 15 +; Production Value: 0 +zend.exception_string_param_max_len = 15 + +;;;;;;;;;;;;;;;;; +; Miscellaneous ; +;;;;;;;;;;;;;;;;; + +; Decides whether PHP may expose the fact that it is installed on the server +; (e.g. by adding its signature to the Web server header). It is no security +; threat in any way, but it makes it possible to determine whether you use PHP +; on your server or not. +; https://php.net/expose-php +expose_php = On + +;;;;;;;;;;;;;;;;;;; +; Resource Limits ; +;;;;;;;;;;;;;;;;;;; + +; Maximum execution time of each script, in seconds +; https://php.net/max-execution-time +; Note: This directive is hardcoded to 0 for the CLI SAPI +max_execution_time = 30 + +; Maximum amount of time each script may spend parsing request data. It's a good +; idea to limit this time on productions servers in order to eliminate unexpectedly +; long running scripts. +; Note: This directive is hardcoded to -1 for the CLI SAPI +; Default Value: -1 (Unlimited) +; Development Value: 60 (60 seconds) +; Production Value: 60 (60 seconds) +; https://php.net/max-input-time +max_input_time = 60 + +; Maximum input variable nesting level +; https://php.net/max-input-nesting-level +;max_input_nesting_level = 64 + +; How many GET/POST/COOKIE input variables may be accepted +;max_input_vars = 1000 + +; How many multipart body parts (combined input variable and file uploads) may +; be accepted. +; Default Value: -1 (Sum of max_input_vars and max_file_uploads) +;max_multipart_body_parts = 1500 + +; Maximum amount of memory a script may consume +; https://php.net/memory-limit +memory_limit = 255M + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +; Error handling and logging ; +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +; This directive informs PHP of which errors, warnings and notices you would like +; it to take action for. The recommended way of setting values for this +; directive is through the use of the error level constants and bitwise +; operators. The error level constants are below here for convenience as well as +; some common settings and their meanings. +; By default, PHP is set to take action on all errors, notices and warnings EXCEPT +; those related to E_NOTICE and E_STRICT, which together cover best practices and +; recommended coding standards in PHP. For performance reasons, this is the +; recommend error reporting setting. Your production server shouldn't be wasting +; resources complaining about best practices and coding standards. That's what +; development servers and development settings are for. +; Note: The php.ini-development file has this setting as E_ALL. This +; means it pretty much reports everything which is exactly what you want during +; development and early testing. +; +; Error Level Constants: +; E_ALL - All errors and warnings +; E_ERROR - fatal run-time errors +; E_RECOVERABLE_ERROR - almost fatal run-time errors +; E_WARNING - run-time warnings (non-fatal errors) +; E_PARSE - compile-time parse errors +; E_NOTICE - run-time notices (these are warnings which often result +; from a bug in your code, but it's possible that it was +; intentional (e.g., using an uninitialized variable and +; relying on the fact it is automatically initialized to an +; empty string) +; E_STRICT - run-time notices, enable to have PHP suggest changes +; to your code which will ensure the best interoperability +; and forward compatibility of your code +; E_CORE_ERROR - fatal errors that occur during PHP's initial startup +; E_CORE_WARNING - warnings (non-fatal errors) that occur during PHP's +; initial startup +; E_COMPILE_ERROR - fatal compile-time errors +; E_COMPILE_WARNING - compile-time warnings (non-fatal errors) +; E_USER_ERROR - user-generated error message +; E_USER_WARNING - user-generated warning message +; E_USER_NOTICE - user-generated notice message +; E_DEPRECATED - warn about code that will not work in future versions +; of PHP +; E_USER_DEPRECATED - user-generated deprecation warnings +; +; Common Values: +; E_ALL (Show all errors, warnings and notices including coding standards.) +; E_ALL & ~E_NOTICE (Show all errors, except for notices) +; E_ALL & ~E_NOTICE & ~E_STRICT (Show all errors, except for notices and coding standards warnings.) +; E_COMPILE_ERROR|E_RECOVERABLE_ERROR|E_ERROR|E_CORE_ERROR (Show only errors) +; Default Value: E_ALL +; Development Value: E_ALL +; Production Value: E_ALL & ~E_DEPRECATED & ~E_STRICT +; https://php.net/error-reporting +error_reporting = E_ALL + +; This directive controls whether or not and where PHP will output errors, +; notices and warnings too. Error output is very useful during development, but +; it could be very dangerous in production environments. Depending on the code +; which is triggering the error, sensitive information could potentially leak +; out of your application such as database usernames and passwords or worse. +; For production environments, we recommend logging errors rather than +; sending them to STDOUT. +; Possible Values: +; Off = Do not display any errors +; stderr = Display errors to STDERR (affects only CGI/CLI binaries!) +; On or stdout = Display errors to STDOUT +; Default Value: On +; Development Value: On +; Production Value: Off +; https://php.net/display-errors +display_errors = On + +; The display of errors which occur during PHP's startup sequence are handled +; separately from display_errors. We strongly recommend you set this to 'off' +; for production servers to avoid leaking configuration details. +; Default Value: On +; Development Value: On +; Production Value: Off +; https://php.net/display-startup-errors +display_startup_errors = On + +; Besides displaying errors, PHP can also log errors to locations such as a +; server-specific log, STDERR, or a location specified by the error_log +; directive found below. While errors should not be displayed on productions +; servers they should still be monitored and logging is a great way to do that. +; Default Value: Off +; Development Value: On +; Production Value: On +; https://php.net/log-errors +log_errors = On + +; Do not log repeated messages. Repeated errors must occur in same file on same +; line unless ignore_repeated_source is set true. +; https://php.net/ignore-repeated-errors +ignore_repeated_errors = Off + +; Ignore source of message when ignoring repeated messages. When this setting +; is On you will not log errors with repeated messages from different files or +; source lines. +; https://php.net/ignore-repeated-source +ignore_repeated_source = Off + +; If this parameter is set to Off, then memory leaks will not be shown (on +; stdout or in the log). This is only effective in a debug compile, and if +; error reporting includes E_WARNING in the allowed list +; https://php.net/report-memleaks +report_memleaks = On + +; This setting is off by default. +;report_zend_debug = 0 + +; Turn off normal error reporting and emit XML-RPC error XML +; https://php.net/xmlrpc-errors +;xmlrpc_errors = 0 + +; An XML-RPC faultCode +;xmlrpc_error_number = 0 + +; When PHP displays or logs an error, it has the capability of formatting the +; error message as HTML for easier reading. This directive controls whether +; the error message is formatted as HTML or not. +; Note: This directive is hardcoded to Off for the CLI SAPI +; https://php.net/html-errors +;html_errors = On + +; If html_errors is set to On *and* docref_root is not empty, then PHP +; produces clickable error messages that direct to a page describing the error +; or function causing the error in detail. +; You can download a copy of the PHP manual from https://php.net/docs +; and change docref_root to the base URL of your local copy including the +; leading '/'. You must also specify the file extension being used including +; the dot. PHP's default behavior is to leave these settings empty, in which +; case no links to documentation are generated. +; Note: Never use this feature for production boxes. +; https://php.net/docref-root +; Examples +;docref_root = "/phpmanual/" + +; https://php.net/docref-ext +;docref_ext = .html + +; String to output before an error message. PHP's default behavior is to leave +; this setting blank. +; https://php.net/error-prepend-string +; Example: +;error_prepend_string = "" + +; String to output after an error message. PHP's default behavior is to leave +; this setting blank. +; https://php.net/error-append-string +; Example: +;error_append_string = "" + +; Log errors to specified file. PHP's default behavior is to leave this value +; empty. +; https://php.net/error-log +; Example: +;error_log = php_errors.log +; Log errors to syslog (Event Log on Windows). +;error_log = syslog + +; The syslog ident is a string which is prepended to every message logged +; to syslog. Only used when error_log is set to syslog. +;syslog.ident = php + +; The syslog facility is used to specify what type of program is logging +; the message. Only used when error_log is set to syslog. +;syslog.facility = user + +; Set this to disable filtering control characters (the default). +; Some loggers only accept NVT-ASCII, others accept anything that's not +; control characters. If your logger accepts everything, then no filtering +; is needed at all. +; Allowed values are: +; ascii (all printable ASCII characters and NL) +; no-ctrl (all characters except control characters) +; all (all characters) +; raw (like "all", but messages are not split at newlines) +; https://php.net/syslog.filter +;syslog.filter = ascii + +;windows.show_crt_warning +; Default value: 0 +; Development value: 0 +; Production value: 0 + +;;;;;;;;;;;;;;;;; +; Data Handling ; +;;;;;;;;;;;;;;;;; + +; The separator used in PHP generated URLs to separate arguments. +; PHP's default setting is "&". +; https://php.net/arg-separator.output +; Example: +;arg_separator.output = "&" + +; List of separator(s) used by PHP to parse input URLs into variables. +; PHP's default setting is "&". +; NOTE: Every character in this directive is considered as separator! +; https://php.net/arg-separator.input +; Example: +;arg_separator.input = ";&" + +; This directive determines which super global arrays are registered when PHP +; starts up. G,P,C,E & S are abbreviations for the following respective super +; globals: GET, POST, COOKIE, ENV and SERVER. There is a performance penalty +; paid for the registration of these arrays and because ENV is not as commonly +; used as the others, ENV is not recommended on productions servers. You +; can still get access to the environment variables through getenv() should you +; need to. +; Default Value: "EGPCS" +; Development Value: "GPCS" +; Production Value: "GPCS"; +; https://php.net/variables-order +variables_order = "GPCS" + +; This directive determines which super global data (G,P & C) should be +; registered into the super global array REQUEST. If so, it also determines +; the order in which that data is registered. The values for this directive +; are specified in the same manner as the variables_order directive, +; EXCEPT one. Leaving this value empty will cause PHP to use the value set +; in the variables_order directive. It does not mean it will leave the super +; globals array REQUEST empty. +; Default Value: None +; Development Value: "GP" +; Production Value: "GP" +; https://php.net/request-order +request_order = "GP" + +; This directive determines whether PHP registers $argv & $argc each time it +; runs. $argv contains an array of all the arguments passed to PHP when a script +; is invoked. $argc contains an integer representing the number of arguments +; that were passed when the script was invoked. These arrays are extremely +; useful when running scripts from the command line. When this directive is +; enabled, registering these variables consumes CPU cycles and memory each time +; a script is executed. For performance reasons, this feature should be disabled +; on production servers. +; Note: This directive is hardcoded to On for the CLI SAPI +; Default Value: On +; Development Value: Off +; Production Value: Off +; https://php.net/register-argc-argv +register_argc_argv = Off + +; When enabled, the ENV, REQUEST and SERVER variables are created when they're +; first used (Just In Time) instead of when the script starts. If these +; variables are not used within a script, having this directive on will result +; in a performance gain. The PHP directive register_argc_argv must be disabled +; for this directive to have any effect. +; https://php.net/auto-globals-jit +auto_globals_jit = On + +; Whether PHP will read the POST data. +; This option is enabled by default. +; Most likely, you won't want to disable this option globally. It causes $_POST +; and $_FILES to always be empty; the only way you will be able to read the +; POST data will be through the php://input stream wrapper. This can be useful +; to proxy requests or to process the POST data in a memory efficient fashion. +; https://php.net/enable-post-data-reading +;enable_post_data_reading = Off + +; Maximum size of POST data that PHP will accept. +; Its value may be 0 to disable the limit. It is ignored if POST data reading +; is disabled through enable_post_data_reading. +; https://php.net/post-max-size +post_max_size = 30M + +; Automatically add files before PHP document. +; https://php.net/auto-prepend-file +auto_prepend_file = + +; Automatically add files after PHP document. +; https://php.net/auto-append-file +auto_append_file = + +; By default, PHP will output a media type using the Content-Type header. To +; disable this, simply set it to be empty. +; +; PHP's built-in default media type is set to text/html. +; https://php.net/default-mimetype +default_mimetype = "text/html" + +; PHP's default character set is set to UTF-8. +; https://php.net/default-charset +default_charset = "UTF-8" + +; PHP internal character encoding is set to empty. +; If empty, default_charset is used. +; https://php.net/internal-encoding +;internal_encoding = + +; PHP input character encoding is set to empty. +; If empty, default_charset is used. +; https://php.net/input-encoding +;input_encoding = + +; PHP output character encoding is set to empty. +; If empty, default_charset is used. +; See also output_buffer. +; https://php.net/output-encoding +;output_encoding = + +;;;;;;;;;;;;;;;;;;;;;;;;; +; Paths and Directories ; +;;;;;;;;;;;;;;;;;;;;;;;;; + +; UNIX: "/path1:/path2" +;include_path = ".:/php/includes" +; +; Windows: "\path1;\path2" +;include_path = ".;c:\php\includes" +; +; PHP's default setting for include_path is ".;/path/to/php/pear" +; https://php.net/include-path + +; The root of the PHP pages, used only if nonempty. +; if PHP was not compiled with FORCE_REDIRECT, you SHOULD set doc_root +; if you are running php as a CGI under any web server (other than IIS) +; see documentation for security issues. The alternate is to use the +; cgi.force_redirect configuration below +; https://php.net/doc-root +doc_root = + +; The directory under which PHP opens the script using /~username used only +; if nonempty. +; https://php.net/user-dir +user_dir = + +; Directory in which the loadable extensions (modules) reside. +; https://php.net/extension-dir +;extension_dir = "./" +; On windows: +;extension_dir = "ext" + +; Directory where the temporary files should be placed. +; Defaults to the system default (see sys_get_temp_dir) +;sys_temp_dir = "/tmp" + +; Whether or not to enable the dl() function. The dl() function does NOT work +; properly in multithreaded servers, such as IIS or Zeus, and is automatically +; disabled on them. +; https://php.net/enable-dl +enable_dl = Off + +; cgi.force_redirect is necessary to provide security running PHP as a CGI under +; most web servers. Left undefined, PHP turns this on by default. You can +; turn it off here AT YOUR OWN RISK +; **You CAN safely turn this off for IIS, in fact, you MUST.** +; https://php.net/cgi.force-redirect +;cgi.force_redirect = 1 + +; if cgi.nph is enabled it will force cgi to always sent Status: 200 with +; every request. PHP's default behavior is to disable this feature. +;cgi.nph = 1 + +; if cgi.force_redirect is turned on, and you are not running under Apache or Netscape +; (iPlanet) web servers, you MAY need to set an environment variable name that PHP +; will look for to know it is OK to continue execution. Setting this variable MAY +; cause security issues, KNOW WHAT YOU ARE DOING FIRST. +; https://php.net/cgi.redirect-status-env +;cgi.redirect_status_env = + +; cgi.fix_pathinfo provides *real* PATH_INFO/PATH_TRANSLATED support for CGI. PHP's +; previous behaviour was to set PATH_TRANSLATED to SCRIPT_FILENAME, and to not grok +; what PATH_INFO is. For more information on PATH_INFO, see the cgi specs. Setting +; this to 1 will cause PHP CGI to fix its paths to conform to the spec. A setting +; of zero causes PHP to behave as before. Default is 1. You should fix your scripts +; to use SCRIPT_FILENAME rather than PATH_TRANSLATED. +; https://php.net/cgi.fix-pathinfo +;cgi.fix_pathinfo=1 + +; if cgi.discard_path is enabled, the PHP CGI binary can safely be placed outside +; of the web tree and people will not be able to circumvent .htaccess security. +;cgi.discard_path=1 + +; FastCGI under IIS supports the ability to impersonate +; security tokens of the calling client. This allows IIS to define the +; security context that the request runs under. mod_fastcgi under Apache +; does not currently support this feature (03/17/2002) +; Set to 1 if running under IIS. Default is zero. +; https://php.net/fastcgi.impersonate +;fastcgi.impersonate = 1 + +; Disable logging through FastCGI connection. PHP's default behavior is to enable +; this feature. +;fastcgi.logging = 0 + +; cgi.rfc2616_headers configuration option tells PHP what type of headers to +; use when sending HTTP response code. If set to 0, PHP sends Status: header that +; is supported by Apache. When this option is set to 1, PHP will send +; RFC2616 compliant header. +; Default is zero. +; https://php.net/cgi.rfc2616-headers +;cgi.rfc2616_headers = 0 + +; cgi.check_shebang_line controls whether CGI PHP checks for line starting with #! +; (shebang) at the top of the running script. This line might be needed if the +; script support running both as stand-alone script and via PHP CGI<. PHP in CGI +; mode skips this line and ignores its content if this directive is turned on. +; https://php.net/cgi.check-shebang-line +;cgi.check_shebang_line=1 + +;;;;;;;;;;;;;;;; +; File Uploads ; +;;;;;;;;;;;;;;;; + +; Whether to allow HTTP file uploads. +; https://php.net/file-uploads +file_uploads = On + +; Temporary directory for HTTP uploaded files (will use system default if not +; specified). +; https://php.net/upload-tmp-dir +;upload_tmp_dir = + +; Maximum allowed size for uploaded files. +; https://php.net/upload-max-filesize +upload_max_filesize = 30M + +; Maximum number of files that can be uploaded via a single request +max_file_uploads = 20 + +;;;;;;;;;;;;;;;;;; +; Fopen wrappers ; +;;;;;;;;;;;;;;;;;; + +; Whether to allow the treatment of URLs (like http:// or ftp://) as files. +; https://php.net/allow-url-fopen +allow_url_fopen = On + +; Whether to allow include/require to open URLs (like https:// or ftp://) as files. +; https://php.net/allow-url-include +allow_url_include = Off + +; Define the anonymous ftp password (your email address). PHP's default setting +; for this is empty. +; https://php.net/from +;from="john@doe.com" + +; Define the User-Agent string. PHP's default setting for this is empty. +; https://php.net/user-agent +;user_agent="PHP" + +; Default timeout for socket based streams (seconds) +; https://php.net/default-socket-timeout +default_socket_timeout = 60 + +; If your scripts have to deal with files from Macintosh systems, +; or you are running on a Mac and need to deal with files from +; unix or win32 systems, setting this flag will cause PHP to +; automatically detect the EOL character in those files so that +; fgets() and file() will work regardless of the source of the file. +; https://php.net/auto-detect-line-endings +;auto_detect_line_endings = Off + +;;;;;;;;;;;;;;;;;;;;;; +; Dynamic Extensions ; +;;;;;;;;;;;;;;;;;;;;;; + +; If you wish to have an extension loaded automatically, use the following +; syntax: +; +; extension=modulename +; +; For example: +; +; extension=mysqli +; +; When the extension library to load is not located in the default extension +; directory, You may specify an absolute path to the library file: +; +; extension=/path/to/extension/mysqli.so +; +; Note : The syntax used in previous PHP versions ('extension=.so' and +; 'extension='php_.dll') is supported for legacy reasons and may be +; deprecated in a future PHP major version. So, when it is possible, please +; move to the new ('extension=) syntax. +; +; Notes for Windows environments : +; +; - Many DLL files are located in the ext/ +; extension folders as well as the separate PECL DLL download. +; Be sure to appropriately set the extension_dir directive. +; +;extension=bz2 + +; The ldap extension must be before curl if OpenSSL 1.0.2 and OpenLDAP is used +; otherwise it results in segfault when unloading after using SASL. +; See https://github.com/php/php-src/issues/8620 for more info. +;extension=ldap + +;extension=curl +;extension=ffi +;extension=ftp +;extension=fileinfo +;extension=gd +;extension=gettext +;extension=gmp +;extension=intl +;extension=imap +;extension=mbstring +;extension=exif ; Must be after mbstring as it depends on it +;extension=mysqli +;extension=oci8_12c ; Use with Oracle Database 12c Instant Client +;extension=oci8_19 ; Use with Oracle Database 19 Instant Client +;extension=odbc +;extension=openssl +;extension=pdo_firebird +;extension=pdo_mysql +;extension=pdo_oci +;extension=pdo_odbc +;extension=pdo_pgsql +;extension=pdo_sqlite +;extension=pgsql +;extension=shmop + +; The MIBS data available in the PHP distribution must be installed. +; See https://www.php.net/manual/en/snmp.installation.php +;extension=snmp + +;extension=soap +;extension=sockets +;extension=sodium +;extension=sqlite3 +;extension=tidy +;extension=xsl +;extension=zip + +;zend_extension=opcache + +;;;;;;;;;;;;;;;;;;; +; Module Settings ; +;;;;;;;;;;;;;;;;;;; + +[CLI Server] +; Whether the CLI web server uses ANSI color coding in its terminal output. +cli_server.color = On + +[Date] +; Defines the default timezone used by the date functions +; https://php.net/date.timezone +;date.timezone = + +; https://php.net/date.default-latitude +;date.default_latitude = 31.7667 + +; https://php.net/date.default-longitude +;date.default_longitude = 35.2333 + +; https://php.net/date.sunrise-zenith +;date.sunrise_zenith = 90.833333 + +; https://php.net/date.sunset-zenith +;date.sunset_zenith = 90.833333 + +[filter] +; https://php.net/filter.default +;filter.default = unsafe_raw + +; https://php.net/filter.default-flags +;filter.default_flags = + +[iconv] +; Use of this INI entry is deprecated, use global input_encoding instead. +; If empty, default_charset or input_encoding or iconv.input_encoding is used. +; The precedence is: default_charset < input_encoding < iconv.input_encoding +;iconv.input_encoding = + +; Use of this INI entry is deprecated, use global internal_encoding instead. +; If empty, default_charset or internal_encoding or iconv.internal_encoding is used. +; The precedence is: default_charset < internal_encoding < iconv.internal_encoding +;iconv.internal_encoding = + +; Use of this INI entry is deprecated, use global output_encoding instead. +; If empty, default_charset or output_encoding or iconv.output_encoding is used. +; The precedence is: default_charset < output_encoding < iconv.output_encoding +; To use an output encoding conversion, iconv's output handler must be set +; otherwise output encoding conversion cannot be performed. +;iconv.output_encoding = + +[imap] +; rsh/ssh logins are disabled by default. Use this INI entry if you want to +; enable them. Note that the IMAP library does not filter mailbox names before +; passing them to rsh/ssh command, thus passing untrusted data to this function +; with rsh/ssh enabled is insecure. +;imap.enable_insecure_rsh=0 + +[intl] +;intl.default_locale = +; This directive allows you to produce PHP errors when some error +; happens within intl functions. The value is the level of the error produced. +; Default is 0, which does not produce any errors. +;intl.error_level = E_WARNING +;intl.use_exceptions = 0 + +[sqlite3] +; Directory pointing to SQLite3 extensions +; https://php.net/sqlite3.extension-dir +;sqlite3.extension_dir = + +; SQLite defensive mode flag (only available from SQLite 3.26+) +; When the defensive flag is enabled, language features that allow ordinary +; SQL to deliberately corrupt the database file are disabled. This forbids +; writing directly to the schema, shadow tables (eg. FTS data tables), or +; the sqlite_dbpage virtual table. +; https://www.sqlite.org/c3ref/c_dbconfig_defensive.html +; (for older SQLite versions, this flag has no use) +;sqlite3.defensive = 1 + +[Pcre] +; PCRE library backtracking limit. +; https://php.net/pcre.backtrack-limit +;pcre.backtrack_limit=100000 + +; PCRE library recursion limit. +; Please note that if you set this value to a high number you may consume all +; the available process stack and eventually crash PHP (due to reaching the +; stack size limit imposed by the Operating System). +; https://php.net/pcre.recursion-limit +;pcre.recursion_limit=100000 + +; Enables or disables JIT compilation of patterns. This requires the PCRE +; library to be compiled with JIT support. +;pcre.jit=1 + +[Pdo] +; Whether to pool ODBC connections. Can be one of "strict", "relaxed" or "off" +; https://php.net/pdo-odbc.connection-pooling +;pdo_odbc.connection_pooling=strict + +[Pdo_mysql] +; Default socket name for local MySQL connects. If empty, uses the built-in +; MySQL defaults. +pdo_mysql.default_socket= + +[Phar] +; https://php.net/phar.readonly +;phar.readonly = On + +; https://php.net/phar.require-hash +;phar.require_hash = On + +;phar.cache_list = + +[mail function] +; For Win32 only. +; https://php.net/smtp +SMTP = localhost +; https://php.net/smtp-port +smtp_port = 25 + +; For Win32 only. +; https://php.net/sendmail-from +;sendmail_from = me@example.com + +; For Unix only. You may supply arguments as well (default: "sendmail -t -i"). +; https://php.net/sendmail-path +;sendmail_path = + +; Force the addition of the specified parameters to be passed as extra parameters +; to the sendmail binary. These parameters will always replace the value of +; the 5th parameter to mail(). +;mail.force_extra_parameters = + +; Add X-PHP-Originating-Script: that will include uid of the script followed by the filename +mail.add_x_header = Off + +; Use mixed LF and CRLF line separators to keep compatibility with some +; RFC 2822 non conformant MTA. +mail.mixed_lf_and_crlf = Off + +; The path to a log file that will log all mail() calls. Log entries include +; the full path of the script, line number, To address and headers. +;mail.log = +; Log mail to syslog (Event Log on Windows). +;mail.log = syslog + +[ODBC] +; https://php.net/odbc.default-db +;odbc.default_db = Not yet implemented + +; https://php.net/odbc.default-user +;odbc.default_user = Not yet implemented + +; https://php.net/odbc.default-pw +;odbc.default_pw = Not yet implemented + +; Controls the ODBC cursor model. +; Default: SQL_CURSOR_STATIC (default). +;odbc.default_cursortype + +; Allow or prevent persistent links. +; https://php.net/odbc.allow-persistent +odbc.allow_persistent = On + +; Check that a connection is still valid before reuse. +; https://php.net/odbc.check-persistent +odbc.check_persistent = On + +; Maximum number of persistent links. -1 means no limit. +; https://php.net/odbc.max-persistent +odbc.max_persistent = -1 + +; Maximum number of links (persistent + non-persistent). -1 means no limit. +; https://php.net/odbc.max-links +odbc.max_links = -1 + +; Handling of LONG fields. Returns number of bytes to variables. 0 means +; passthru. +; https://php.net/odbc.defaultlrl +odbc.defaultlrl = 4096 + +; Handling of binary data. 0 means passthru, 1 return as is, 2 convert to char. +; See the documentation on odbc_binmode and odbc_longreadlen for an explanation +; of odbc.defaultlrl and odbc.defaultbinmode +; https://php.net/odbc.defaultbinmode +odbc.defaultbinmode = 1 + +[MySQLi] + +; Maximum number of persistent links. -1 means no limit. +; https://php.net/mysqli.max-persistent +mysqli.max_persistent = -1 + +; Allow accessing, from PHP's perspective, local files with LOAD DATA statements +; https://php.net/mysqli.allow_local_infile +;mysqli.allow_local_infile = On + +; It allows the user to specify a folder where files that can be sent via LOAD DATA +; LOCAL can exist. It is ignored if mysqli.allow_local_infile is enabled. +;mysqli.local_infile_directory = + +; Allow or prevent persistent links. +; https://php.net/mysqli.allow-persistent +mysqli.allow_persistent = On + +; Maximum number of links. -1 means no limit. +; https://php.net/mysqli.max-links +mysqli.max_links = -1 + +; Default port number for mysqli_connect(). If unset, mysqli_connect() will use +; the $MYSQL_TCP_PORT or the mysql-tcp entry in /etc/services or the +; compile-time value defined MYSQL_PORT (in that order). Win32 will only look +; at MYSQL_PORT. +; https://php.net/mysqli.default-port +mysqli.default_port = 3306 + +; Default socket name for local MySQL connects. If empty, uses the built-in +; MySQL defaults. +; https://php.net/mysqli.default-socket +mysqli.default_socket = + +; Default host for mysqli_connect() (doesn't apply in safe mode). +; https://php.net/mysqli.default-host +mysqli.default_host = + +; Default user for mysqli_connect() (doesn't apply in safe mode). +; https://php.net/mysqli.default-user +mysqli.default_user = + +; Default password for mysqli_connect() (doesn't apply in safe mode). +; Note that this is generally a *bad* idea to store passwords in this file. +; *Any* user with PHP access can run 'echo get_cfg_var("mysqli.default_pw") +; and reveal this password! And of course, any users with read access to this +; file will be able to reveal the password as well. +; https://php.net/mysqli.default-pw +mysqli.default_pw = + +; If this option is enabled, closing a persistent connection will rollback +; any pending transactions of this connection, before it is put back +; into the persistent connection pool. +;mysqli.rollback_on_cached_plink = Off + +[mysqlnd] +; Enable / Disable collection of general statistics by mysqlnd which can be +; used to tune and monitor MySQL operations. +mysqlnd.collect_statistics = On + +; Enable / Disable collection of memory usage statistics by mysqlnd which can be +; used to tune and monitor MySQL operations. +mysqlnd.collect_memory_statistics = On + +; Records communication from all extensions using mysqlnd to the specified log +; file. +; https://php.net/mysqlnd.debug +;mysqlnd.debug = + +; Defines which queries will be logged. +;mysqlnd.log_mask = 0 + +; Default size of the mysqlnd memory pool, which is used by result sets. +;mysqlnd.mempool_default_size = 16000 + +; Size of a pre-allocated buffer used when sending commands to MySQL in bytes. +;mysqlnd.net_cmd_buffer_size = 2048 + +; Size of a pre-allocated buffer used for reading data sent by the server in +; bytes. +;mysqlnd.net_read_buffer_size = 32768 + +; Timeout for network requests in seconds. +;mysqlnd.net_read_timeout = 31536000 + +; SHA-256 Authentication Plugin related. File with the MySQL server public RSA +; key. +;mysqlnd.sha256_server_public_key = + +[OCI8] + +; Connection: Enables privileged connections using external +; credentials (OCI_SYSOPER, OCI_SYSDBA) +; https://php.net/oci8.privileged-connect +;oci8.privileged_connect = Off + +; Connection: The maximum number of persistent OCI8 connections per +; process. Using -1 means no limit. +; https://php.net/oci8.max-persistent +;oci8.max_persistent = -1 + +; Connection: The maximum number of seconds a process is allowed to +; maintain an idle persistent connection. Using -1 means idle +; persistent connections will be maintained forever. +; https://php.net/oci8.persistent-timeout +;oci8.persistent_timeout = -1 + +; Connection: The number of seconds that must pass before issuing a +; ping during oci_pconnect() to check the connection validity. When +; set to 0, each oci_pconnect() will cause a ping. Using -1 disables +; pings completely. +; https://php.net/oci8.ping-interval +;oci8.ping_interval = 60 + +; Connection: Set this to a user chosen connection class to be used +; for all pooled server requests with Oracle Database Resident +; Connection Pooling (DRCP). To use DRCP, this value should be set to +; the same string for all web servers running the same application, +; the database pool must be configured, and the connection string must +; specify to use a pooled server. +;oci8.connection_class = + +; High Availability: Using On lets PHP receive Fast Application +; Notification (FAN) events generated when a database node fails. The +; database must also be configured to post FAN events. +;oci8.events = Off + +; Tuning: This option enables statement caching, and specifies how +; many statements to cache. Using 0 disables statement caching. +; https://php.net/oci8.statement-cache-size +;oci8.statement_cache_size = 20 + +; Tuning: Enables row prefetching and sets the default number of +; rows that will be fetched automatically after statement execution. +; https://php.net/oci8.default-prefetch +;oci8.default_prefetch = 100 + +; Tuning: Sets the amount of LOB data that is internally returned from +; Oracle Database when an Oracle LOB locator is initially retrieved as +; part of a query. Setting this can improve performance by reducing +; round-trips. +; https://php.net/oci8.prefetch-lob-size +; oci8.prefetch_lob_size = 0 + +; Compatibility. Using On means oci_close() will not close +; oci_connect() and oci_new_connect() connections. +; https://php.net/oci8.old-oci-close-semantics +;oci8.old_oci_close_semantics = Off + +[PostgreSQL] +; Allow or prevent persistent links. +; https://php.net/pgsql.allow-persistent +pgsql.allow_persistent = On + +; Detect broken persistent links always with pg_pconnect(). +; Auto reset feature requires a little overheads. +; https://php.net/pgsql.auto-reset-persistent +pgsql.auto_reset_persistent = Off + +; Maximum number of persistent links. -1 means no limit. +; https://php.net/pgsql.max-persistent +pgsql.max_persistent = -1 + +; Maximum number of links (persistent+non persistent). -1 means no limit. +; https://php.net/pgsql.max-links +pgsql.max_links = -1 + +; Ignore PostgreSQL backends Notice message or not. +; Notice message logging require a little overheads. +; https://php.net/pgsql.ignore-notice +pgsql.ignore_notice = 0 + +; Log PostgreSQL backends Notice message or not. +; Unless pgsql.ignore_notice=0, module cannot log notice message. +; https://php.net/pgsql.log-notice +pgsql.log_notice = 0 + +[bcmath] +; Number of decimal digits for all bcmath functions. +; https://php.net/bcmath.scale +bcmath.scale = 0 + +[browscap] +; https://php.net/browscap +;browscap = extra/browscap.ini + +[Session] +; Handler used to store/retrieve data. +; https://php.net/session.save-handler +session.save_handler = files + +; Argument passed to save_handler. In the case of files, this is the path +; where data files are stored. Note: Windows users have to change this +; variable in order to use PHP's session functions. +; +; The path can be defined as: +; +; session.save_path = "N;/path" +; +; where N is an integer. Instead of storing all the session files in +; /path, what this will do is use subdirectories N-levels deep, and +; store the session data in those directories. This is useful if +; your OS has problems with many files in one directory, and is +; a more efficient layout for servers that handle many sessions. +; +; NOTE 1: PHP will not create this directory structure automatically. +; You can use the script in the ext/session dir for that purpose. +; NOTE 2: See the section on garbage collection below if you choose to +; use subdirectories for session storage +; +; The file storage module creates files using mode 600 by default. +; You can change that by using +; +; session.save_path = "N;MODE;/path" +; +; where MODE is the octal representation of the mode. Note that this +; does not overwrite the process's umask. +; https://php.net/session.save-path +;session.save_path = "/tmp" + +; Whether to use strict session mode. +; Strict session mode does not accept an uninitialized session ID, and +; regenerates the session ID if the browser sends an uninitialized session ID. +; Strict mode protects applications from session fixation via a session adoption +; vulnerability. It is disabled by default for maximum compatibility, but +; enabling it is encouraged. +; https://wiki.php.net/rfc/strict_sessions +session.use_strict_mode = 0 + +; Whether to use cookies. +; https://php.net/session.use-cookies +session.use_cookies = 1 + +; https://php.net/session.cookie-secure +;session.cookie_secure = + +; This option forces PHP to fetch and use a cookie for storing and maintaining +; the session id. We encourage this operation as it's very helpful in combating +; session hijacking when not specifying and managing your own session id. It is +; not the be-all and end-all of session hijacking defense, but it's a good start. +; https://php.net/session.use-only-cookies +session.use_only_cookies = 1 + +; Name of the session (used as cookie name). +; https://php.net/session.name +session.name = PHPSESSID + +; Initialize session on request startup. +; https://php.net/session.auto-start +session.auto_start = 0 + +; Lifetime in seconds of cookie or, if 0, until browser is restarted. +; https://php.net/session.cookie-lifetime +session.cookie_lifetime = 0 + +; The path for which the cookie is valid. +; https://php.net/session.cookie-path +session.cookie_path = / + +; The domain for which the cookie is valid. +; https://php.net/session.cookie-domain +session.cookie_domain = + +; Whether or not to add the httpOnly flag to the cookie, which makes it +; inaccessible to browser scripting languages such as JavaScript. +; https://php.net/session.cookie-httponly +session.cookie_httponly = + +; Add SameSite attribute to cookie to help mitigate Cross-Site Request Forgery (CSRF/XSRF) +; Current valid values are "Strict", "Lax" or "None". When using "None", +; make sure to include the quotes, as `none` is interpreted like `false` in ini files. +; https://tools.ietf.org/html/draft-west-first-party-cookies-07 +session.cookie_samesite = + +; Handler used to serialize data. php is the standard serializer of PHP. +; https://php.net/session.serialize-handler +session.serialize_handler = php + +; Defines the probability that the 'garbage collection' process is started on every +; session initialization. The probability is calculated by using gc_probability/gc_divisor, +; e.g. 1/100 means there is a 1% chance that the GC process starts on each request. +; Default Value: 1 +; Development Value: 1 +; Production Value: 1 +; https://php.net/session.gc-probability +session.gc_probability = 1 + +; Defines the probability that the 'garbage collection' process is started on every +; session initialization. The probability is calculated by using gc_probability/gc_divisor, +; e.g. 1/100 means there is a 1% chance that the GC process starts on each request. +; For high volume production servers, using a value of 1000 is a more efficient approach. +; Default Value: 100 +; Development Value: 1000 +; Production Value: 1000 +; https://php.net/session.gc-divisor +session.gc_divisor = 1000 + +; After this number of seconds, stored data will be seen as 'garbage' and +; cleaned up by the garbage collection process. +; https://php.net/session.gc-maxlifetime +session.gc_maxlifetime = 1440 + +; NOTE: If you are using the subdirectory option for storing session files +; (see session.save_path above), then garbage collection does *not* +; happen automatically. You will need to do your own garbage +; collection through a shell script, cron entry, or some other method. +; For example, the following script is the equivalent of setting +; session.gc_maxlifetime to 1440 (1440 seconds = 24 minutes): +; find /path/to/sessions -cmin +24 -type f | xargs rm + +; Check HTTP Referer to invalidate externally stored URLs containing ids. +; HTTP_REFERER has to contain this substring for the session to be +; considered as valid. +; https://php.net/session.referer-check +session.referer_check = + +; Set to {nocache,private,public,} to determine HTTP caching aspects +; or leave this empty to avoid sending anti-caching headers. +; https://php.net/session.cache-limiter +session.cache_limiter = nocache + +; Document expires after n minutes. +; https://php.net/session.cache-expire +session.cache_expire = 180 + +; trans sid support is disabled by default. +; Use of trans sid may risk your users' security. +; Use this option with caution. +; - User may send URL contains active session ID +; to other person via. email/irc/etc. +; - URL that contains active session ID may be stored +; in publicly accessible computer. +; - User may access your site with the same session ID +; always using URL stored in browser's history or bookmarks. +; https://php.net/session.use-trans-sid +session.use_trans_sid = 0 + +; Set session ID character length. This value could be between 22 to 256. +; Shorter length than default is supported only for compatibility reason. +; Users should use 32 or more chars. +; https://php.net/session.sid-length +; Default Value: 32 +; Development Value: 26 +; Production Value: 26 +session.sid_length = 26 + +; The URL rewriter will look for URLs in a defined set of HTML tags. +;
is special; if you include them here, the rewriter will +; add a hidden field with the info which is otherwise appended +; to URLs. tag's action attribute URL will not be modified +; unless it is specified. +; Note that all valid entries require a "=", even if no value follows. +; Default Value: "a=href,area=href,frame=src,form=" +; Development Value: "a=href,area=href,frame=src,form=" +; Production Value: "a=href,area=href,frame=src,form=" +; https://php.net/url-rewriter.tags +session.trans_sid_tags = "a=href,area=href,frame=src,form=" + +; URL rewriter does not rewrite absolute URLs by default. +; To enable rewrites for absolute paths, target hosts must be specified +; at RUNTIME. i.e. use ini_set() +; tags is special. PHP will check action attribute's URL regardless +; of session.trans_sid_tags setting. +; If no host is defined, HTTP_HOST will be used for allowed host. +; Example value: php.net,www.php.net,wiki.php.net +; Use "," for multiple hosts. No spaces are allowed. +; Default Value: "" +; Development Value: "" +; Production Value: "" +;session.trans_sid_hosts="" + +; Define how many bits are stored in each character when converting +; the binary hash data to something readable. +; Possible values: +; 4 (4 bits: 0-9, a-f) +; 5 (5 bits: 0-9, a-v) +; 6 (6 bits: 0-9, a-z, A-Z, "-", ",") +; Default Value: 4 +; Development Value: 5 +; Production Value: 5 +; https://php.net/session.hash-bits-per-character +session.sid_bits_per_character = 5 + +; Enable upload progress tracking in $_SESSION +; Default Value: On +; Development Value: On +; Production Value: On +; https://php.net/session.upload-progress.enabled +;session.upload_progress.enabled = On + +; Cleanup the progress information as soon as all POST data has been read +; (i.e. upload completed). +; Default Value: On +; Development Value: On +; Production Value: On +; https://php.net/session.upload-progress.cleanup +;session.upload_progress.cleanup = On + +; A prefix used for the upload progress key in $_SESSION +; Default Value: "upload_progress_" +; Development Value: "upload_progress_" +; Production Value: "upload_progress_" +; https://php.net/session.upload-progress.prefix +;session.upload_progress.prefix = "upload_progress_" + +; The index name (concatenated with the prefix) in $_SESSION +; containing the upload progress information +; Default Value: "PHP_SESSION_UPLOAD_PROGRESS" +; Development Value: "PHP_SESSION_UPLOAD_PROGRESS" +; Production Value: "PHP_SESSION_UPLOAD_PROGRESS" +; https://php.net/session.upload-progress.name +;session.upload_progress.name = "PHP_SESSION_UPLOAD_PROGRESS" + +; How frequently the upload progress should be updated. +; Given either in percentages (per-file), or in bytes +; Default Value: "1%" +; Development Value: "1%" +; Production Value: "1%" +; https://php.net/session.upload-progress.freq +;session.upload_progress.freq = "1%" + +; The minimum delay between updates, in seconds +; Default Value: 1 +; Development Value: 1 +; Production Value: 1 +; https://php.net/session.upload-progress.min-freq +;session.upload_progress.min_freq = "1" + +; Only write session data when session data is changed. Enabled by default. +; https://php.net/session.lazy-write +;session.lazy_write = On + +[Assertion] +; Switch whether to compile assertions at all (to have no overhead at run-time) +; -1: Do not compile at all +; 0: Jump over assertion at run-time +; 1: Execute assertions +; Changing from or to a negative value is only possible in php.ini! (For turning assertions on and off at run-time, see assert.active, when zend.assertions = 1) +; Default Value: 1 +; Development Value: 1 +; Production Value: -1 +; https://php.net/zend.assertions +zend.assertions = 1 + +; Assert(expr); active by default. +; https://php.net/assert.active +;assert.active = On + +; Throw an AssertionError on failed assertions +; https://php.net/assert.exception +;assert.exception = On + +; Issue a PHP warning for each failed assertion. (Overridden by assert.exception if active) +; https://php.net/assert.warning +;assert.warning = On + +; Don't bail out by default. +; https://php.net/assert.bail +;assert.bail = Off + +; User-function to be called if an assertion fails. +; https://php.net/assert.callback +;assert.callback = 0 + +[COM] +; path to a file containing GUIDs, IIDs or filenames of files with TypeLibs +; https://php.net/com.typelib-file +;com.typelib_file = + +; allow Distributed-COM calls +; https://php.net/com.allow-dcom +;com.allow_dcom = true + +; autoregister constants of a component's typelib on com_load() +; https://php.net/com.autoregister-typelib +;com.autoregister_typelib = true + +; register constants casesensitive +; https://php.net/com.autoregister-casesensitive +;com.autoregister_casesensitive = false + +; show warnings on duplicate constant registrations +; https://php.net/com.autoregister-verbose +;com.autoregister_verbose = true + +; The default character set code-page to use when passing strings to and from COM objects. +; Default: system ANSI code page +;com.code_page= + +; The version of the .NET framework to use. The value of the setting are the first three parts +; of the framework's version number, separated by dots, and prefixed with "v", e.g. "v4.0.30319". +;com.dotnet_version= + +[mbstring] +; language for internal character representation. +; This affects mb_send_mail() and mbstring.detect_order. +; https://php.net/mbstring.language +;mbstring.language = Japanese + +; Use of this INI entry is deprecated, use global internal_encoding instead. +; internal/script encoding. +; Some encoding cannot work as internal encoding. (e.g. SJIS, BIG5, ISO-2022-*) +; If empty, default_charset or internal_encoding or iconv.internal_encoding is used. +; The precedence is: default_charset < internal_encoding < iconv.internal_encoding +;mbstring.internal_encoding = + +; Use of this INI entry is deprecated, use global input_encoding instead. +; http input encoding. +; mbstring.encoding_translation = On is needed to use this setting. +; If empty, default_charset or input_encoding or mbstring.input is used. +; The precedence is: default_charset < input_encoding < mbstring.http_input +; https://php.net/mbstring.http-input +;mbstring.http_input = + +; Use of this INI entry is deprecated, use global output_encoding instead. +; http output encoding. +; mb_output_handler must be registered as output buffer to function. +; If empty, default_charset or output_encoding or mbstring.http_output is used. +; The precedence is: default_charset < output_encoding < mbstring.http_output +; To use an output encoding conversion, mbstring's output handler must be set +; otherwise output encoding conversion cannot be performed. +; https://php.net/mbstring.http-output +;mbstring.http_output = + +; enable automatic encoding translation according to +; mbstring.internal_encoding setting. Input chars are +; converted to internal encoding by setting this to On. +; Note: Do _not_ use automatic encoding translation for +; portable libs/applications. +; https://php.net/mbstring.encoding-translation +;mbstring.encoding_translation = Off + +; automatic encoding detection order. +; "auto" detect order is changed according to mbstring.language +; https://php.net/mbstring.detect-order +;mbstring.detect_order = auto + +; substitute_character used when character cannot be converted +; one from another +; https://php.net/mbstring.substitute-character +;mbstring.substitute_character = none + +; Enable strict encoding detection. +;mbstring.strict_detection = Off + +; This directive specifies the regex pattern of content types for which mb_output_handler() +; is activated. +; Default: mbstring.http_output_conv_mimetypes=^(text/|application/xhtml\+xml) +;mbstring.http_output_conv_mimetypes= + +; This directive specifies maximum stack depth for mbstring regular expressions. It is similar +; to the pcre.recursion_limit for PCRE. +;mbstring.regex_stack_limit=100000 + +; This directive specifies maximum retry count for mbstring regular expressions. It is similar +; to the pcre.backtrack_limit for PCRE. +;mbstring.regex_retry_limit=1000000 + +[gd] +; Tell the jpeg decode to ignore warnings and try to create +; a gd image. The warning will then be displayed as notices +; disabled by default +; https://php.net/gd.jpeg-ignore-warning +;gd.jpeg_ignore_warning = 1 + +[exif] +; Exif UNICODE user comments are handled as UCS-2BE/UCS-2LE and JIS as JIS. +; With mbstring support this will automatically be converted into the encoding +; given by corresponding encode setting. When empty mbstring.internal_encoding +; is used. For the decode settings you can distinguish between motorola and +; intel byte order. A decode setting cannot be empty. +; https://php.net/exif.encode-unicode +;exif.encode_unicode = ISO-8859-15 + +; https://php.net/exif.decode-unicode-motorola +;exif.decode_unicode_motorola = UCS-2BE + +; https://php.net/exif.decode-unicode-intel +;exif.decode_unicode_intel = UCS-2LE + +; https://php.net/exif.encode-jis +;exif.encode_jis = + +; https://php.net/exif.decode-jis-motorola +;exif.decode_jis_motorola = JIS + +; https://php.net/exif.decode-jis-intel +;exif.decode_jis_intel = JIS + +[Tidy] +; The path to a default tidy configuration file to use when using tidy +; https://php.net/tidy.default-config +;tidy.default_config = /usr/local/lib/php/default.tcfg + +; Should tidy clean and repair output automatically? +; WARNING: Do not use this option if you are generating non-html content +; such as dynamic images +; https://php.net/tidy.clean-output +tidy.clean_output = Off + +[soap] +; Enables or disables WSDL caching feature. +; https://php.net/soap.wsdl-cache-enabled +soap.wsdl_cache_enabled=1 + +; Sets the directory name where SOAP extension will put cache files. +; https://php.net/soap.wsdl-cache-dir +soap.wsdl_cache_dir="/tmp" + +; (time to live) Sets the number of second while cached file will be used +; instead of original one. +; https://php.net/soap.wsdl-cache-ttl +soap.wsdl_cache_ttl=86400 + +; Sets the size of the cache limit. (Max. number of WSDL files to cache) +soap.wsdl_cache_limit = 5 + +[sysvshm] +; A default size of the shared memory segment +;sysvshm.init_mem = 10000 + +[ldap] +; Sets the maximum number of open links or -1 for unlimited. +ldap.max_links = -1 + +[dba] +;dba.default_handler= + +[opcache] +; Determines if Zend OPCache is enabled +;opcache.enable=1 + +; Determines if Zend OPCache is enabled for the CLI version of PHP +;opcache.enable_cli=0 + +; The OPcache shared memory storage size. +;opcache.memory_consumption=128 + +; The amount of memory for interned strings in Mbytes. +;opcache.interned_strings_buffer=8 + +; The maximum number of keys (scripts) in the OPcache hash table. +; Only numbers between 200 and 1000000 are allowed. +;opcache.max_accelerated_files=10000 + +; The maximum percentage of "wasted" memory until a restart is scheduled. +;opcache.max_wasted_percentage=5 + +; When this directive is enabled, the OPcache appends the current working +; directory to the script key, thus eliminating possible collisions between +; files with the same name (basename). Disabling the directive improves +; performance, but may break existing applications. +;opcache.use_cwd=1 + +; When disabled, you must reset the OPcache manually or restart the +; webserver for changes to the filesystem to take effect. +;opcache.validate_timestamps=1 + +; How often (in seconds) to check file timestamps for changes to the shared +; memory storage allocation. ("1" means validate once per second, but only +; once per request. "0" means always validate) +;opcache.revalidate_freq=2 + +; Enables or disables file search in include_path optimization +;opcache.revalidate_path=0 + +; If disabled, all PHPDoc comments are dropped from the code to reduce the +; size of the optimized code. +;opcache.save_comments=1 + +; If enabled, compilation warnings (including notices and deprecations) will +; be recorded and replayed each time a file is included. Otherwise, compilation +; warnings will only be emitted when the file is first cached. +;opcache.record_warnings=0 + +; Allow file existence override (file_exists, etc.) performance feature. +;opcache.enable_file_override=0 + +; A bitmask, where each bit enables or disables the appropriate OPcache +; passes +;opcache.optimization_level=0x7FFFBFFF + +;opcache.dups_fix=0 + +; The location of the OPcache blacklist file (wildcards allowed). +; Each OPcache blacklist file is a text file that holds the names of files +; that should not be accelerated. The file format is to add each filename +; to a new line. The filename may be a full path or just a file prefix +; (i.e., /var/www/x blacklists all the files and directories in /var/www +; that start with 'x'). Line starting with a ; are ignored (comments). +;opcache.blacklist_filename= + +; Allows exclusion of large files from being cached. By default all files +; are cached. +;opcache.max_file_size=0 + +; Check the cache checksum each N requests. +; The default value of "0" means that the checks are disabled. +;opcache.consistency_checks=0 + +; How long to wait (in seconds) for a scheduled restart to begin if the cache +; is not being accessed. +;opcache.force_restart_timeout=180 + +; OPcache error_log file name. Empty string assumes "stderr". +;opcache.error_log= + +; All OPcache errors go to the Web server log. +; By default, only fatal errors (level 0) or errors (level 1) are logged. +; You can also enable warnings (level 2), info messages (level 3) or +; debug messages (level 4). +;opcache.log_verbosity_level=1 + +; Preferred Shared Memory back-end. Leave empty and let the system decide. +;opcache.preferred_memory_model= + +; Protect the shared memory from unexpected writing during script execution. +; Useful for internal debugging only. +;opcache.protect_memory=0 + +; Allows calling OPcache API functions only from PHP scripts which path is +; started from specified string. The default "" means no restriction +;opcache.restrict_api= + +; Mapping base of shared memory segments (for Windows only). All the PHP +; processes have to map shared memory into the same address space. This +; directive allows to manually fix the "Unable to reattach to base address" +; errors. +;opcache.mmap_base= + +; Facilitates multiple OPcache instances per user (for Windows only). All PHP +; processes with the same cache ID and user share an OPcache instance. +;opcache.cache_id= + +; Enables and sets the second level cache directory. +; It should improve performance when SHM memory is full, at server restart or +; SHM reset. The default "" disables file based caching. +;opcache.file_cache= + +; Enables or disables opcode caching in shared memory. +;opcache.file_cache_only=0 + +; Enables or disables checksum validation when script loaded from file cache. +;opcache.file_cache_consistency_checks=1 + +; Implies opcache.file_cache_only=1 for a certain process that failed to +; reattach to the shared memory (for Windows only). Explicitly enabled file +; cache is required. +;opcache.file_cache_fallback=1 + +; Enables or disables copying of PHP code (text segment) into HUGE PAGES. +; Under certain circumstances (if only a single global PHP process is +; started from which all others fork), this can increase performance +; by a tiny amount because TLB misses are reduced. On the other hand, this +; delays PHP startup, increases memory usage and degrades performance +; under memory pressure - use with care. +; Requires appropriate OS configuration. +;opcache.huge_code_pages=0 + +; Validate cached file permissions. +;opcache.validate_permission=0 + +; Prevent name collisions in chroot'ed environment. +;opcache.validate_root=0 + +; If specified, it produces opcode dumps for debugging different stages of +; optimizations. +;opcache.opt_debug_level=0 + +; Specifies a PHP script that is going to be compiled and executed at server +; start-up. +; https://php.net/opcache.preload +;opcache.preload= + +; Preloading code as root is not allowed for security reasons. This directive +; facilitates to let the preloading to be run as another user. +; https://php.net/opcache.preload_user +;opcache.preload_user= + +; Prevents caching files that are less than this number of seconds old. It +; protects from caching of incompletely updated files. In case all file updates +; on your site are atomic, you may increase performance by setting it to "0". +;opcache.file_update_protection=2 + +; Absolute path used to store shared lockfiles (for *nix only). +;opcache.lockfile_path=/tmp + +[curl] +; A default value for the CURLOPT_CAINFO option. This is required to be an +; absolute path. +;curl.cainfo = + +[openssl] +; The location of a Certificate Authority (CA) file on the local filesystem +; to use when verifying the identity of SSL/TLS peers. Most users should +; not specify a value for this directive as PHP will attempt to use the +; OS-managed cert stores in its absence. If specified, this value may still +; be overridden on a per-stream basis via the "cafile" SSL stream context +; option. +;openssl.cafile= + +; If openssl.cafile is not specified or if the CA file is not found, the +; directory pointed to by openssl.capath is searched for a suitable +; certificate. This value must be a correctly hashed certificate directory. +; Most users should not specify a value for this directive as PHP will +; attempt to use the OS-managed cert stores in its absence. If specified, +; this value may still be overridden on a per-stream basis via the "capath" +; SSL stream context option. +;openssl.capath= + +[ffi] +; FFI API restriction. Possible values: +; "preload" - enabled in CLI scripts and preloaded files (default) +; "false" - always disabled +; "true" - always enabled +;ffi.enable=preload + +; List of headers files to preload, wildcard patterns allowed. +;ffi.preload= diff --git a/routes/api_v2.php b/routes/api_v2.php index 10445c3a5..8a6cdcff2 100644 --- a/routes/api_v2.php +++ b/routes/api_v2.php @@ -170,6 +170,8 @@ use App\Http\Controllers\V2\Tasks\AdminIndexTasksController; use App\Http\Controllers\V2\Tasks\SubmitProjectTasksController; use App\Http\Controllers\V2\Tasks\ViewTaskController; +use App\Http\Controllers\V2\Terrafund\TerrafundCreateGeometryController; +use App\Http\Controllers\V2\Terrafund\TerrafundEditGeometryController; use App\Http\Controllers\V2\TreeSpecies\GetTreeSpeciesForEntityController; use App\Http\Controllers\V2\UpdateRequests\AdminIndexUpdateRequestsController; use App\Http\Controllers\V2\UpdateRequests\AdminSoftDeleteUpdateRequestController; @@ -614,6 +616,33 @@ }); }); +Route::prefix('terrafund')->group(function () { + Route::post('/polygon', [TerrafundCreateGeometryController::class, 'storeGeometry']); + Route::post('/upload-geojson', [TerrafundCreateGeometryController::class, 'uploadGeoJSONFile']); + Route::post('/upload-shapefile', [TerrafundCreateGeometryController::class, 'uploadShapefile']); + Route::post('/upload-kml', [TerrafundCreateGeometryController::class, 'uploadKMLFile']); + Route::post('/polygon/{uuid}', [TerrafundCreateGeometryController::class, 'processGeometry']); + + Route::get('/geojson/complete', [TerrafundCreateGeometryController::class, 'getPolygonsAsGeoJSON']); + Route::get('/validation/self-intersection', [TerrafundCreateGeometryController::class, 'checkSelfIntersection']); + Route::get('/validation/size-limit', [TerrafundCreateGeometryController::class, 'validatePolygonSize']); + Route::get('/validation/spike', [TerrafundCreateGeometryController::class, 'checkBoundarySegments']); + Route::get('/validation/within-country', [TerrafundCreateGeometryController::class, 'checkWithinCountry']); + Route::get('/validation/geometry-type', [TerrafundCreateGeometryController::class, 'getGeometryType']); + Route::get('/country-names', [TerrafundCreateGeometryController::class, 'getAllCountryNames']); + Route::get('/validation/criteria-data', [TerrafundCreateGeometryController::class, 'getCriteriaData']); + Route::get('/validation/overlapping', [TerrafundCreateGeometryController::class, 'validateOverlapping']); + Route::get('/validation/estimated-area', [TerrafundCreateGeometryController::class, 'validateEstimatedArea']); + Route::get('/validation/table-data', [TerrafundCreateGeometryController::class, 'validateDataInDB']); + + Route::get('/polygon/{uuid}', [TerrafundEditGeometryController::class, 'getSitePolygonData']); + Route::get('/polygon/geojson/{uuid}', [TerrafundEditGeometryController::class, 'getPolygonGeojson']); + Route::put('/polygon/{uuid}', [TerrafundEditGeometryController::class, 'updateGeometry']); + + Route::put('/site-polygon/{uuid}', [TerrafundEditGeometryController::class, 'updateSitePolygon']); + Route::post('/site-polygon/{uuid}', [TerrafundEditGeometryController::class, 'createSitePolygon']); +}); + Route::get('/funding-programme', [FundingProgrammeController::class, 'index'])->middleware('i18n'); Route::get('/funding-programme/{fundingProgramme}', [FundingProgrammeController::class, 'show']); diff --git a/storage/logs/.gitignore b/storage/logs/.gitignore old mode 100644 new mode 100755 From 3f05e3832d9629f0ec03e150383c0f0718a031dd Mon Sep 17 00:00:00 2001 From: Limber Mamani <154026979+LimberHope@users.noreply.github.com> Date: Thu, 18 Apr 2024 15:10:02 -0400 Subject: [PATCH 05/30] feat: add old_id (#149) --- .../Controllers/V2/Sites/CreateSiteWithFormController.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/Http/Controllers/V2/Sites/CreateSiteWithFormController.php b/app/Http/Controllers/V2/Sites/CreateSiteWithFormController.php index e3a78586f..b8e72e635 100644 --- a/app/Http/Controllers/V2/Sites/CreateSiteWithFormController.php +++ b/app/Http/Controllers/V2/Sites/CreateSiteWithFormController.php @@ -23,10 +23,14 @@ public function __invoke(Form $form, CreateEntityFormRequest $formRequest) return new JsonResponse('No Project found for this site.', 404); } + $lastOldId = Site::orderByDesc('old_id') + ->value('old_id'); + $site = Site::create([ 'framework_key' => $project->framework_key, 'project_id' => $project->id, 'status' => EntityStatusStateMachine::STARTED, + 'old_id' => $lastOldId + 1, ]); return $site->createSchemaResource(); From 5c6ce781bf78ce56f10ff766f2b96589b0af71b4 Mon Sep 17 00:00:00 2001 From: Nathan Curtis Date: Thu, 18 Apr 2024 14:52:31 -0700 Subject: [PATCH 06/30] [TM-803] Implement bulk upload of images to sites by service accounts. --- .../Migration/RolesMigrationCommand.php | 2 +- app/Exceptions/Handler.php | 5 + .../Controllers/V2/Files/UploadController.php | 61 +++++++-- .../Requests/V2/File/BulkUploadRequest.php | 47 +++++++ app/Policies/V2/Sites/SitePolicy.php | 18 ++- app/functions.php | 2 + config/wri/file-handling.php | 2 +- database/factories/UserFactory.php | 10 ++ routes/api_v2.php | 1 + tests/V2/Files/UploadControllerTest.php | 127 +++++++++++++++++- 10 files changed, 260 insertions(+), 15 deletions(-) create mode 100644 app/Http/Requests/V2/File/BulkUploadRequest.php diff --git a/app/Console/Commands/Migration/RolesMigrationCommand.php b/app/Console/Commands/Migration/RolesMigrationCommand.php index b94087e09..a0f1a4b41 100644 --- a/app/Console/Commands/Migration/RolesMigrationCommand.php +++ b/app/Console/Commands/Migration/RolesMigrationCommand.php @@ -75,7 +75,7 @@ public function handle() $role->givePermissionTo(['projects-read', 'polygons-manage', 'media-manage']); } - User::whereIn('role', ['user','admin', 'terrafund-admin'])->get() + User::whereIn('role', ['user', 'admin', 'terrafund-admin', 'service'])->get() ->each(function (User $user) { if ($user->primary_role == null) { assignSpatieRole($user); diff --git a/app/Exceptions/Handler.php b/app/Exceptions/Handler.php index 87e3a9a40..9cfc7ab50 100644 --- a/app/Exceptions/Handler.php +++ b/app/Exceptions/Handler.php @@ -18,6 +18,8 @@ use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Log; use Illuminate\Validation\ValidationException; +use Spatie\MediaLibrary\MediaCollections\Exceptions\MimeTypeNotAllowed; +use Spatie\MediaLibrary\MediaCollections\Exceptions\UnreachableUrl; use Symfony\Component\HttpKernel\Exception\HttpException; use Symfony\Component\HttpKernel\Exception\MethodNotAllowedHttpException; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; @@ -345,6 +347,9 @@ public function render($request, Throwable $exception) return JsonResponseHelper::error([], 404); case InvalidStatusException::class: return JsonResponseHelper::error($exception->getMessage(), 422); + case MimeTypeNotAllowed::class: + case UnreachableUrl::class: + return JsonResponseHelper::error([[$exception->getMessage()]], 422); default: if (config('app.env') == 'local') { return new Response($this->renderExceptionContent($exception), 500, ['Content-Type' => 'text/html']); diff --git a/app/Http/Controllers/V2/Files/UploadController.php b/app/Http/Controllers/V2/Files/UploadController.php index 376c133fc..dbf26dfa2 100644 --- a/app/Http/Controllers/V2/Files/UploadController.php +++ b/app/Http/Controllers/V2/Files/UploadController.php @@ -3,12 +3,14 @@ namespace App\Http\Controllers\V2\Files; use App\Http\Controllers\Controller; +use App\Http\Requests\V2\File\BulkUploadRequest; use App\Http\Requests\V2\File\UploadRequest; use App\Http\Resources\V2\Files\FileResource; use App\Models\V2\MediaModel; use Illuminate\Support\Arr; use Illuminate\Support\Facades\Validator; -use mysql_xdevapi\Exception; +use Exception; +use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; class UploadController extends Controller { @@ -22,13 +24,48 @@ public function __invoke(UploadRequest $request, string $collection, MediaModel $this->prepHandler($qry, $request->all(), $mediaModel, $config, $collection); $details = $this->executeHandler($qry, $collection); - if (Arr::has($request->all(), ['lat', 'lng'])) { - $this->saveFileCoordinates($details, $request); + $this->saveFileCoordinates($details, $request->all()); + $this->saveAdditionalFileProperties($details, $request->all(), $config); + + return new FileResource($details); + } + + public function bulkUrlUpload(BulkUploadRequest $request, string $collection, MediaModel $mediaModel) + { + $this->authorize('uploadFiles', $mediaModel); + + if ($collection != 'photos') { + // Only the photos collection is allowed for bulk upload + throw new NotFoundHttpException(); } - $this->saveAdditionalFileProperties($details, $request, $config); + $config = $this->getConfiguration($mediaModel, $collection); + $files = []; + try { + foreach ($request->getPayload() as $data) { + // The downloadable file gets shuttled through the internals of Spatie without a chance for us to run + // our own validations on them. png/jpg are the only mimes allowed for the photos collection according + // to config/file-handling.php, and we disallow other collections than 'photos' above. + $handler = $mediaModel->addMediaFromUrl($data['download_url'], 'image/png', 'image/jpg'); + + $this->prepHandler($handler, $data, $mediaModel, $config, $collection); + $details = $this->executeHandler($handler, $collection); + + $this->saveFileCoordinates($details, $data); + $this->saveAdditionalFileProperties($details, $data, $config); + + $files[] = $details; + } + } catch (Exception $exception) { + // if we get an error in the bulk upload, remove any media that did successfully get saved. + foreach ($files as $file) { + $file->delete(); + } + + throw $exception; + } - return new FileResource($details); + return FileResource::collection($files); } private function getConfiguration(MediaModel $mediaModel, $collection): array @@ -76,17 +113,19 @@ private function executeHandler($handler, $collection) ->toMediaCollection($collection); } - private function saveFileCoordinates($media, $request) + private function saveFileCoordinates($media, $data) { - $media->lat = $request->lat; - $media->lng = $request->lng; - $media->save(); + if (Arr::has($data, ['lat', 'lng'])) { + $media->lat = $data['lat']; + $media->lng = $data['lng']; + $media->save(); + } } - private function saveAdditionalFileProperties($media, $request, $config) + private function saveAdditionalFileProperties($media, $data, $config) { $media->file_type = $this->getType($media, $config); - $media->is_public = $request->is_public ?? true; + $media->is_public = $data['is_public'] ?? true; $media->save(); } diff --git a/app/Http/Requests/V2/File/BulkUploadRequest.php b/app/Http/Requests/V2/File/BulkUploadRequest.php new file mode 100644 index 000000000..de8623919 --- /dev/null +++ b/app/Http/Requests/V2/File/BulkUploadRequest.php @@ -0,0 +1,47 @@ + [ + 'prohibited', + ], + '*.download_url' => [ + 'required', + 'url', + ], + '*.title' => [ + 'sometimes', + 'nullable', + 'string', + ], + '*.lat' => [ + 'nullable', + 'numeric', + 'between:-90,90', + ], + '*.lng' => [ + 'nullable', + 'numeric', + 'between:-180,180', + ], + '*.is_public' => [ + 'sometimes', + 'nullable', + 'boolean', + ], + ]; + } +} diff --git a/app/Policies/V2/Sites/SitePolicy.php b/app/Policies/V2/Sites/SitePolicy.php index 9d910fca6..9f8370a3c 100644 --- a/app/Policies/V2/Sites/SitePolicy.php +++ b/app/Policies/V2/Sites/SitePolicy.php @@ -83,7 +83,23 @@ protected function isTheirs(?User $user, ?Site $site = null): bool public function uploadFiles(?User $user, ?Site $site = null): bool { - return $user->email_address_verified_at != null; + if ($user->email_address_verified_at == null) { + return false; + } + + if ($user->can('manage-own') && $this->isTheirs($user, $site)) { + return true; + } + + if ($user->can('framework-' . $site->framework_key)) { + return true; + } + + if ($user->can('media-manage')) { + return true; + } + + return false; } public function export(?User $user, ?Form $form = null, ?Project $project = null): bool diff --git a/app/functions.php b/app/functions.php index 7407f7590..9076847d6 100644 --- a/app/functions.php +++ b/app/functions.php @@ -165,5 +165,7 @@ function assignSpatieRole($user) $user->assignRole('admin-terrafund'); break; + case 'service': + $user->assignRole('greenhouse-service-account'); } } diff --git a/config/wri/file-handling.php b/config/wri/file-handling.php index eb58e801a..2392ccab8 100644 --- a/config/wri/file-handling.php +++ b/config/wri/file-handling.php @@ -5,7 +5,7 @@ 'logo-image' => 'file|max:3000|mimes:jpg,png', 'cover-image' => 'file|max:20000|mimes:jpg,png', 'cover-image-with-svg' => 'file|max:20000|mimes:jpg,png,svg', - 'photos' => 'file|max:25000|mimes:jpg,png', + 'photos' => 'file|max:2|mimes:jpg,png', 'pdf' => 'file|max:5000|mimes:pdf', 'documents' => 'file|mimes:pdf,xls,xlsx,csv,txt,doc', 'general-documents' => 'file|mimes:pdf,xls,xlsx,csv,txt,png,jpg,doc', diff --git a/database/factories/UserFactory.php b/database/factories/UserFactory.php index 864925f0d..34c203713 100644 --- a/database/factories/UserFactory.php +++ b/database/factories/UserFactory.php @@ -67,4 +67,14 @@ public function terrafundAdmin() ]; }); } + + public function serviceAccount() + { + return $this->state(function (array $attributes) { + return [ + 'api_key' => base64_encode(random_bytes(48)), + 'role' => 'service', + ]; + }); + } } diff --git a/routes/api_v2.php b/routes/api_v2.php index e4fe6cb88..6f441b75a 100644 --- a/routes/api_v2.php +++ b/routes/api_v2.php @@ -611,6 +611,7 @@ MediaModel::class, function () { Route::post('/{collection}/{mediaModel}', UploadController::class); + Route::post('/{collection}/{mediaModel}/bulk_url', [UploadController::class, 'bulkUrlUpload']); }, prefix: 'file/upload', modelParameter: 'mediaModel' diff --git a/tests/V2/Files/UploadControllerTest.php b/tests/V2/Files/UploadControllerTest.php index fd9b524ea..3a1eb7c40 100644 --- a/tests/V2/Files/UploadControllerTest.php +++ b/tests/V2/Files/UploadControllerTest.php @@ -15,8 +15,11 @@ use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\WithFaker; use Illuminate\Http\UploadedFile; +use Illuminate\Support\Facades\Artisan; use Illuminate\Support\Facades\Storage; +use Illuminate\Support\Str; use Tests\TestCase; +use function PHPUnit\Framework\assertContains; final class UploadControllerTest extends TestCase { @@ -221,7 +224,8 @@ public function test_file_upload_for_a_project_report_is_successful() public function test_file_upload_for_a_site_is_successful() { $admin = User::factory()->admin()->create(); - $site = Site::factory()->create(); + Artisan::call('v2migration:roles'); + $site = Site::factory()->ppc()->create(); Storage::fake('uploads'); @@ -486,4 +490,125 @@ public function test_file_upload_sets_media_file_type_successfully() 'file_type' => 'documents', ]); } + + public function test_bulk_upload_validation() + { + $service = User::factory()->serviceAccount()->create(); + Artisan::call('v2migration:roles'); + $site = Site::factory()->create(); + $organisation = Organisation::factory()->create(); + // It's not ideal for the testing suite to use a real hosted image, but I haven't found a way to fake a + // http download URL in phpunit/spatie. + $url = 'https://new-wri-prod.wri-restoration-marketplace-api.com/images/V2/land-tenures/national-protected-area.png'; + + // User doesn't own the site. + $this->actingAs(User::factory()->create()) + ->postJson("/api/v2/file/upload/site/photos/$site->uuid/bulk_url", []) + ->assertForbidden(); + + // Service accounts can only upload to sites + $this->actingAs($service) + ->postJson("/api/v2/file/upload/organisation/photos/$organisation->uuid/bulk_url", []) + ->assertForbidden(); + + // Only the photos collection is allowed + $this->actingAs($service) + ->postJson("/api/v2/file/upload/site/pdf/$site->uuid/bulk_url", []) + ->assertStatus(404); + + // UUID isn't allowed + $content = $this->actingAs($service) + ->postJson("/api/v2/file/upload/site/photos/$site->uuid/bulk_url", [["uuid" => "test", "download_url" => "test"]]) + ->assertStatus(422) + ->json(); + $this->assertStringContainsString('uuid field is prohibited', $content['errors'][0]['detail']); + + // Payload isn't an array of images + $this->actingAs($service) + ->postJson("/api/v2/file/upload/site/photos/$site->uuid/bulk_url", ["download_url" => "test"]) + ->assertStatus(422); + + // Payload has incorrect download URL format + $content = $this->actingAs($service) + ->postJson("/api/v2/file/upload/site/photos/$site->uuid/bulk_url", [["download_url" => "test"]]) + ->assertStatus(422) + ->json(); + $this->assertStringContainsString('format is invalid', $content['errors'][0]['detail']); + + // Unreachable URL + $content = $this->actingAs($service) + ->postJson("/api/v2/file/upload/site/photos/$site->uuid/bulk_url", [["download_url" => 'https://terramatch.org/foo.jpg']]) + ->assertStatus(422) + ->json(); + $this->assertStringContainsString('cannot be reached', $content['errors'][0]['detail']); + } + + public function test_bulk_upload_functionality() + { + $service = User::factory()->serviceAccount()->create(); + Artisan::call('v2migration:roles'); + $site = Site::factory()->create(); + // It's not ideal for the testing suite to use a real hosted image, but I haven't found a way to fake a + // http download URL in phpunit/spatie. + $url = 'https://new-wri-prod.wri-restoration-marketplace-api.com/images/V2/land-tenures/national-protected-area.png'; + $badMimeUrl = 'https://www.terramatch.org/images/landing-page-hero-banner.webp'; + + // Check a valid upload + $this->actingAs($service) + ->postJson( + "/api/v2/file/upload/site/photos/$site->uuid/bulk_url", + [["download_url" => $url]] + ) + ->assertSuccessful(); + $site = $site->refresh(); + $this->assertEquals($site->getMedia('photos')->count(), 1); + $media = $site->getFirstMedia('photos'); + $this->assertEquals($media->mime_type, 'image/png'); + $this->assertEquals($media->file_name, 'national-protected-area.png'); + + // Check that the first file doesn't stick around in an invalid upload + $site->clearMediaCollection('photos'); + $content = $this->actingAs($service) + ->postJson( + "/api/v2/file/upload/site/photos/$site->uuid/bulk_url", + [["download_url" => $url], ["download_url" => $badMimeUrl]]) + ->assertStatus(422) + ->json(); + $this->assertStringContainsString('File has a mime type', $content['errors'][0]['detail']); + $site = $site->refresh(); + $this->assertEquals($site->getMedia('photos')->count(), 0); + + // Check that multiple file upload works + $site->clearMediaCollection('photos'); + $this->actingAs($service) + ->postJson( + "/api/v2/file/upload/site/photos/$site->uuid/bulk_url", + [["download_url" => $url], ["download_url" => $url]] + ) + ->assertSuccessful(); + $site = $site->refresh(); + $this->assertEquals($site->getMedia('photos')->count(), 2); + + // Check that optional fields are honored + $site->clearMediaCollection('photos'); + $this->actingAs($service) + ->postJson( + "/api/v2/file/upload/site/photos/$site->uuid/bulk_url", + [[ + "download_url" => $url, + "title" => "Test Image", + "lat" => 42, + "lng" => -50, + "is_public" => false, + ]] + ) + ->assertSuccessful(); + $site = $site->refresh(); + $media = $site->getFirstMedia('photos'); + $this->assertNotNull($media->uuid); + $this->assertEquals($media->name, 'Test Image'); + $this->assertEquals($media->lat, 42); + $this->assertEquals($media->lng, -50); + $this->assertEquals($media->is_public, false); + } } From b6068c0e5276b38b85b7a86cf94a615f965ec3c0 Mon Sep 17 00:00:00 2001 From: Nathan Curtis Date: Thu, 18 Apr 2024 14:53:47 -0700 Subject: [PATCH 07/30] [TM-803] Lint fix --- .../Controllers/V2/Files/UploadController.php | 3 ++- .../Requests/V2/File/BulkUploadRequest.php | 1 - database/factories/UserFactory.php | 8 +++--- tests/V2/Files/UploadControllerTest.php | 27 +++++++++---------- 4 files changed, 19 insertions(+), 20 deletions(-) diff --git a/app/Http/Controllers/V2/Files/UploadController.php b/app/Http/Controllers/V2/Files/UploadController.php index dbf26dfa2..d764e46d3 100644 --- a/app/Http/Controllers/V2/Files/UploadController.php +++ b/app/Http/Controllers/V2/Files/UploadController.php @@ -7,9 +7,9 @@ use App\Http\Requests\V2\File\UploadRequest; use App\Http\Resources\V2\Files\FileResource; use App\Models\V2\MediaModel; +use Exception; use Illuminate\Support\Arr; use Illuminate\Support\Facades\Validator; -use Exception; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; class UploadController extends Controller @@ -41,6 +41,7 @@ public function bulkUrlUpload(BulkUploadRequest $request, string $collection, Me $config = $this->getConfiguration($mediaModel, $collection); $files = []; + try { foreach ($request->getPayload() as $data) { // The downloadable file gets shuttled through the internals of Spatie without a chance for us to run diff --git a/app/Http/Requests/V2/File/BulkUploadRequest.php b/app/Http/Requests/V2/File/BulkUploadRequest.php index de8623919..462c32c2c 100644 --- a/app/Http/Requests/V2/File/BulkUploadRequest.php +++ b/app/Http/Requests/V2/File/BulkUploadRequest.php @@ -2,7 +2,6 @@ namespace App\Http\Requests\V2\File; -use App\Rules\CheckMimeTypeRule; use Illuminate\Foundation\Http\FormRequest; class BulkUploadRequest extends FormRequest diff --git a/database/factories/UserFactory.php b/database/factories/UserFactory.php index 34c203713..18cb951cd 100644 --- a/database/factories/UserFactory.php +++ b/database/factories/UserFactory.php @@ -71,10 +71,10 @@ public function terrafundAdmin() public function serviceAccount() { return $this->state(function (array $attributes) { - return [ - 'api_key' => base64_encode(random_bytes(48)), - 'role' => 'service', - ]; + return [ + 'api_key' => base64_encode(random_bytes(48)), + 'role' => 'service', + ]; }); } } diff --git a/tests/V2/Files/UploadControllerTest.php b/tests/V2/Files/UploadControllerTest.php index 3a1eb7c40..e11fe2c8c 100644 --- a/tests/V2/Files/UploadControllerTest.php +++ b/tests/V2/Files/UploadControllerTest.php @@ -17,9 +17,7 @@ use Illuminate\Http\UploadedFile; use Illuminate\Support\Facades\Artisan; use Illuminate\Support\Facades\Storage; -use Illuminate\Support\Str; use Tests\TestCase; -use function PHPUnit\Framework\assertContains; final class UploadControllerTest extends TestCase { @@ -518,26 +516,26 @@ public function test_bulk_upload_validation() // UUID isn't allowed $content = $this->actingAs($service) - ->postJson("/api/v2/file/upload/site/photos/$site->uuid/bulk_url", [["uuid" => "test", "download_url" => "test"]]) + ->postJson("/api/v2/file/upload/site/photos/$site->uuid/bulk_url", [['uuid' => 'test', 'download_url' => 'test']]) ->assertStatus(422) ->json(); $this->assertStringContainsString('uuid field is prohibited', $content['errors'][0]['detail']); // Payload isn't an array of images $this->actingAs($service) - ->postJson("/api/v2/file/upload/site/photos/$site->uuid/bulk_url", ["download_url" => "test"]) + ->postJson("/api/v2/file/upload/site/photos/$site->uuid/bulk_url", ['download_url' => 'test']) ->assertStatus(422); // Payload has incorrect download URL format $content = $this->actingAs($service) - ->postJson("/api/v2/file/upload/site/photos/$site->uuid/bulk_url", [["download_url" => "test"]]) + ->postJson("/api/v2/file/upload/site/photos/$site->uuid/bulk_url", [['download_url' => 'test']]) ->assertStatus(422) ->json(); $this->assertStringContainsString('format is invalid', $content['errors'][0]['detail']); // Unreachable URL $content = $this->actingAs($service) - ->postJson("/api/v2/file/upload/site/photos/$site->uuid/bulk_url", [["download_url" => 'https://terramatch.org/foo.jpg']]) + ->postJson("/api/v2/file/upload/site/photos/$site->uuid/bulk_url", [['download_url' => 'https://terramatch.org/foo.jpg']]) ->assertStatus(422) ->json(); $this->assertStringContainsString('cannot be reached', $content['errors'][0]['detail']); @@ -557,7 +555,7 @@ public function test_bulk_upload_functionality() $this->actingAs($service) ->postJson( "/api/v2/file/upload/site/photos/$site->uuid/bulk_url", - [["download_url" => $url]] + [['download_url' => $url]] ) ->assertSuccessful(); $site = $site->refresh(); @@ -571,7 +569,8 @@ public function test_bulk_upload_functionality() $content = $this->actingAs($service) ->postJson( "/api/v2/file/upload/site/photos/$site->uuid/bulk_url", - [["download_url" => $url], ["download_url" => $badMimeUrl]]) + [['download_url' => $url], ['download_url' => $badMimeUrl]] + ) ->assertStatus(422) ->json(); $this->assertStringContainsString('File has a mime type', $content['errors'][0]['detail']); @@ -583,7 +582,7 @@ public function test_bulk_upload_functionality() $this->actingAs($service) ->postJson( "/api/v2/file/upload/site/photos/$site->uuid/bulk_url", - [["download_url" => $url], ["download_url" => $url]] + [['download_url' => $url], ['download_url' => $url]] ) ->assertSuccessful(); $site = $site->refresh(); @@ -595,11 +594,11 @@ public function test_bulk_upload_functionality() ->postJson( "/api/v2/file/upload/site/photos/$site->uuid/bulk_url", [[ - "download_url" => $url, - "title" => "Test Image", - "lat" => 42, - "lng" => -50, - "is_public" => false, + 'download_url' => $url, + 'title' => 'Test Image', + 'lat' => 42, + 'lng' => -50, + 'is_public' => false, ]] ) ->assertSuccessful(); From 09b8af78c52cdcb965d15cdfbdde53cfc81446b4 Mon Sep 17 00:00:00 2001 From: Nathan Curtis Date: Thu, 18 Apr 2024 15:14:04 -0700 Subject: [PATCH 08/30] [TM-803] Update docs --- openapi-src/V2/paths/_index.yml | 49 +++++++++++++++++++++- resources/docs/swagger-v2.yml | 73 ++++++++++++++++++++++++++++++++- 2 files changed, 119 insertions(+), 3 deletions(-) diff --git a/openapi-src/V2/paths/_index.yml b/openapi-src/V2/paths/_index.yml index d96c9f717..9419eff25 100644 --- a/openapi-src/V2/paths/_index.yml +++ b/openapi-src/V2/paths/_index.yml @@ -887,13 +887,58 @@ in: formData - type: boolean name: is_public - default: false + default: true in: formData responses: '200': description: OK schema: $ref: '../definitions/_index.yml#/V2FileRead' +/v2/file/upload/site/photos/{UUID}/bulk_url: + post: + summary: Upload a batch of photos to a specific site + operationId: v2-post-upload-file-site-photos-uuid-bulk + tags: + - Files + consumes: + - application/json + produces: + - application/json + parameters: + - type: string + name: UUID + in: path + required: true + - description: Batch of photos to upload + in: body + name: body + required: true + schema: + type: array + items: + type: object + properties: + download_url: + type: string + title: + type: string + default: Name of image + lat: + type: integer + default: null + lng: + type: integer + default: null + is_public: + type: boolean + default: true + responses: + '200': + description: OK + schema: + type: array + items: + $ref: '../definitions/_index.yml#/V2FileRead' /v2/files/{UUID}: put: summary: Update properties of a specific file @@ -2457,4 +2502,4 @@ $ref: './Exports/get-v2-projects-uuid-entity-export.yml' /v2/{ENTITY}/{UUID}/export: get: - $ref: './Exports/get-v2-entity-export-uuid.yml' \ No newline at end of file + $ref: './Exports/get-v2-entity-export-uuid.yml' diff --git a/resources/docs/swagger-v2.yml b/resources/docs/swagger-v2.yml index 77ac99069..6219c06da 100644 --- a/resources/docs/swagger-v2.yml +++ b/resources/docs/swagger-v2.yml @@ -69825,7 +69825,7 @@ paths: in: formData - type: boolean name: is_public - default: false + default: true in: formData responses: '200': @@ -69858,6 +69858,77 @@ paths: type: boolean created_at: type: string + '/v2/file/upload/site/photos/{UUID}/bulk_url': + post: + summary: Upload a batch of photos to a specific site + operationId: v2-post-upload-file-site-photos-uuid-bulk + tags: + - Files + consumes: + - application/json + produces: + - application/json + parameters: + - type: string + name: UUID + in: path + required: true + - description: Batch of photos to upload + in: body + name: body + required: true + schema: + type: array + items: + type: object + properties: + download_url: + type: string + title: + type: string + default: Name of image + lat: + type: integer + default: null + lng: + type: integer + default: null + is_public: + type: boolean + default: true + responses: + '200': + description: OK + schema: + type: array + items: + title: V2FileRead + type: object + properties: + uuid: + type: string + url: + type: string + thumb_url: + type: string + collection_name: + type: string + title: + type: string + file_name: + type: string + mime_type: + type: string + size: + type: integer + lat: + type: integer + lng: + type: integer + is_public: + type: boolean + created_at: + type: string '/v2/files/{UUID}': put: summary: Update properties of a specific file From 564d5c7275504ba495b90d9d4399700c1b74cee2 Mon Sep 17 00:00:00 2001 From: Nathan Curtis Date: Fri, 19 Apr 2024 09:39:30 -0700 Subject: [PATCH 09/30] [TM-803] Roll back testing change. --- config/wri/file-handling.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/wri/file-handling.php b/config/wri/file-handling.php index 2392ccab8..eb58e801a 100644 --- a/config/wri/file-handling.php +++ b/config/wri/file-handling.php @@ -5,7 +5,7 @@ 'logo-image' => 'file|max:3000|mimes:jpg,png', 'cover-image' => 'file|max:20000|mimes:jpg,png', 'cover-image-with-svg' => 'file|max:20000|mimes:jpg,png,svg', - 'photos' => 'file|max:2|mimes:jpg,png', + 'photos' => 'file|max:25000|mimes:jpg,png', 'pdf' => 'file|max:5000|mimes:pdf', 'documents' => 'file|mimes:pdf,xls,xlsx,csv,txt,doc', 'general-documents' => 'file|mimes:pdf,xls,xlsx,csv,txt,png,jpg,doc', From 4e78b5ea9e20ccd7d2d30f5896c7798c41f7f648 Mon Sep 17 00:00:00 2001 From: Nathan Curtis Date: Fri, 19 Apr 2024 11:11:25 -0700 Subject: [PATCH 10/30] [TM-803] Allow .jpeg images --- app/Http/Controllers/V2/Files/UploadController.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/Http/Controllers/V2/Files/UploadController.php b/app/Http/Controllers/V2/Files/UploadController.php index d764e46d3..192ab9068 100644 --- a/app/Http/Controllers/V2/Files/UploadController.php +++ b/app/Http/Controllers/V2/Files/UploadController.php @@ -47,7 +47,9 @@ public function bulkUrlUpload(BulkUploadRequest $request, string $collection, Me // The downloadable file gets shuttled through the internals of Spatie without a chance for us to run // our own validations on them. png/jpg are the only mimes allowed for the photos collection according // to config/file-handling.php, and we disallow other collections than 'photos' above. - $handler = $mediaModel->addMediaFromUrl($data['download_url'], 'image/png', 'image/jpg'); + $handler = $mediaModel->addMediaFromUrl( + $data['download_url'], + 'image/png', 'image/jpg', 'image/jpeg'); $this->prepHandler($handler, $data, $mediaModel, $config, $collection); $details = $this->executeHandler($handler, $collection); From 22e866b9b044e55d3c17b44106f14daa81eb814c Mon Sep 17 00:00:00 2001 From: Nathan Curtis Date: Fri, 19 Apr 2024 12:14:09 -0700 Subject: [PATCH 11/30] [TM-804] Store the created_by user id when uploading media. --- .../Controllers/V2/Files/UploadController.php | 2 ++ ...9_190707_add_created_by_to_media_table.php | 29 +++++++++++++++++++ 2 files changed, 31 insertions(+) create mode 100644 database/migrations/2024_04_19_190707_add_created_by_to_media_table.php diff --git a/app/Http/Controllers/V2/Files/UploadController.php b/app/Http/Controllers/V2/Files/UploadController.php index 192ab9068..affef8680 100644 --- a/app/Http/Controllers/V2/Files/UploadController.php +++ b/app/Http/Controllers/V2/Files/UploadController.php @@ -9,6 +9,7 @@ use App\Models\V2\MediaModel; use Exception; use Illuminate\Support\Arr; +use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Validator; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; @@ -129,6 +130,7 @@ private function saveAdditionalFileProperties($media, $data, $config) { $media->file_type = $this->getType($media, $config); $media->is_public = $data['is_public'] ?? true; + $media->created_by = Auth::user()->id; $media->save(); } diff --git a/database/migrations/2024_04_19_190707_add_created_by_to_media_table.php b/database/migrations/2024_04_19_190707_add_created_by_to_media_table.php new file mode 100644 index 000000000..dc6d595e0 --- /dev/null +++ b/database/migrations/2024_04_19_190707_add_created_by_to_media_table.php @@ -0,0 +1,29 @@ +foreignIdFor(User::class, 'created_by')->nullable(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('media', function (Blueprint $table) { + $table->dropColumn(['created_by']); + }); + } +}; From 10a5d50ba4b748776fd78f11a2c95862f003ca7a Mon Sep 17 00:00:00 2001 From: Nathan Curtis Date: Fri, 19 Apr 2024 13:43:11 -0700 Subject: [PATCH 12/30] [TM-804] Implement bulk delete --- app/Http/Controllers/V2/MediaController.php | 26 ++++++++ routes/api_v2.php | 1 + tests/V2/MediaControllerTest.php | 70 +++++++++++++++++++++ 3 files changed, 97 insertions(+) create mode 100644 tests/V2/MediaControllerTest.php diff --git a/app/Http/Controllers/V2/MediaController.php b/app/Http/Controllers/V2/MediaController.php index ec96ad489..763e28cd3 100644 --- a/app/Http/Controllers/V2/MediaController.php +++ b/app/Http/Controllers/V2/MediaController.php @@ -3,10 +3,13 @@ namespace App\Http\Controllers\V2; use App\Http\Controllers\Controller; +use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; +use Illuminate\Support\Facades\Auth; use Spatie\MediaLibrary\MediaCollections\Models\Media; +use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; class MediaController extends Controller { @@ -32,4 +35,27 @@ public function delete(Request $request, string $uuid, string $collection = ''): return response()->json(['success' => 'media has been deleted'], 202); } + + public function bulkDelete(Request $request): JsonResponse + { + if (!Auth::user()->can('media-manage')) { + throw new AuthorizationException('No permission to bulk delete'); + } + + $uuids = $request->input('uuids'); + if (empty($uuids)) { + throw new NotFoundHttpException(); + } + + $media = Media::whereIn('uuid', $uuids)->where('created_by', Auth::user()->id); + if ($media->count() != count($uuids)) { + // If the bulk delete endpoint is being called for some media that weren't created by this user, + // avoid deleting any of them. + throw new AuthorizationException('Some of the media you are trying to delete were not created by you.'); + } + + $media->delete(); + + return response()->json(['success' => 'media has been deleted'], 202); + } } diff --git a/routes/api_v2.php b/routes/api_v2.php index 452e0d758..426143a3e 100644 --- a/routes/api_v2.php +++ b/routes/api_v2.php @@ -232,6 +232,7 @@ }); Route::prefix('media')->group(function () { + Route::delete('', [MediaController::class, 'bulkDelete']); Route::delete('/{uuid}', [MediaController::class, 'delete']); Route::delete('/{uuid}/{collection}', [MediaController::class, 'delete']); }); diff --git a/tests/V2/MediaControllerTest.php b/tests/V2/MediaControllerTest.php new file mode 100644 index 000000000..3126c40dc --- /dev/null +++ b/tests/V2/MediaControllerTest.php @@ -0,0 +1,70 @@ +serviceAccount()->create(); + $admin = User::factory()->admin()->create(); + Artisan::call('v2migration:roles'); + + $site = Site::factory()->ppc()->create(); + $photo1 = $site->addMedia(UploadedFile::fake()->image('photo1'))->toMediaCollection('photos'); + $photo1->update(['created_by' => $service->id]); + $photo2 = $site->addMedia(UploadedFile::fake()->image('photo2'))->toMediaCollection('photos'); + $photo2->update(['created_by' => $admin->id]); + $photo3 = $site->addMedia(UploadedFile::fake()->image('photo3'))->toMediaCollection('photos'); + $photo3->update(['created_by' => $service->id]); + + // No UUIDS is a 404 + $this->actingAs($service) + ->delete("/api/v2/media") + ->assertNotFound(); + + // Can't delete photo created by admin + $this->actingAs($service) + ->delete($this->buildBulkDeleteUrl([$photo1->uuid, $photo2->uuid])) + ->assertForbidden(); + $this->assertEquals(3, $site->refresh()->getMedia('photos')->count()); + + // Only service accounts can use bulk delete + $this->actingAs($admin) + ->delete($this->buildBulkDeleteUrl([$photo2->uuid])) + ->assertForbidden(); + $this->assertEquals(3, $site->refresh()->getMedia('photos')->count()); + + // Success case + $this->actingAs($service) + ->delete($this->buildBulkDeleteUrl([$photo1->uuid, $photo3->uuid])) + ->assertSuccessful(); + $this->assertEquals(1, $site->refresh()->getMedia('photos')->count()); + } + + private function buildBulkDeleteUrl($uuids): string + { + return '/api/v2/media?uuids[]=' . implode('&uuids[]=', $uuids); + } +} From 1f614c510fc0a6683951b28884cc9a0bfddf81b5 Mon Sep 17 00:00:00 2001 From: Nathan Curtis Date: Fri, 19 Apr 2024 13:55:28 -0700 Subject: [PATCH 13/30] [TM-804] Document the bulk delete endpoint. --- openapi-src/V2/paths/_index.yml | 16 ++++++++++++++++ resources/docs/swagger-v2.yml | 16 ++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/openapi-src/V2/paths/_index.yml b/openapi-src/V2/paths/_index.yml index f7b12bcbe..ec43250d3 100644 --- a/openapi-src/V2/paths/_index.yml +++ b/openapi-src/V2/paths/_index.yml @@ -931,6 +931,22 @@ type: array items: $ref: '../definitions/_index.yml#/V2FileRead' +/v2/media: + delete: + summary: Bulk delete a set of media by UUID + operationId: v2-bulk-delete-media + tags: + - Media + parameters: + - type: array + name: uuids[] + in: query + required: true + items: + type: string + responses: + '200': + description: OK /v2/files/{UUID}: put: summary: Update properties of a specific file diff --git a/resources/docs/swagger-v2.yml b/resources/docs/swagger-v2.yml index 18ba8da3e..74a86ac98 100644 --- a/resources/docs/swagger-v2.yml +++ b/resources/docs/swagger-v2.yml @@ -69769,6 +69769,22 @@ paths: type: boolean created_at: type: string + /v2/media: + delete: + summary: Bulk delete a set of media by UUID + operationId: v2-bulk-delete-media + tags: + - Media + parameters: + - type: array + name: 'uuids[]' + in: query + required: true + items: + type: string + responses: + '200': + description: OK '/v2/files/{UUID}': put: summary: Update properties of a specific file From 636519b4f8712b26838cf18e2a4a408c6bfbd61e Mon Sep 17 00:00:00 2001 From: Nathan Curtis Date: Fri, 19 Apr 2024 14:07:18 -0700 Subject: [PATCH 14/30] [TM-804] Lint fix. --- app/Http/Controllers/V2/Files/UploadController.php | 5 ++++- app/Http/Controllers/V2/MediaController.php | 2 +- ...24_04_19_190707_add_created_by_to_media_table.php | 3 +-- tests/V2/MediaControllerTest.php | 12 +----------- 4 files changed, 7 insertions(+), 15 deletions(-) diff --git a/app/Http/Controllers/V2/Files/UploadController.php b/app/Http/Controllers/V2/Files/UploadController.php index affef8680..7a00279cb 100644 --- a/app/Http/Controllers/V2/Files/UploadController.php +++ b/app/Http/Controllers/V2/Files/UploadController.php @@ -50,7 +50,10 @@ public function bulkUrlUpload(BulkUploadRequest $request, string $collection, Me // to config/file-handling.php, and we disallow other collections than 'photos' above. $handler = $mediaModel->addMediaFromUrl( $data['download_url'], - 'image/png', 'image/jpg', 'image/jpeg'); + 'image/png', + 'image/jpg', + 'image/jpeg' + ); $this->prepHandler($handler, $data, $mediaModel, $config, $collection); $details = $this->executeHandler($handler, $collection); diff --git a/app/Http/Controllers/V2/MediaController.php b/app/Http/Controllers/V2/MediaController.php index 763e28cd3..4631e0024 100644 --- a/app/Http/Controllers/V2/MediaController.php +++ b/app/Http/Controllers/V2/MediaController.php @@ -38,7 +38,7 @@ public function delete(Request $request, string $uuid, string $collection = ''): public function bulkDelete(Request $request): JsonResponse { - if (!Auth::user()->can('media-manage')) { + if (! Auth::user()->can('media-manage')) { throw new AuthorizationException('No permission to bulk delete'); } diff --git a/database/migrations/2024_04_19_190707_add_created_by_to_media_table.php b/database/migrations/2024_04_19_190707_add_created_by_to_media_table.php index dc6d595e0..77cbd2698 100644 --- a/database/migrations/2024_04_19_190707_add_created_by_to_media_table.php +++ b/database/migrations/2024_04_19_190707_add_created_by_to_media_table.php @@ -5,8 +5,7 @@ use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema; -return new class extends Migration -{ +return new class extends Migration { /** * Run the migrations. */ diff --git a/tests/V2/MediaControllerTest.php b/tests/V2/MediaControllerTest.php index 3126c40dc..e558db516 100644 --- a/tests/V2/MediaControllerTest.php +++ b/tests/V2/MediaControllerTest.php @@ -3,21 +3,11 @@ namespace Tests\V2; use App\Models\User; -use App\Models\V2\Nurseries\Nursery; -use App\Models\V2\Nurseries\NurseryReport; -use App\Models\V2\Organisation; -use App\Models\V2\Projects\Project; -use App\Models\V2\Projects\ProjectMonitoring; -use App\Models\V2\Projects\ProjectReport; use App\Models\V2\Sites\Site; -use App\Models\V2\Sites\SiteMonitoring; -use App\Models\V2\Sites\SiteReport; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\WithFaker; use Illuminate\Http\UploadedFile; use Illuminate\Support\Facades\Artisan; -use Illuminate\Support\Facades\Storage; -use Spatie\MediaLibrary\MediaCollections\Models\Media; use Tests\TestCase; final class MediaControllerTest extends TestCase @@ -41,7 +31,7 @@ public function test_bulk_delete(): void // No UUIDS is a 404 $this->actingAs($service) - ->delete("/api/v2/media") + ->delete('/api/v2/media') ->assertNotFound(); // Can't delete photo created by admin From 8d8873fcf3e7b7b459b25c3db09e2b7408b58a40 Mon Sep 17 00:00:00 2001 From: Nathan Curtis Date: Fri, 19 Apr 2024 15:34:16 -0700 Subject: [PATCH 15/30] [TM-803] We can use images hosted in the local docker container for this test. --- tests/V2/Files/UploadControllerTest.php | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/tests/V2/Files/UploadControllerTest.php b/tests/V2/Files/UploadControllerTest.php index e11fe2c8c..c98fffb77 100644 --- a/tests/V2/Files/UploadControllerTest.php +++ b/tests/V2/Files/UploadControllerTest.php @@ -546,10 +546,8 @@ public function test_bulk_upload_functionality() $service = User::factory()->serviceAccount()->create(); Artisan::call('v2migration:roles'); $site = Site::factory()->create(); - // It's not ideal for the testing suite to use a real hosted image, but I haven't found a way to fake a - // http download URL in phpunit/spatie. - $url = 'https://new-wri-prod.wri-restoration-marketplace-api.com/images/V2/land-tenures/national-protected-area.png'; - $badMimeUrl = 'https://www.terramatch.org/images/landing-page-hero-banner.webp'; + $url = 'http://localhost/images/V2/land-tenures/national-protected-area.png'; + $badMimeUrl = 'http://localhost/images/email_logo.gif'; // Check a valid upload $this->actingAs($service) From 2a63b0a9b1b6777384918ffb87fcdb424cf1f24f Mon Sep 17 00:00:00 2001 From: Nathan Curtis Date: Wed, 24 Apr 2024 15:25:14 -0700 Subject: [PATCH 16/30] [TM-836] Checkpoint commit on the merge entities tool. --- app/Console/Commands/MergeEntities.php | 232 +++++++++++++++++++++++++ app/Models/V2/Sites/Site.php | 3 + config/wri/entity-merge-mapping.php | 44 +++++ 3 files changed, 279 insertions(+) create mode 100644 app/Console/Commands/MergeEntities.php create mode 100644 config/wri/entity-merge-mapping.php diff --git a/app/Console/Commands/MergeEntities.php b/app/Console/Commands/MergeEntities.php new file mode 100644 index 000000000..dc7fe6b16 --- /dev/null +++ b/app/Console/Commands/MergeEntities.php @@ -0,0 +1,232 @@ +argument('type'); + switch ($type) { + case 'sites': + $entities = $this->getEntities(Site::class); + $merged = $entities->shift(); + $this->mergeSites($merged, $entities); + + break; + + default: + $this->abort("Unsupported type: $type"); + } + } + + private function getEntities($modelClass): Collection + { + $mergedUuid = $this->argument('merged'); + $merged = $modelClass::isUuid($mergedUuid)->first(); + if ($merged == null) { + $this->abort("Base model not found: $mergedUuid"); + } + + $feederUuids = $this->argument('feeders'); + $feeders = Site::whereIn('uuid', $feederUuids)->get(); + if (count($feeders) != count($feederUuids)) { + $this->abort('Some feeders not found: ' . json_encode($feederUuids)); + } + + return collect([$merged])->push($feeders)->flatten(); + } + + #[NoReturn] private function abort(string $message, int $exitCode = 1): void + { + echo $message; + exit($exitCode); + } + + private function confirmMerge(string $mergeName, Collection $feederNames): void + { + $mergeMessage = "Would you like to execute this merge? This operation cannot easily be undone...\n". + " Merged Entity Name:\n $mergeName\n" . + " Feeder Entity Names: \n " . + $feederNames->join("\n ") + . "\n\n"; + if (!$this->confirm($mergeMessage)) { + $this->abort('Merge aborted', 0); + } + } + + private function mergeSites(Site $mergeSite, Collection $feederSites): void + { + $frameworks = $feederSites->map(fn (Site $site) => $site->framework_key)->push($mergeSite->framework_key)->unique(); + if ($frameworks->count() > 1) { + $this->abort('Multiple frameworks detected in sites: ' . json_encode($frameworks)); + } + + $projectIds = $feederSites->map(fn (Site $site) => $site->project_id)->push($mergeSite->project_id)->unique(); + if ($projectIds->count() > 1) { + $this->abort('Multiple project_ids detected in sites: ' . json_encode($projectIds)); + } + + $this->confirmMerge($mergeSite->name, $feederSites->map(fn ($site) => $site->name)); + + try { + DB::beginTransaction(); + $this->mergeEntities($mergeSite, $feederSites); + + // merge report information from the same reporting period (should be on the same task) and remove all update requests + + // remove all outstanding update requests + + // remove all feeder entities + + DB::commit(); + } catch (Exception $e) { + DB::rollBack(); + + $this->abort("Exception encountered during merge operation, transaction aborted: " . $e->getMessage()); + } + } + + /** + * Merges entity information and remove all update requests. Merged entity will be in 'awaiting-approval' state + * @throws Exception + */ + private function mergeEntities(EntityModel $merge, Collection $feeders): void + { + $config = config("wri.entity-merge-mapping.models.$merge->shortName.frameworks.$merge->framework_key"); + if (empty($config)) { + throw new Exception("Merge mapping configuration not found: $merge->shortName, $merge->framework_key"); + } + + $entities = collect([$merge])->push($feeders)->flatten(); + foreach ($config['properties'] ?? [] as $property => $commandSpec) { + $commandParts = explode(':', $commandSpec); + $command = array_shift($commandParts); + switch ($command) { + case 'date': + $dates = $entities->map(fn ($entity) => Carbon::parse($entity->$property)); + $merge->$property = $this->mergeDates($dates, ...$commandParts); + break; + + case 'long-text': + $texts = $entities->map(fn ($entity) => $entity->$property); + $merge->$property = $texts->join("\n\n"); + break; + + case 'set-null': + $merge->$property = null; + break; + + case 'union': + $sets = $entities->map(fn ($entity) => $entity->$property); + $merge->$property = $sets->flatten()->filter()->unique()->all(); + break; + + case 'sum': + $values = $entities->map(fn ($entity) => $entity->$property); + $merge->$property = $values->sum(); + break; + + default: + throw new Exception("Unknown properties command: $command"); + } + } + + foreach ($config['relations'] ?? [] as $property => $commandSpec) { + $commandParts = explode(':', $commandSpec); + $command = array_shift($commandParts); + switch ($command) { + case 'move-to-merged': + $this->moveAssociations($property, $merge, $feeders); + break; + + default: + throw new Exception("Unknown relations command: $command"); + } + } + + foreach ($config['file-collections'] ?? [] as $property => $commandSpec) { + $commandParts = explode(':', $commandSpec); + $command = array_shift($commandParts); + switch ($command) { + case 'move-to-merged': + /** @var MediaModel $merge */ + $this->moveMedia($property, $merge, $feeders); + break; + + default: + throw new Exception("Unknown file collections command: $command"); + } + } + } + + /** + * @throws Exception + */ + private function mergeDates(Collection $dates, $strategy): Carbon + { + return $dates->reduce(function (?Carbon $carry, Carbon $date) use ($strategy) { + if ($carry == null) return $date; + + return match ($strategy) { + 'first' => $carry->minimum($date), + 'last' => $carry->maximum($date), + default => throw new Exception("Unrecognized date strategy: $strategy"), + }; + }); + } + + private function moveAssociations(string $property, EntityModel $merge, Collection $feeders): void + { + // In this method we assume that the type of $merge and the models in $feeders match, so we simply + // need to update the foreign key for each of the associated models (and can ignore the type). We expect the + // relationship to be a MorphMany + + $foreignKey = $merge->$property()->getForeignKeyName(); + foreach ($feeders as $feeder) { + $feeder->$property()->update([$foreignKey => $merge->id]); + } + } + + private function moveMedia(string $collection, MediaModel $merge, Collection $feeders): void + { + /** @var MediaModel $feeder */ + foreach ($feeders as $feeder) { + /** @var Media $media */ + foreach ($feeder->getMedia($collection) as $media) { + $media->move($merge, $collection); + } + } + } +} diff --git a/app/Models/V2/Sites/Site.php b/app/Models/V2/Sites/Site.php index 7ade6d29e..8918a17c7 100644 --- a/app/Models/V2/Sites/Site.php +++ b/app/Models/V2/Sites/Site.php @@ -36,6 +36,9 @@ use Spatie\MediaLibrary\InteractsWithMedia; use Spatie\MediaLibrary\MediaCollections\Models\Media; +/** + * @property string project_id + */ class Site extends Model implements MediaModel, AuditableContract, EntityModel { use HasFactory; diff --git a/config/wri/entity-merge-mapping.php b/config/wri/entity-merge-mapping.php new file mode 100644 index 000000000..6652c1c3d --- /dev/null +++ b/config/wri/entity-merge-mapping.php @@ -0,0 +1,44 @@ + [ + 'site' => [ + 'frameworks' => [ + 'terrafund' => [ + 'properties' => [ + // Skip 'name' because it's from the merged site + 'start_date' => 'date:first', + 'end_date' => 'date:last', + 'landscape_community_contribution' => 'long-text', + 'boundary_geojson' => 'set-null', + 'land_use_types' => 'union', + 'restoration_strategy' => 'union', + 'hectares_to_restore_goal' => 'sum', + 'land_tenures' => 'union', + ], + 'relations' => [ + 'disturbances' => 'move-to-merged', + ], + 'file-collections' => [ + 'photos' => 'move-to-merged', + ], + ] + ] + ], + 'site-report' => [ + 'frameworks' => [ + 'terrafund' => [ + 'properties' => [ + + ], + 'linked-fields' => [ + + ], + 'conditionals' => [ + + ] + ] + ] + ] + ] +]; From 42604f77df4f276b373aa9e68c3ecfd63ce56983 Mon Sep 17 00:00:00 2001 From: Nathan Curtis Date: Wed, 24 Apr 2024 16:28:25 -0700 Subject: [PATCH 17/30] [TM-836] All features complete except for conditionals. --- app/Console/Commands/MergeEntities.php | 96 +++++++++++++++++++++-- app/Models/V2/TreeSpecies/TreeSpecies.php | 4 + config/wri/entity-merge-mapping.php | 16 ++-- 3 files changed, 103 insertions(+), 13 deletions(-) diff --git a/app/Console/Commands/MergeEntities.php b/app/Console/Commands/MergeEntities.php index dc7fe6b16..4ae77cccf 100644 --- a/app/Console/Commands/MergeEntities.php +++ b/app/Console/Commands/MergeEntities.php @@ -4,7 +4,11 @@ use App\Models\V2\EntityModel; use App\Models\V2\MediaModel; +use App\Models\V2\ReportModel; use App\Models\V2\Sites\Site; +use App\Models\V2\TreeSpecies\TreeSpecies; +use App\Models\V2\UpdateRequests\UpdateRequest; +use App\StateMachines\EntityStatusStateMachine; use Exception; use Illuminate\Console\Command; use Illuminate\Support\Carbon; @@ -60,7 +64,9 @@ private function getEntities($modelClass): Collection } $feederUuids = $this->argument('feeders'); - $feeders = Site::whereIn('uuid', $feederUuids)->get(); + // This would be faster as a whereIn, but we want to keep the order intact; matching it with the + // order that was passed into the command + $feeders = collect($feederUuids)->map(fn ($uuid) => $modelClass::isUuid($uuid)->first()); if (count($feeders) != count($feederUuids)) { $this->abort('Some feeders not found: ' . json_encode($feederUuids)); } @@ -86,6 +92,9 @@ private function confirmMerge(string $mergeName, Collection $feederNames): void } } + // Note for future expansion, the code to merge nurseries would be basically the same as this, but this pattern + // wouldn't work for projects because it relies on ensuring that the parent entity (the project for sites/nurseries) + // is the same, and projects would need to dig into merging their sites and nurseries as well. private function mergeSites(Site $mergeSite, Collection $feederSites): void { $frameworks = $feederSites->map(fn (Site $site) => $site->framework_key)->push($mergeSite->framework_key)->unique(); @@ -102,13 +111,10 @@ private function mergeSites(Site $mergeSite, Collection $feederSites): void try { DB::beginTransaction(); - $this->mergeEntities($mergeSite, $feederSites); - - // merge report information from the same reporting period (should be on the same task) and remove all update requests - - // remove all outstanding update requests - // remove all feeder entities + $this->mergeEntities($mergeSite, $feederSites); + $this->mergeReports($mergeSite, $feederSites); + $feederSites->each(function ($site) { $site->delete(); }); DB::commit(); } catch (Exception $e) { @@ -119,7 +125,27 @@ private function mergeSites(Site $mergeSite, Collection $feederSites): void } /** - * Merges entity information and remove all update requests. Merged entity will be in 'awaiting-approval' state + * Merges all reports from the feeder entities into the merge entity's reports. Finds associated reporting + * periods through the task associated with the merge entity's reports. The feeder's reports are removed and the + * Merge reports are put in the 'awaiting-approval' state. All associated update requests are removed. + * @throws Exception + */ + private function mergeReports(EntityModel $merge, Collection $feeders): void + { + /** @var ReportModel $report */ + $foreignKey = $merge->reports()->getForeignKeyName(); + foreach ($merge->reports()->get() as $report) { + $hasMany = $report->task->hasMany(get_class($report)); + // A whereIn would be faster, but we want to keep the reports in the same order as the feeders + $associatedReports = $feeders->map(fn ($feeder) => $hasMany->where($foreignKey, $feeder->id)->first()); + $this->mergeEntities($report, $associatedReports); + $associatedReports->each(function ($report) { $report->delete(); }); + } + } + + /** + * Merges entity information and remove all update requests. Merged entity will be in 'awaiting-approval' state. + * The caller is responsible for removing the feeder entities. * @throws Exception */ private function mergeEntities(EntityModel $merge, Collection $feeders): void @@ -158,6 +184,11 @@ private function mergeEntities(EntityModel $merge, Collection $feeders): void $merge->$property = $values->sum(); break; + case 'ensure-unique-string': + $texts = $entities->map(fn ($entity) => $entity->$property); + $merge->$property = $this->ensureUniqueString($property, $texts); + break; + default: throw new Exception("Unknown properties command: $command"); } @@ -171,6 +202,10 @@ private function mergeEntities(EntityModel $merge, Collection $feeders): void $this->moveAssociations($property, $merge, $feeders); break; + case 'tree-species-merge': + $this->treeSpeciesMerge($property, $merge, $feeders); + break; + default: throw new Exception("Unknown relations command: $command"); } @@ -189,6 +224,14 @@ private function mergeEntities(EntityModel $merge, Collection $feeders): void throw new Exception("Unknown file collections command: $command"); } } + + $merge->save(); + $merge->updateRequests()->delete(); + $feeders->each(function ($feeder) { $feeder->updateRequests()->delete(); }); + $merge->update([ + 'status' => EntityStatusStateMachine::AWAITING_APPROVAL, + 'update_request_status' => UpdateRequest::ENTITY_STATUS_NO_UPDATE, + ]); } /** @@ -207,6 +250,23 @@ private function mergeDates(Collection $dates, $strategy): Carbon }); } + /** + * @throws Exception + */ + private function ensureUniqueString(string $property, Collection $texts): ?string + { + $unique = $texts->filter()->unique(); + if ($unique->count() == 0) { + return null; + } + + if ($unique->count() > 1) { + throw new Exception("Property required to be unique as is not: $property, " . json_encode($unique)); + } + + return $unique->first(); + } + private function moveAssociations(string $property, EntityModel $merge, Collection $feeders): void { // In this method we assume that the type of $merge and the models in $feeders match, so we simply @@ -219,6 +279,26 @@ private function moveAssociations(string $property, EntityModel $merge, Collecti } } + private function treeSpeciesMerge(string $property, EntityModel $merge, Collection $feeders): void + { + $foreignKey = $merge->$property()->getForeignKeyName(); + foreach ($feeders as $feeder) { + /** @var TreeSpecies $feederTree */ + foreach ($feeder->$property()->get() as $feederTree) { + if ($merge->$property()->where('name', $feederTree->name)->exists()) { + /** @var TreeSpecies $baseTree */ + $baseTree = $merge->$property()->where('name', $feederTree->name)->first(); + $baseTree->update(['amount' => $baseTree->amount + $feederTree->amount]); + $feederTree->delete(); + } else { + $feederTree->update([$foreignKey => $merge->id]); + // Make sure that the merge model's association is aware of the addition + $merge->refresh(); + } + } + } + } + private function moveMedia(string $collection, MediaModel $merge, Collection $feeders): void { /** @var MediaModel $feeder */ diff --git a/app/Models/V2/TreeSpecies/TreeSpecies.php b/app/Models/V2/TreeSpecies/TreeSpecies.php index 4de10ded3..157c2688a 100644 --- a/app/Models/V2/TreeSpecies/TreeSpecies.php +++ b/app/Models/V2/TreeSpecies/TreeSpecies.php @@ -8,6 +8,10 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\SoftDeletes; +/** + * @property string $name + * @property mixed $amount + */ class TreeSpecies extends Model { use HasFactory; diff --git a/config/wri/entity-merge-mapping.php b/config/wri/entity-merge-mapping.php index 6652c1c3d..323b76cd6 100644 --- a/config/wri/entity-merge-mapping.php +++ b/config/wri/entity-merge-mapping.php @@ -6,7 +6,7 @@ 'frameworks' => [ 'terrafund' => [ 'properties' => [ - // Skip 'name' because it's from the merged site + // Skip 'name' because the merged site keeps its name 'start_date' => 'date:first', 'end_date' => 'date:last', 'landscape_community_contribution' => 'long-text', @@ -29,13 +29,19 @@ 'frameworks' => [ 'terrafund' => [ 'properties' => [ - + 'polygon_status' => 'long-text', + 'technical_narrative' => 'long-text', + 'shared_drive_link' => 'ensure-unique-string', ], - 'linked-fields' => [ - + 'relations' => [ + 'disturbances' => 'move-to-merged', + 'treeSpecies' => 'tree-species-merge', + 'nonTreeSpecies' => 'tree-species-merge', + ], + 'file-collections' => [ + 'photos' => 'move-to-merged', ], 'conditionals' => [ - ] ] ] From e7d6946aa96f78484414284a2c7bfbb903bff00c Mon Sep 17 00:00:00 2001 From: Nathan Curtis Date: Thu, 25 Apr 2024 11:09:00 -0700 Subject: [PATCH 18/30] [TM-836] Functionally complete. --- app/Console/Commands/MergeEntities.php | 109 +++++++++++++++++++++---- config/wri/entity-merge-mapping.php | 2 + 2 files changed, 96 insertions(+), 15 deletions(-) diff --git a/app/Console/Commands/MergeEntities.php b/app/Console/Commands/MergeEntities.php index 4ae77cccf..882c38e8f 100644 --- a/app/Console/Commands/MergeEntities.php +++ b/app/Console/Commands/MergeEntities.php @@ -74,7 +74,8 @@ private function getEntities($modelClass): Collection return collect([$merged])->push($feeders)->flatten(); } - #[NoReturn] private function abort(string $message, int $exitCode = 1): void + #[NoReturn] + private function abort(string $message, int $exitCode = 1): void { echo $message; exit($exitCode); @@ -87,7 +88,7 @@ private function confirmMerge(string $mergeName, Collection $feederNames): void " Feeder Entity Names: \n " . $feederNames->join("\n ") . "\n\n"; - if (!$this->confirm($mergeMessage)) { + if (! $this->confirm($mergeMessage)) { $this->abort('Merge aborted', 0); } } @@ -120,7 +121,7 @@ private function mergeSites(Site $mergeSite, Collection $feederSites): void } catch (Exception $e) { DB::rollBack(); - $this->abort("Exception encountered during merge operation, transaction aborted: " . $e->getMessage()); + $this->abort('Exception encountered during merge operation, transaction aborted: ' . $e->getMessage()); } } @@ -155,83 +156,159 @@ private function mergeEntities(EntityModel $merge, Collection $feeders): void throw new Exception("Merge mapping configuration not found: $merge->shortName, $merge->framework_key"); } + $this->processProperties(data_get($config, 'properties'), $merge, $feeders); + $this->processRelations(data_get($config, 'relations'), $merge, $feeders); + $this->processConditionals(data_get($config, 'conditionals'), $merge, $feeders); + // Saving file collections for last because I'm not entirely sure that rolling back the transaction will actually + // undo the spatie media "move", so we want to avoid aborting the process at this point if at all possible. + $this->processFileCollections(data_get($config, 'file-collections'), $merge, $feeders); + + $merge->save(); + $merge->updateRequests()->delete(); + $feeders->each(function ($feeder) { $feeder->updateRequests()->delete(); }); + $merge->update([ + 'status' => EntityStatusStateMachine::AWAITING_APPROVAL, + 'update_request_status' => UpdateRequest::ENTITY_STATUS_NO_UPDATE, + ]); + } + + /** + * @throws Exception + */ + private function processProperties($properties, $merge, $feeders): void + { $entities = collect([$merge])->push($feeders)->flatten(); - foreach ($config['properties'] ?? [] as $property => $commandSpec) { + foreach ($properties ?? [] as $property => $commandSpec) { $commandParts = explode(':', $commandSpec); $command = array_shift($commandParts); switch ($command) { case 'date': $dates = $entities->map(fn ($entity) => Carbon::parse($entity->$property)); $merge->$property = $this->mergeDates($dates, ...$commandParts); + break; case 'long-text': $texts = $entities->map(fn ($entity) => $entity->$property); $merge->$property = $texts->join("\n\n"); + break; case 'set-null': $merge->$property = null; + break; case 'union': $sets = $entities->map(fn ($entity) => $entity->$property); $merge->$property = $sets->flatten()->filter()->unique()->all(); + break; case 'sum': $values = $entities->map(fn ($entity) => $entity->$property); $merge->$property = $values->sum(); + break; case 'ensure-unique-string': $texts = $entities->map(fn ($entity) => $entity->$property); $merge->$property = $this->ensureUniqueString($property, $texts); + break; default: throw new Exception("Unknown properties command: $command"); } } + } - foreach ($config['relations'] ?? [] as $property => $commandSpec) { + /** + * @throws Exception + */ + private function processRelations($relations, $merge, $feeders): void + { + foreach ($relations ?? [] as $property => $commandSpec) { $commandParts = explode(':', $commandSpec); $command = array_shift($commandParts); switch ($command) { case 'move-to-merged': $this->moveAssociations($property, $merge, $feeders); + break; case 'tree-species-merge': $this->treeSpeciesMerge($property, $merge, $feeders); + break; default: throw new Exception("Unknown relations command: $command"); } } + } + + /** + * @throws Exception + */ + private function processConditionals($conditionals, $merge, $feeders): void + { + // Conditionals are specified differently from the other sets. It's an array of linked field keys. The task of + // this block is to find the conditional that controls the display of that linked field and make sure that it's + // set to "true" if any of entities have it set to true. We also want to clear out the answers fiend from the + // merged entity because most of it is incorrect at this point (aside from what we set in this block). + $answers = []; + if (! empty($conditionals)) { + $form = $merge->getForm(); + // get an associative array of uuid -> question for all questions in the form. + $questions = $form + ->sections + ->map(fn ($section) => $section->questions) + ->flatten() + ->mapWithKeys(fn ($question) => [$question->uuid => $question]); + foreach ($conditionals as $linkedField) { + $linkedFieldQuestion = $questions->first(fn ($question) => $question->linked_field_key == $linkedField); + if ($linkedFieldQuestion == null) { + throw new Exception("No question found for linked field: $linkedFieldQuestion"); + } + if (! $linkedFieldQuestion->show_on_parent_conditional) { + throw new Exception("Question for linked field isn't gated by a conditional: $linkedFieldQuestion"); + } + + $conditional = $questions[$linkedField->parent_id]; + if ($conditional == null) { + throw new Exception("No parent conditional found for linked field: $linkedFieldQuestion"); + } + if ($conditional['input_type'] != 'conditional') { + throw new Exception("Parent of linked field question is not a conditional: $linkedFieldQuestion"); + } + + $answers[$conditional->uuid] = data_get($merge->answers, $conditional->uuid) || + $feeders->contains(fn ($feeder) => data_get($feeder->answers, $conditional->uuid)); + } + } + $merge->answers = $answers; + } - foreach ($config['file-collections'] ?? [] as $property => $commandSpec) { + /** + * @throws Exception + */ + private function processFileCollections($fileCollections, $merge, $feeders): void + { + foreach ($fileCollections ?? [] as $property => $commandSpec) { $commandParts = explode(':', $commandSpec); $command = array_shift($commandParts); switch ($command) { case 'move-to-merged': /** @var MediaModel $merge */ $this->moveMedia($property, $merge, $feeders); + break; default: throw new Exception("Unknown file collections command: $command"); } } - - $merge->save(); - $merge->updateRequests()->delete(); - $feeders->each(function ($feeder) { $feeder->updateRequests()->delete(); }); - $merge->update([ - 'status' => EntityStatusStateMachine::AWAITING_APPROVAL, - 'update_request_status' => UpdateRequest::ENTITY_STATUS_NO_UPDATE, - ]); } /** @@ -240,7 +317,9 @@ private function mergeEntities(EntityModel $merge, Collection $feeders): void private function mergeDates(Collection $dates, $strategy): Carbon { return $dates->reduce(function (?Carbon $carry, Carbon $date) use ($strategy) { - if ($carry == null) return $date; + if ($carry == null) { + return $date; + } return match ($strategy) { 'first' => $carry->minimum($date), diff --git a/config/wri/entity-merge-mapping.php b/config/wri/entity-merge-mapping.php index 323b76cd6..593e507aa 100644 --- a/config/wri/entity-merge-mapping.php +++ b/config/wri/entity-merge-mapping.php @@ -42,6 +42,8 @@ 'photos' => 'move-to-merged', ], 'conditionals' => [ + 'site-rep-rel-disturbances', + 'site-rep-technical-narrative', ] ] ] From c299975205c89ac8df8f450e6cf616ed9a645583 Mon Sep 17 00:00:00 2001 From: Nathan Curtis Date: Thu, 25 Apr 2024 11:38:02 -0700 Subject: [PATCH 19/30] [TM-836] Bug fixes and tweaks --- app/Console/Commands/MergeEntities.php | 6 +++--- config/wri/entity-merge-mapping.php | 8 +++++--- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/app/Console/Commands/MergeEntities.php b/app/Console/Commands/MergeEntities.php index 882c38e8f..a15bb44f6 100644 --- a/app/Console/Commands/MergeEntities.php +++ b/app/Console/Commands/MergeEntities.php @@ -138,7 +138,7 @@ private function mergeReports(EntityModel $merge, Collection $feeders): void foreach ($merge->reports()->get() as $report) { $hasMany = $report->task->hasMany(get_class($report)); // A whereIn would be faster, but we want to keep the reports in the same order as the feeders - $associatedReports = $feeders->map(fn ($feeder) => $hasMany->where($foreignKey, $feeder->id)->first()); + $associatedReports = $feeders->map(fn ($feeder) => (clone $hasMany)->where($foreignKey, $feeder->id)->first())->filter(); $this->mergeEntities($report, $associatedReports); $associatedReports->each(function ($report) { $report->delete(); }); } @@ -271,11 +271,11 @@ private function processConditionals($conditionals, $merge, $feeders): void if ($linkedFieldQuestion == null) { throw new Exception("No question found for linked field: $linkedFieldQuestion"); } - if (! $linkedFieldQuestion->show_on_parent_conditional) { + if (! $linkedFieldQuestion->show_on_parent_condition) { throw new Exception("Question for linked field isn't gated by a conditional: $linkedFieldQuestion"); } - $conditional = $questions[$linkedField->parent_id]; + $conditional = $questions[$linkedFieldQuestion->parent_id]; if ($conditional == null) { throw new Exception("No parent conditional found for linked field: $linkedFieldQuestion"); } diff --git a/config/wri/entity-merge-mapping.php b/config/wri/entity-merge-mapping.php index 593e507aa..63616ca73 100644 --- a/config/wri/entity-merge-mapping.php +++ b/config/wri/entity-merge-mapping.php @@ -7,13 +7,15 @@ 'terrafund' => [ 'properties' => [ // Skip 'name' because the merged site keeps its name - 'start_date' => 'date:first', - 'end_date' => 'date:last', + // Last minute decision was made to let these three keep their values from the base site, but + // the implementation for these commands is complete. + // 'start_date' => 'date:first', + // 'end_date' => 'date:last', + // 'hectares_to_restore_goal' => 'sum', 'landscape_community_contribution' => 'long-text', 'boundary_geojson' => 'set-null', 'land_use_types' => 'union', 'restoration_strategy' => 'union', - 'hectares_to_restore_goal' => 'sum', 'land_tenures' => 'union', ], 'relations' => [ From 751a1054331737c2190e57a9b41a80ea60adf77f Mon Sep 17 00:00:00 2001 From: Nathan Curtis Date: Thu, 25 Apr 2024 11:53:35 -0700 Subject: [PATCH 20/30] [TM-836] Bring back the start/end/hectares processing. --- config/wri/entity-merge-mapping.php | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/config/wri/entity-merge-mapping.php b/config/wri/entity-merge-mapping.php index 63616ca73..c386fb19d 100644 --- a/config/wri/entity-merge-mapping.php +++ b/config/wri/entity-merge-mapping.php @@ -7,11 +7,9 @@ 'terrafund' => [ 'properties' => [ // Skip 'name' because the merged site keeps its name - // Last minute decision was made to let these three keep their values from the base site, but - // the implementation for these commands is complete. - // 'start_date' => 'date:first', - // 'end_date' => 'date:last', - // 'hectares_to_restore_goal' => 'sum', + 'start_date' => 'date:first', + 'end_date' => 'date:last', + 'hectares_to_restore_goal' => 'sum', 'landscape_community_contribution' => 'long-text', 'boundary_geojson' => 'set-null', 'land_use_types' => 'union', From e7fbe31dfe9209fdcb0ebfe49dcc26c63322ddb0 Mon Sep 17 00:00:00 2001 From: Nathan Curtis Date: Thu, 25 Apr 2024 12:24:04 -0700 Subject: [PATCH 21/30] [TM-836] Add a quick "all went well" message to the end of the script run. --- app/Console/Commands/MergeEntities.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/Console/Commands/MergeEntities.php b/app/Console/Commands/MergeEntities.php index a15bb44f6..b0f84e343 100644 --- a/app/Console/Commands/MergeEntities.php +++ b/app/Console/Commands/MergeEntities.php @@ -123,6 +123,8 @@ private function mergeSites(Site $mergeSite, Collection $feederSites): void $this->abort('Exception encountered during merge operation, transaction aborted: ' . $e->getMessage()); } + + echo "Merge complete!"; } /** From 4154cbd15b9df2fcb5c3885f7d140dd36b8a2ca7 Mon Sep 17 00:00:00 2001 From: Nathan Curtis Date: Thu, 25 Apr 2024 13:37:47 -0700 Subject: [PATCH 22/30] lint fix --- app/Console/Commands/MergeEntities.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/Console/Commands/MergeEntities.php b/app/Console/Commands/MergeEntities.php index b0f84e343..a8cfb4759 100644 --- a/app/Console/Commands/MergeEntities.php +++ b/app/Console/Commands/MergeEntities.php @@ -124,7 +124,7 @@ private function mergeSites(Site $mergeSite, Collection $feederSites): void $this->abort('Exception encountered during merge operation, transaction aborted: ' . $e->getMessage()); } - echo "Merge complete!"; + echo 'Merge complete!'; } /** From 7a2764ef51a5819ff2ee6053c8d50e2e0d9805e2 Mon Sep 17 00:00:00 2001 From: Nathan Curtis Date: Fri, 26 Apr 2024 11:37:50 -0700 Subject: [PATCH 23/30] [TM-836] Some updates after testing on staging. --- app/Console/Commands/MergeEntities.php | 45 +++++++++++++++++++------- config/wri/entity-merge-mapping.php | 4 +-- 2 files changed, 36 insertions(+), 13 deletions(-) diff --git a/app/Console/Commands/MergeEntities.php b/app/Console/Commands/MergeEntities.php index a8cfb4759..6a3b2642b 100644 --- a/app/Console/Commands/MergeEntities.php +++ b/app/Console/Commands/MergeEntities.php @@ -160,15 +160,24 @@ private function mergeEntities(EntityModel $merge, Collection $feeders): void $this->processProperties(data_get($config, 'properties'), $merge, $feeders); $this->processRelations(data_get($config, 'relations'), $merge, $feeders); + + // Conditionals has to come after properties and relations because it relies on the data for the above being + // accurate. We also want to make sure none of the relations are cached with incorrect values, so a save and + // refresh is appropriate here. + $merge->save(); + $merge->refresh(); $this->processConditionals(data_get($config, 'conditionals'), $merge, $feeders); - // Saving file collections for last because I'm not entirely sure that rolling back the transaction will actually - // undo the spatie media "move", so we want to avoid aborting the process at this point if at all possible. + + // Saving file collections for last because I'm not entirely sure that rolling back the transaction will + // actually undo the spatie media "move", so we want to avoid aborting the process at this point if at all + // possible. $this->processFileCollections(data_get($config, 'file-collections'), $merge, $feeders); $merge->save(); $merge->updateRequests()->delete(); $feeders->each(function ($feeder) { $feeder->updateRequests()->delete(); }); $merge->update([ + 'answers' => $merge->getEntityAnswers($merge->getForm()), 'status' => EntityStatusStateMachine::AWAITING_APPROVAL, 'update_request_status' => UpdateRequest::ENTITY_STATUS_NO_UPDATE, ]); @@ -191,7 +200,7 @@ private function processProperties($properties, $merge, $feeders): void break; case 'long-text': - $texts = $entities->map(fn ($entity) => $entity->$property); + $texts = $entities->map(fn ($entity) => $entity->$property)->filter(); $merge->$property = $texts->join("\n\n"); break; @@ -255,10 +264,9 @@ private function processRelations($relations, $merge, $feeders): void */ private function processConditionals($conditionals, $merge, $feeders): void { - // Conditionals are specified differently from the other sets. It's an array of linked field keys. The task of - // this block is to find the conditional that controls the display of that linked field and make sure that it's - // set to "true" if any of entities have it set to true. We also want to clear out the answers fiend from the - // merged entity because most of it is incorrect at this point (aside from what we set in this block). + // Some of the reports that are merging in are "migrated" models, which means that we can't rely on their + // answers field as a source of truth. Instead, we set the conditional to true if the field that it hides + // has any content. $answers = []; if (! empty($conditionals)) { $form = $merge->getForm(); @@ -268,7 +276,8 @@ private function processConditionals($conditionals, $merge, $feeders): void ->map(fn ($section) => $section->questions) ->flatten() ->mapWithKeys(fn ($question) => [$question->uuid => $question]); - foreach ($conditionals as $linkedField) { + + foreach ($conditionals as $linkedField => $commandSpec) { $linkedFieldQuestion = $questions->first(fn ($question) => $question->linked_field_key == $linkedField); if ($linkedFieldQuestion == null) { throw new Exception("No question found for linked field: $linkedFieldQuestion"); @@ -285,8 +294,22 @@ private function processConditionals($conditionals, $merge, $feeders): void throw new Exception("Parent of linked field question is not a conditional: $linkedFieldQuestion"); } - $answers[$conditional->uuid] = data_get($merge->answers, $conditional->uuid) || - $feeders->contains(fn ($feeder) => data_get($feeder->answers, $conditional->uuid)); + $commandParts = explode(':', $commandSpec); + $command = array_shift($commandParts); + switch ($command) { + case 'has-relation': + $property = $commandParts[0]; + $answers[$conditional->uuid] = $merge->$property()->count() > 0; + break; + + case 'has-text': + $property = $commandParts[0]; + $answers[$conditional->uuid] = !empty($merge->$property); + break; + + default: + throw new Exception("Unknown conditionals command: $command"); + } } } $merge->answers = $answers; @@ -342,7 +365,7 @@ private function ensureUniqueString(string $property, Collection $texts): ?strin } if ($unique->count() > 1) { - throw new Exception("Property required to be unique as is not: $property, " . json_encode($unique)); + throw new Exception("Property required to be unique is not: $property, " . json_encode($unique)); } return $unique->first(); diff --git a/config/wri/entity-merge-mapping.php b/config/wri/entity-merge-mapping.php index c386fb19d..c3715af82 100644 --- a/config/wri/entity-merge-mapping.php +++ b/config/wri/entity-merge-mapping.php @@ -42,8 +42,8 @@ 'photos' => 'move-to-merged', ], 'conditionals' => [ - 'site-rep-rel-disturbances', - 'site-rep-technical-narrative', + 'site-rep-rel-disturbances' => 'has-relation:disturbances', + 'site-rep-technical-narrative' => 'has-text:technical_narrative', ] ] ] From aa8d62c7c949bbd5541c285b69c4e1be157ac918 Mon Sep 17 00:00:00 2001 From: Nathan Curtis Date: Fri, 26 Apr 2024 12:54:45 -0700 Subject: [PATCH 24/30] [TM-836] Make sure property changes get persisted before moving on to relations. --- app/Console/Commands/MergeEntities.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/Console/Commands/MergeEntities.php b/app/Console/Commands/MergeEntities.php index 6a3b2642b..2601a54e8 100644 --- a/app/Console/Commands/MergeEntities.php +++ b/app/Console/Commands/MergeEntities.php @@ -232,6 +232,9 @@ private function processProperties($properties, $merge, $feeders): void throw new Exception("Unknown properties command: $command"); } } + + // Make sure any property changes don't get wiped out be a refresh() further down the migration process. + $merge->save(); } /** From 1b12ca4e7f62c052dc946bcb01b05fc036720c75 Mon Sep 17 00:00:00 2001 From: Nathan Curtis Date: Fri, 26 Apr 2024 13:05:26 -0700 Subject: [PATCH 25/30] [TM-836] Rename variable for clarity. --- app/Console/Commands/MergeEntities.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/Console/Commands/MergeEntities.php b/app/Console/Commands/MergeEntities.php index 2601a54e8..93e0ab1c2 100644 --- a/app/Console/Commands/MergeEntities.php +++ b/app/Console/Commands/MergeEntities.php @@ -323,13 +323,13 @@ private function processConditionals($conditionals, $merge, $feeders): void */ private function processFileCollections($fileCollections, $merge, $feeders): void { - foreach ($fileCollections ?? [] as $property => $commandSpec) { + foreach ($fileCollections ?? [] as $collection => $commandSpec) { $commandParts = explode(':', $commandSpec); $command = array_shift($commandParts); switch ($command) { case 'move-to-merged': /** @var MediaModel $merge */ - $this->moveMedia($property, $merge, $feeders); + $this->moveMedia($collection, $merge, $feeders); break; From 280a9931c6f45bfad3b78e9d50b6aca5962fd5a7 Mon Sep 17 00:00:00 2001 From: Nathan Curtis Date: Fri, 26 Apr 2024 14:48:17 -0700 Subject: [PATCH 26/30] [TM-836] Move over "file" collection photos as well. --- config/wri/entity-merge-mapping.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/config/wri/entity-merge-mapping.php b/config/wri/entity-merge-mapping.php index c3715af82..be1a7fee5 100644 --- a/config/wri/entity-merge-mapping.php +++ b/config/wri/entity-merge-mapping.php @@ -40,6 +40,9 @@ ], 'file-collections' => [ 'photos' => 'move-to-merged', + // It doesn't appear to be possible for a TF site or site report to create a photo in this + // collection today, but there are historical collections that have them. + 'file' => 'move-to-merged', ], 'conditionals' => [ 'site-rep-rel-disturbances' => 'has-relation:disturbances', From 2d57183884f69730e8f4c073403c3356f4d0d4f4 Mon Sep 17 00:00:00 2001 From: Nathan Curtis Date: Fri, 26 Apr 2024 15:03:53 -0700 Subject: [PATCH 27/30] [TM-836] Lint fix --- app/Console/Commands/MergeEntities.php | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/app/Console/Commands/MergeEntities.php b/app/Console/Commands/MergeEntities.php index 93e0ab1c2..4ade8b8b6 100644 --- a/app/Console/Commands/MergeEntities.php +++ b/app/Console/Commands/MergeEntities.php @@ -124,7 +124,7 @@ private function mergeSites(Site $mergeSite, Collection $feederSites): void $this->abort('Exception encountered during merge operation, transaction aborted: ' . $e->getMessage()); } - echo 'Merge complete!'; + echo 'Merge complete!\n\n'; } /** @@ -303,11 +303,13 @@ private function processConditionals($conditionals, $merge, $feeders): void case 'has-relation': $property = $commandParts[0]; $answers[$conditional->uuid] = $merge->$property()->count() > 0; + break; case 'has-text': $property = $commandParts[0]; - $answers[$conditional->uuid] = !empty($merge->$property); + $answers[$conditional->uuid] = ! empty($merge->$property); + break; default: From 66ebc6fc288bd46c0272933ec57aa601e6ca12cd Mon Sep 17 00:00:00 2001 From: Nathan Curtis Date: Fri, 26 Apr 2024 15:46:34 -0700 Subject: [PATCH 28/30] [TM-836] Simpler media move. --- app/Console/Commands/MergeEntities.php | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/app/Console/Commands/MergeEntities.php b/app/Console/Commands/MergeEntities.php index 4ade8b8b6..bfa8205d7 100644 --- a/app/Console/Commands/MergeEntities.php +++ b/app/Console/Commands/MergeEntities.php @@ -121,10 +121,10 @@ private function mergeSites(Site $mergeSite, Collection $feederSites): void } catch (Exception $e) { DB::rollBack(); - $this->abort('Exception encountered during merge operation, transaction aborted: ' . $e->getMessage()); + $this->abort('Exception encountered during merge operation, transaction aborted: ' . $e->getMessage() . "\n\n"); } - echo 'Merge complete!\n\n'; + echo "Merge complete!\n\n"; } /** @@ -160,18 +160,15 @@ private function mergeEntities(EntityModel $merge, Collection $feeders): void $this->processProperties(data_get($config, 'properties'), $merge, $feeders); $this->processRelations(data_get($config, 'relations'), $merge, $feeders); + $this->processFileCollections(data_get($config, 'file-collections'), $merge, $feeders); - // Conditionals has to come after properties and relations because it relies on the data for the above being - // accurate. We also want to make sure none of the relations are cached with incorrect values, so a save and - // refresh is appropriate here. + // Conditionals has to come after the other sets because it relies on the data for the above being accurate. We + // also want to make sure none of the relations are cached with incorrect values, so a save and refresh is + // appropriate here. $merge->save(); $merge->refresh(); $this->processConditionals(data_get($config, 'conditionals'), $merge, $feeders); - // Saving file collections for last because I'm not entirely sure that rolling back the transaction will - // actually undo the spatie media "move", so we want to avoid aborting the process at this point if at all - // possible. - $this->processFileCollections(data_get($config, 'file-collections'), $merge, $feeders); $merge->save(); $merge->updateRequests()->delete(); @@ -414,7 +411,10 @@ private function moveMedia(string $collection, MediaModel $merge, Collection $fe foreach ($feeders as $feeder) { /** @var Media $media */ foreach ($feeder->getMedia($collection) as $media) { - $media->move($merge, $collection); + // Spatie as a "move" method, but it tries to download, copy, upload and then remove the original media. + // It appears to be kosher for us to just move the DB association, which is both faster and testable on + // staging. + $media->update(['model_id' => $merge->id]); } } } From 7980008c9b0c2a09f20d056f387042adf7415c79 Mon Sep 17 00:00:00 2001 From: Nathan Curtis Date: Fri, 26 Apr 2024 16:06:06 -0700 Subject: [PATCH 29/30] [TM-836] Make sure to catch "file" media in sites as well. --- config/wri/entity-merge-mapping.php | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/config/wri/entity-merge-mapping.php b/config/wri/entity-merge-mapping.php index be1a7fee5..a9e2961e8 100644 --- a/config/wri/entity-merge-mapping.php +++ b/config/wri/entity-merge-mapping.php @@ -21,6 +21,9 @@ ], 'file-collections' => [ 'photos' => 'move-to-merged', + // It doesn't appear to be possible for a TF site or site report to create a photo in any + // collection aside from 'photos' today, but there are historical collections that have them. + 'file' => 'move-to-merged', ], ] ] @@ -40,8 +43,8 @@ ], 'file-collections' => [ 'photos' => 'move-to-merged', - // It doesn't appear to be possible for a TF site or site report to create a photo in this - // collection today, but there are historical collections that have them. + // It doesn't appear to be possible for a TF site or site report to create a photo in any + // collection aside from 'photos' today, but there are historical collections that have them. 'file' => 'move-to-merged', ], 'conditionals' => [ From f551df47ecffe684aa8b18357fcacfc10a38138c Mon Sep 17 00:00:00 2001 From: Nathan Curtis Date: Tue, 30 Apr 2024 15:48:09 -0700 Subject: [PATCH 30/30] [TM-836] Handle null dates correctly. --- app/Console/Commands/MergeEntities.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/Console/Commands/MergeEntities.php b/app/Console/Commands/MergeEntities.php index bfa8205d7..4c2a0b420 100644 --- a/app/Console/Commands/MergeEntities.php +++ b/app/Console/Commands/MergeEntities.php @@ -191,7 +191,9 @@ private function processProperties($properties, $merge, $feeders): void $command = array_shift($commandParts); switch ($command) { case 'date': - $dates = $entities->map(fn ($entity) => Carbon::parse($entity->$property)); + $dates = $entities + ->map(fn ($entity) => empty($entity->$property) ? null : Carbon::parse($entity->$property)) + ->filter(); $merge->$property = $this->mergeDates($dates, ...$commandParts); break;