mirror of
https://github.com/HolgerHatGarKeineNode/einundzwanzig-app.git
synced 2026-06-17 04:30:31 +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);
|
||||
}
|
||||
|
||||
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