diff --git a/app/Console/Commands/Migrations/MigrateOrganisationLocations.php b/app/Console/Commands/Migrations/MigrateOrganisationLocations.php new file mode 100644 index 0000000000..7262629eab --- /dev/null +++ b/app/Console/Commands/Migrations/MigrateOrganisationLocations.php @@ -0,0 +1,56 @@ +chunk(1000, function ($organisations) { + $this->info('1000 Chunk...'); + foreach ($organisations as $organisation) { + $organisation->locations()->attach($organisation->location_id); + $this->count++; + } + }); + + $this->info('Migrated ' . $this->count . ' organisations'); + return 0; + } +} diff --git a/app/Datagrids/Bulks/CreatureBulk.php b/app/Datagrids/Bulks/CreatureBulk.php index 4fbfb1c6bf..7b83aa6be6 100644 --- a/app/Datagrids/Bulks/CreatureBulk.php +++ b/app/Datagrids/Bulks/CreatureBulk.php @@ -8,6 +8,7 @@ class CreatureBulk extends Bulk 'name', 'type', 'creature_id', + 'locations', 'tags', 'private_choice', 'extinct_choice', diff --git a/app/Datagrids/Bulks/OrganisationBulk.php b/app/Datagrids/Bulks/OrganisationBulk.php index 30fe6b6574..854af2db21 100644 --- a/app/Datagrids/Bulks/OrganisationBulk.php +++ b/app/Datagrids/Bulks/OrganisationBulk.php @@ -7,7 +7,7 @@ class OrganisationBulk extends Bulk protected array $fields = [ 'name', 'type', - 'location_id', + 'locations', 'organisation_id', 'tags', 'private_choice', diff --git a/app/Datagrids/Bulks/RaceBulk.php b/app/Datagrids/Bulks/RaceBulk.php index d2feef01f2..d51f52bb3c 100644 --- a/app/Datagrids/Bulks/RaceBulk.php +++ b/app/Datagrids/Bulks/RaceBulk.php @@ -8,6 +8,7 @@ class RaceBulk extends Bulk 'name', 'type', 'race_id', + 'locations', 'tags', 'private_choice', 'entity_image', diff --git a/app/Http/Controllers/Api/v1/OrganisationApiController.php b/app/Http/Controllers/Api/v1/OrganisationApiController.php index 0569a66fa0..f61e895478 100644 --- a/app/Http/Controllers/Api/v1/OrganisationApiController.php +++ b/app/Http/Controllers/Api/v1/OrganisationApiController.php @@ -47,6 +47,7 @@ public function store(Request $request, Campaign $campaign) $data['campaign_id'] = $campaign->id; $model = Organisation::create($data); $this->crudSave($model); + $model->refresh(); return new Resource($model); } diff --git a/app/Http/Requests/StoreCreature.php b/app/Http/Requests/StoreCreature.php index 96454e81e0..512bd25498 100644 --- a/app/Http/Requests/StoreCreature.php +++ b/app/Http/Requests/StoreCreature.php @@ -36,6 +36,8 @@ public function rules() 'image' => 'mimes:jpeg,png,jpg,gif,webp|max:' . Limit::upload(), 'image_url' => 'nullable|url|active_url', 'template_id' => 'nullable', + 'locations' => 'array', + 'locations.*' => 'distinct|exists:locations,id', ]; /** @var Creature $self */ diff --git a/app/Http/Requests/StoreOrganisation.php b/app/Http/Requests/StoreOrganisation.php index 103f5369e6..6459a8c47f 100644 --- a/app/Http/Requests/StoreOrganisation.php +++ b/app/Http/Requests/StoreOrganisation.php @@ -33,10 +33,11 @@ public function rules() 'entry' => 'nullable|string', 'type' => 'nullable|string|max:191', 'image' => 'mimes:jpeg,png,jpg,gif,webp|max:' . Limit::upload(), - 'location_id' => 'nullable|integer|exists:locations,id', 'organisation_id' => 'nullable|exists:organisations,id', 'image_url' => 'nullable|url|active_url', 'template_id' => 'nullable', + 'locations' => 'array', + 'locations.*' => 'distinct|exists:locations,id', ]; /** @var Organisation $self */ diff --git a/app/Http/Requests/StoreRace.php b/app/Http/Requests/StoreRace.php index a029ee82e9..8b9b39acc8 100644 --- a/app/Http/Requests/StoreRace.php +++ b/app/Http/Requests/StoreRace.php @@ -36,6 +36,8 @@ public function rules() 'image' => 'mimes:jpeg,png,jpg,gif,webp|max:' . Limit::upload(), 'image_url' => 'nullable|url|active_url', 'template_id' => 'nullable', + 'locations' => 'array', + 'locations.*' => 'distinct|exists:locations,id', ]; /** @var Race $self */ diff --git a/app/Http/Resources/OrganisationResource.php b/app/Http/Resources/OrganisationResource.php index 097bc4e403..3621c19208 100644 --- a/app/Http/Resources/OrganisationResource.php +++ b/app/Http/Resources/OrganisationResource.php @@ -16,11 +16,13 @@ public function toArray($request) { /** @var Organisation $model */ $model = $this->resource; + $locationIDs = $model->locations()->pluck('locations.id'); return $this->entity([ 'type' => $model->type, 'organisation_id' => $model->organisation_id, 'is_defunct' => (bool) $model->is_defunct, - 'members' => OrganisationMemberResource::collection($model->members()->has('character')->with('character')->get()) + 'members' => OrganisationMemberResource::collection($model->members()->has('character')->with('character')->get()), + 'locations' => $locationIDs ]); } } diff --git a/app/Models/Organisation.php b/app/Models/Organisation.php index a8646c9a6b..e9f45395a1 100644 --- a/app/Models/Organisation.php +++ b/app/Models/Organisation.php @@ -25,6 +25,7 @@ * @property Collection|OrganisationMember[] $members * @property Collection|Organisation[] $descendants * @property Collection|Organisation[] $organisations + * @property Collection|Location[] $locations * @property bool $is_defunct */ class Organisation extends MiscModel @@ -43,7 +44,6 @@ class Organisation extends MiscModel 'name', 'slug', 'entry', - 'location_id', 'organisation_id', 'type', 'is_private', @@ -66,7 +66,6 @@ class Organisation extends MiscModel * Fields that can be sorted on */ protected array $sortableColumns = [ - 'location.name', 'is_defunct', ]; @@ -75,11 +74,11 @@ class Organisation extends MiscModel */ protected array $foreignExport = [ 'members', + 'pivotLocations', ]; protected array $exportFields = [ 'base', - 'location_id', 'is_defunct', ]; @@ -90,7 +89,6 @@ class Organisation extends MiscModel * @var string[] */ public array $nullableForeignKeys = [ - 'location_id', 'organisation_id' ]; @@ -105,8 +103,9 @@ public function scopePreparedWith(Builder $query): Builder ->with([ 'entity', 'entity.image', - 'location', - 'location.entity', + 'locations' => function ($sub) { + $sub->select('id', 'name'); + }, 'parent' => function ($sub) { $sub->select('id', 'name'); }, @@ -121,6 +120,40 @@ public function scopePreparedWith(Builder $query): Builder ]); } + /** + * Filter on organisations in specific locations + */ + public function scopeLocation(Builder $query, int|null $location, FilterOption $filter): Builder + { + if ($filter === FilterOption::NONE) { + if (!empty($location)) { + return $query; + } + return $query + ->whereRaw('(select count(*) from organisation_location as ol where ol.organisation_id = ' . + $this->getTable() . '.id and ol.location_id = ' . ((int) $location) . ') = 0'); + } elseif ($filter === FilterOption::EXCLUDE) { + return $query + ->whereRaw('(select count(*) from organisation_location as ol where ol.organisation_id = ' . + $this->getTable() . '.id and ol.location_id = ' . ((int) $location) . ') = 0'); + } + + $ids = [$location]; + if ($filter === FilterOption::CHILDREN) { + /** @var Location|null $model */ + $model = Location::find($location); + if (!empty($model)) { + $ids = [...$model->descendants->pluck('id')->toArray(), $model->id]; + } + } + return $query + ->select($this->getTable() . '.*') + ->leftJoin('organisation_location as ol', function ($join) { + $join->on('ol.organisation_id', '=', $this->getTable() . '.id'); + }) + ->whereIn('ol.location_id', $ids)->distinct(); + } + /** * Filter for organisations with specific member */ @@ -204,6 +237,20 @@ public function location() return $this->belongsTo('App\Models\Location', 'location_id', 'id'); } + /** + * Organisations have multiple locations + */ + public function locations() + { + return $this->belongsToMany('App\Models\Location', 'organisation_location') + ->with('entity'); + } + + public function pivotLocations() + { + return $this->hasMany('App\Models\OrganisationLocation'); + } + /** * @return \Illuminate\Database\Eloquent\Relations\HasMany */ @@ -281,7 +328,7 @@ public function entityTypeId(): int */ public function showProfileInfo(): bool { - return !empty($this->type) || !empty($this->location) || !$this->entity->elapsedEvents->isEmpty(); + return !empty($this->type) || !empty($this->location) || !$this->entity->elapsedEvents->isEmpty() || $this->locations->isNotEmpty(); } /** diff --git a/app/Models/OrganisationLocation.php b/app/Models/OrganisationLocation.php new file mode 100644 index 0000000000..de06cfc58a --- /dev/null +++ b/app/Models/OrganisationLocation.php @@ -0,0 +1,48 @@ +belongsTo('App\Models\Organisation', 'organisation_id', 'id'); + } + + /** + * @return \Illuminate\Database\Eloquent\Relations\BelongsTo + */ + public function location() + { + return $this->belongsTo('App\Models\Location', 'location_id', 'id'); + } + + public function exportFields(): array + { + return ['location_id']; + } +} diff --git a/app/Observers/Concerns/HasLocations.php b/app/Observers/Concerns/HasLocations.php index 4a0bba760d..0bce016912 100644 --- a/app/Observers/Concerns/HasLocations.php +++ b/app/Observers/Concerns/HasLocations.php @@ -10,7 +10,7 @@ trait HasLocations { /** */ - public function saveLocations(MiscModel|Creature $model) + public function saveLocations(MiscModel|Creature $model, array $locations = []) { /** @var Creature $model */ $existing = $unique = $recreate = []; @@ -28,11 +28,13 @@ public function saveLocations(MiscModel|Creature $model) if (!empty($recreate)) { $model->locations()->attach($recreate); } - - $locations = request()->get('locations', []); + if (!$locations) { + $locations = request()->get('locations', []); + $detach = true; + } $newLocations = []; foreach ($locations as $id) { - // Existing race, do nothing + // Existing location, do nothing if (!empty($existing[$id])) { unset($existing[$id]); continue; @@ -51,7 +53,7 @@ public function saveLocations(MiscModel|Creature $model) $model->locations()->attach($newLocations); // Detach the remaining - if (!empty($existing)) { + if (!empty($existing) && isset($detach)) { $model->locations()->detach($existing); } } diff --git a/app/Observers/OrganisationObserver.php b/app/Observers/OrganisationObserver.php index de3b2fb8c0..727de4a658 100644 --- a/app/Observers/OrganisationObserver.php +++ b/app/Observers/OrganisationObserver.php @@ -5,14 +5,27 @@ use App\Models\Character; use App\Models\Organisation; use App\Models\OrganisationMember; +use App\Observers\Concerns\HasLocations; class OrganisationObserver extends MiscObserver { + use HasLocations; + public function saved(Organisation $organisation) { $this->saveMembers($organisation); } + /** + */ + public function crudSaved(Organisation $organisation) + { + if (!request()->has('save_locations') && !request()->has('locations')) { + return; + } + $this->saveLocations($organisation); + } + /** */ public function deleting(Organisation $organisation) diff --git a/app/Services/BulkService.php b/app/Services/BulkService.php index d6bcef43f7..2f8c74e6f0 100644 --- a/app/Services/BulkService.php +++ b/app/Services/BulkService.php @@ -16,11 +16,13 @@ use App\Models\MiscModel; use Exception; use Illuminate\Support\Str; +use App\Observers\Concerns\HasLocations; use Stevebauman\Purify\Facades\Purify; class BulkService { use CampaignAware; + use HasLocations; protected EntityService $entityService; @@ -275,6 +277,10 @@ public function editing(array $fields, Bulk $bulk): int unset($filledFields['tags']); $tagIds = Arr::get($fields, 'tags', []); + // Handle locations differently + unset($filledFields['locations']); + $locationIds = Arr::get($fields, 'locations', []); + // Handle images differently if (isset($filledFields['entity_image'])) { $imageUuid = $filledFields['entity_image']; @@ -351,6 +357,13 @@ public function editing(array $fields, Bulk $bulk): int $this->count++; + $locationsAction = Arr::get($fields, 'bulk-locations', 'add'); + if ($locationsAction === 'remove') { + $entity->locations()->detach($locationIds); + } else { + $this->saveLocations($entity, $locationIds); + } + // No tags? We're done if (empty($fields['tags'])) { continue; diff --git a/app/Services/Campaign/Import/Mappers/EntityMapper.php b/app/Services/Campaign/Import/Mappers/EntityMapper.php index c6176b3bb6..288d2a4fa5 100644 --- a/app/Services/Campaign/Import/Mappers/EntityMapper.php +++ b/app/Services/Campaign/Import/Mappers/EntityMapper.php @@ -348,6 +348,10 @@ protected function foreign(string $model, string $field): self protected function pivot(string $relation, string $model, string $field): self { + //Check if import has old location_id and migrate it to new locations pivot table system, currently only happens with organisations + if ($relation == 'pivotLocations' && isset($this->data['location_id']) && !in_array(['location_id' => $this->data['location_id']], $this->data[$relation])) { + $this->data[$relation][] = ['location_id' => $this->data['location_id']]; + } foreach ($this->data[$relation] as $pivot) { if (!ImportIdMapper::has($model, $pivot[$field])) { continue; diff --git a/app/Services/Campaign/Import/Mappers/OrganisationMapper.php b/app/Services/Campaign/Import/Mappers/OrganisationMapper.php index 2e7f852598..1085f5a7ed 100644 --- a/app/Services/Campaign/Import/Mappers/OrganisationMapper.php +++ b/app/Services/Campaign/Import/Mappers/OrganisationMapper.php @@ -6,7 +6,7 @@ class OrganisationMapper extends MiscMapper { - protected array $ignore = ['id', 'campaign_id', 'slug', 'image', '_lft', '_rgt', 'organisation_id', 'location_id', 'created_at', 'updated_at']; + protected array $ignore = ['id', 'campaign_id', 'slug', 'image', '_lft', '_rgt', 'organisation_id', 'created_at', 'updated_at', 'location_id']; protected string $className = Organisation::class; protected string $mappingName = 'organisations'; @@ -22,7 +22,7 @@ public function second(): void { $this ->loadModel() - ->foreign('locations', 'location_id') + ->pivot('pivotLocations', 'locations', 'location_id') ->saveModel() ->entitySecond(); } diff --git a/app/Services/Entity/ConnectionService.php b/app/Services/Entity/ConnectionService.php index 88a50eeae1..d736f38467 100644 --- a/app/Services/Entity/ConnectionService.php +++ b/app/Services/Entity/ConnectionService.php @@ -118,7 +118,18 @@ protected function initRace() { $this ->loadChildRaces() - ->loadRaceLocations() + ->loadLocations() + ; + } + + protected function initOrganisation() + { + $this + ->loadMapMarkers() + ->loadLocations() + ->loadTimelines() + ->loadQuests() + ->loadAuthoredJournals() ; } @@ -308,7 +319,7 @@ protected function loadChildRaces(): self return $this; } - protected function loadRaceLocations(): self + protected function loadLocations(): self { /** @var Location $parent */ $parent = $this->entity->child; diff --git a/app/Services/Entity/EntityRelationService.php b/app/Services/Entity/EntityRelationService.php index bb79bb30f0..b6581158d2 100644 --- a/app/Services/Entity/EntityRelationService.php +++ b/app/Services/Entity/EntityRelationService.php @@ -467,7 +467,7 @@ protected function initOrganisation(): self $this ->addParent() ->addOrganisations() - ->addLocation() + ->addLocations() ->addQuests() ->addMapMarkers() ->addAuthorJournals(); diff --git a/app/Services/Entity/TransformService.php b/app/Services/Entity/TransformService.php index aad6a6c69e..b017a5e4ba 100644 --- a/app/Services/Entity/TransformService.php +++ b/app/Services/Entity/TransformService.php @@ -8,7 +8,6 @@ use App\Models\Entity; use App\Models\Post; use App\Models\MiscModel; -use App\Models\EntityLog; use App\Models\OrganisationMember; use App\Traits\EntityAware; use Illuminate\Support\Str; @@ -94,9 +93,10 @@ protected function locations(): self { $raceID = config('entities.ids.race'); $creatureID = config('entities.ids.creature'); + $organisationID = config('entities.ids.organisation'); //If the entity is switched from one location to multiple locations - if (!in_array($this->child->entityTypeId(), [$raceID, $creatureID]) && in_array($this->new->entityTypeId(), [$raceID, $creatureID])) { + if (!in_array($this->child->entityTypeId(), [$raceID, $creatureID, $organisationID]) && in_array($this->new->entityTypeId(), [$raceID, $creatureID, $organisationID])) { if (in_array('location_id', $this->child->getFillable()) && !empty($this->child->location_id)) { // @phpstan-ignore-next-line $this->new->locations()->attach($this->child->location_id); @@ -108,8 +108,8 @@ protected function locations(): self } if ( - !in_array($this->child->entityTypeId(), [$raceID, $creatureID]) || - !in_array($this->new->entityTypeId(), [$raceID, $creatureID]) + !in_array($this->child->entityTypeId(), [$raceID, $creatureID, $organisationID]) || + !in_array($this->new->entityTypeId(), [$raceID, $creatureID, $organisationID]) ) { if (property_exists($this->child, 'locations')) { // @phpstan-ignore-next-line diff --git a/database/migrations/2024_04_15_220412_create_organisation_location_table.php b/database/migrations/2024_04_15_220412_create_organisation_location_table.php new file mode 100644 index 0000000000..f370b8b94e --- /dev/null +++ b/database/migrations/2024_04_15_220412_create_organisation_location_table.php @@ -0,0 +1,29 @@ +unsignedInteger('organisation_id'); + $table->unsignedInteger('location_id'); + + $table->foreign('organisation_id')->references('id')->on('organisations')->cascadeOnDelete(); + $table->foreign('location_id')->references('id')->on('locations')->cascadeOnDelete(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('organisation_location'); + } +}; diff --git a/lang/en/crud.php b/lang/en/crud.php index 9ee720da87..f9f14f946f 100644 --- a/lang/en/crud.php +++ b/lang/en/crud.php @@ -53,6 +53,7 @@ ], 'edit' => [ 'tagging' => 'Action for tags', + 'locations' => 'Action for locations', 'tags' => [ 'add' => 'Add', 'remove' => 'Remove', diff --git a/resources/api-docs/1.0/organisations.md b/resources/api-docs/1.0/organisations.md index 786ef228e7..d4430d5217 100644 --- a/resources/api-docs/1.0/organisations.md +++ b/resources/api-docs/1.0/organisations.md @@ -45,11 +45,15 @@ The list of returned entities can be filtered. The available filters are [availa "created_by": 1, "updated_at": "2019-08-29T13:48:54.000000Z", "updated_by": 1, - "location_id": 4, "organisation_id": 4, "type": "Kingdom", "is_defunct": true, - "members": [] + "members": [], + "locations": [ + 67, + 66, + 65 + ] } ] } @@ -82,11 +86,15 @@ To get the details of a single organisation, use the following endpoint. "created_by": 1, "updated_at": "2019-08-29T13:48:54.000000Z", "updated_by": 1, - "location_id": 4, "organisation_id": 4, "type": "Kingdom", "is_defunct": true, - "members": [] + "members": [], + "locations": [ + 67, + 66, + 65 + ] } } @@ -141,7 +149,7 @@ To create an organisation, use the following endpoint. | `entry` | `string` | The html description of the organisation | | `type` | `string` | Type of organisation | | `organisation_id` | `integer` | The parent organisation | -| `location_id` | `integer` | The organisation's location | +| `locations` | `array` | Array of location ids | | `tags` | `array` | Array of tag ids | | `is_defunct` | `boolean` | If the organisation is defunct | | `image_url` | `string` | URL to a picture to be used for the organisation || `entity_image_uuid` | `string` | Gallery image UUID for the entity image (limited to superboosted campaigns) | diff --git a/resources/views/cruds/fields/locations.blade.php b/resources/views/cruds/fields/locations.blade.php index 864df50e7a..88dd4a4cc7 100644 --- a/resources/views/cruds/fields/locations.blade.php +++ b/resources/views/cruds/fields/locations.blade.php @@ -1,7 +1,9 @@ @if (!$campaign->enabled('locations')) @endif - +@if (isset($bulk) && $bulk) +