From a777ccf250ebd8feeb49a1e05363cebfe6dfe0b8 Mon Sep 17 00:00:00 2001 From: Nathan Curtis Date: Wed, 24 Apr 2024 15:25:14 -0700 Subject: [PATCH 01/15] [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 e7fef85e673b3417ea3355d8f52c80ef8df52d42 Mon Sep 17 00:00:00 2001 From: Nathan Curtis Date: Wed, 24 Apr 2024 16:28:25 -0700 Subject: [PATCH 02/15] [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 89c2a1fcf54ca807d9194a4456aa44f6554a5a90 Mon Sep 17 00:00:00 2001 From: Nathan Curtis Date: Thu, 25 Apr 2024 11:09:00 -0700 Subject: [PATCH 03/15] [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 3f28d97ff3f22e562efdc7083c8dc16e6d92b9bd Mon Sep 17 00:00:00 2001 From: Nathan Curtis Date: Thu, 25 Apr 2024 11:38:02 -0700 Subject: [PATCH 04/15] [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 10563d0b4315a95cebb2f6b33254eb701b4c2829 Mon Sep 17 00:00:00 2001 From: Nathan Curtis Date: Thu, 25 Apr 2024 11:53:35 -0700 Subject: [PATCH 05/15] [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 4110a4e12e4c8ce21d8b423cc6ccc25097b6ba45 Mon Sep 17 00:00:00 2001 From: Nathan Curtis Date: Thu, 25 Apr 2024 12:24:04 -0700 Subject: [PATCH 06/15] [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 fcaecd8665d93c51e2a125436da4ba3e18300784 Mon Sep 17 00:00:00 2001 From: Nathan Curtis Date: Thu, 25 Apr 2024 13:37:47 -0700 Subject: [PATCH 07/15] 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 355a7f2bc3240332061fbbd7af2f349dff57e68d Mon Sep 17 00:00:00 2001 From: Nathan Curtis Date: Fri, 26 Apr 2024 11:37:50 -0700 Subject: [PATCH 08/15] [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 aa2f8516b68db7b2a79c11ac68c2c0ed8e4652ac Mon Sep 17 00:00:00 2001 From: Nathan Curtis Date: Fri, 26 Apr 2024 12:54:45 -0700 Subject: [PATCH 09/15] [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 e85e9af52cf62b1a9de715ce7a8d6f5596d7b387 Mon Sep 17 00:00:00 2001 From: Nathan Curtis Date: Fri, 26 Apr 2024 13:05:26 -0700 Subject: [PATCH 10/15] [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 6019098675d6462cb38cc1646161308016da772e Mon Sep 17 00:00:00 2001 From: Nathan Curtis Date: Fri, 26 Apr 2024 14:48:17 -0700 Subject: [PATCH 11/15] [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 904b734e9dd7b13b9502929ab30d468eccdb5b9f Mon Sep 17 00:00:00 2001 From: Nathan Curtis Date: Fri, 26 Apr 2024 15:03:53 -0700 Subject: [PATCH 12/15] [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 dca5ef38efb41d03bb7b440cb2c51a8dd4e197f6 Mon Sep 17 00:00:00 2001 From: Nathan Curtis Date: Fri, 26 Apr 2024 15:46:34 -0700 Subject: [PATCH 13/15] [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 897a3ae413740fa24ffe102de5570348c83d696f Mon Sep 17 00:00:00 2001 From: Nathan Curtis Date: Fri, 26 Apr 2024 16:06:06 -0700 Subject: [PATCH 14/15] [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 aaa4e0da7843b5da8f9776e1482ed18fda8fb918 Mon Sep 17 00:00:00 2001 From: Nathan Curtis Date: Tue, 30 Apr 2024 15:48:09 -0700 Subject: [PATCH 15/15] [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;