mirror of
https://github.com/HolgerHatGarKeineNode/einundzwanzig-app.git
synced 2026-06-22 06:10:30 +00:00
✨ Introduce places:cleanup command to remove unused venues and cities
- 🧹 Add `places:cleanup` console command for dry-run and forced deletion of venues (without course/bitcoin events) and cities (without venues/meetups). - 🧪 Add feature tests for `places:cleanup`, covering dry-run, forced deletion, and scenarios ensuring retention of dependent records. - ➕ Add `bitcoinEvents` relationship to `Venue` model to support cleanup logic.
This commit is contained in:
@@ -0,0 +1,81 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands\Database;
|
||||||
|
|
||||||
|
use App\Models\City;
|
||||||
|
use App\Models\Venue;
|
||||||
|
use Illuminate\Console\Attributes\Description;
|
||||||
|
use Illuminate\Console\Attributes\Signature;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
|
||||||
|
#[Signature('places:cleanup {--force : Actually delete instead of doing a dry-run}')]
|
||||||
|
#[Description('Delete venues without any course or bitcoin events, then cities without any venues or meetups.')]
|
||||||
|
class CleanupPlaces extends Command
|
||||||
|
{
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
$force = (bool) $this->option('force');
|
||||||
|
|
||||||
|
if ($force && ! $this->confirm('This permanently deletes venues and cities on this database. Continue?', false)) {
|
||||||
|
$this->warn('Aborted.');
|
||||||
|
|
||||||
|
return Command::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Venues first: removing unused venues can make their city deletable in the same run.
|
||||||
|
$venues = Venue::query()
|
||||||
|
->whereDoesntHave('courseEvents')
|
||||||
|
->whereDoesntHave('bitcoinEvents')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$this->deleteAll('venue', $venues, $force);
|
||||||
|
|
||||||
|
$cities = City::query()
|
||||||
|
->whereDoesntHave('venues')
|
||||||
|
->whereDoesntHave('meetups')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$this->deleteAll('city', $cities, $force);
|
||||||
|
|
||||||
|
$this->newLine();
|
||||||
|
$this->table(['Type', $force ? 'Deleted' : 'To delete'], [
|
||||||
|
['Venues', $venues->count()],
|
||||||
|
['Cities', $cities->count()],
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (! $force) {
|
||||||
|
$this->comment('Dry-run only. Re-run with --force to apply.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return Command::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param Collection<int, Model> $models
|
||||||
|
*/
|
||||||
|
private function deleteAll(string $label, $models, bool $force): void
|
||||||
|
{
|
||||||
|
if ($models->isEmpty()) {
|
||||||
|
$this->info(sprintf('No unused %s entries to clean up.', $label));
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->info(sprintf(
|
||||||
|
'%s %d unused %s entry/entries.',
|
||||||
|
$force ? 'Deleting' : '[DRY-RUN] Would delete',
|
||||||
|
$models->count(),
|
||||||
|
$label,
|
||||||
|
));
|
||||||
|
|
||||||
|
if (! $force) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ->delete() per model (not a mass delete) so Venue media is removed via its delete hook.
|
||||||
|
$this->withProgressBar($models, fn ($model) => $model->delete());
|
||||||
|
$this->newLine();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -102,4 +102,9 @@ class Venue extends Model implements HasMedia
|
|||||||
{
|
{
|
||||||
return $this->hasMany(CourseEvent::class);
|
return $this->hasMany(CourseEvent::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function bitcoinEvents(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(BitcoinEvent::class);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\BitcoinEvent;
|
||||||
|
use App\Models\City;
|
||||||
|
use App\Models\CourseEvent;
|
||||||
|
use App\Models\Meetup;
|
||||||
|
use App\Models\Venue;
|
||||||
|
|
||||||
|
it('does nothing on a dry-run', function () {
|
||||||
|
$venue = Venue::factory()->create();
|
||||||
|
|
||||||
|
$this->artisan('places:cleanup')->assertExitCode(0);
|
||||||
|
|
||||||
|
expect(Venue::query()->find($venue->id))->not->toBeNull();
|
||||||
|
expect(City::query()->find($venue->city_id))->not->toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deletes unused venues and cities with --force', function () {
|
||||||
|
$venue = Venue::factory()->create();
|
||||||
|
$city = City::query()->find($venue->city_id);
|
||||||
|
|
||||||
|
$this->artisan('places:cleanup', ['--force' => true])
|
||||||
|
->expectsConfirmation('This permanently deletes venues and cities on this database. Continue?', 'yes')
|
||||||
|
->assertExitCode(0);
|
||||||
|
|
||||||
|
expect(Venue::query()->find($venue->id))->toBeNull();
|
||||||
|
expect(City::query()->find($city->id))->toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps venues with course events and bitcoin events', function () {
|
||||||
|
$withCourse = CourseEvent::factory()->create()->venue;
|
||||||
|
$withBitcoin = BitcoinEvent::factory()->create()->venue;
|
||||||
|
|
||||||
|
$this->artisan('places:cleanup', ['--force' => true])
|
||||||
|
->expectsConfirmation('This permanently deletes venues and cities on this database. Continue?', 'yes')
|
||||||
|
->assertExitCode(0);
|
||||||
|
|
||||||
|
expect(Venue::query()->find($withCourse->id))->not->toBeNull();
|
||||||
|
expect(Venue::query()->find($withBitcoin->id))->not->toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps cities with meetups even without venues', function () {
|
||||||
|
$meetup = Meetup::factory()->create();
|
||||||
|
|
||||||
|
$this->artisan('places:cleanup', ['--force' => true])
|
||||||
|
->expectsConfirmation('This permanently deletes venues and cities on this database. Continue?', 'yes')
|
||||||
|
->assertExitCode(0);
|
||||||
|
|
||||||
|
expect(City::query()->find($meetup->city_id))->not->toBeNull();
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user