diff --git a/app/Filament/Resources/SmrAreaResource.php b/app/Filament/Resources/SmrAreaResource.php new file mode 100644 index 000000000..b4cd949a1 --- /dev/null +++ b/app/Filament/Resources/SmrAreaResource.php @@ -0,0 +1,119 @@ +schema([ + Select::make('airfield_id') + ->label(self::translateFormPath('airfield.label')) + ->helperText(self::translateFormPath('airfield.helper')) + ->options(SelectOptions::airfields()) + ->searchable(!App::runningUnitTests()) + ->required(), + TextInput::make('name') + ->label(self::translateFormPath('name.label')) + ->helperText(self::translateFormPath('name.helper')) + ->minLength(1) + ->maxLength(255), + TextInput::make('source') + ->label(self::translateFormPath('source.label')) + ->helperText(self::translateFormPath('source.helper')) + ->minLength(1) + ->maxLength(255), + Textarea::make('coordinates') + ->label(self::translateFormPath('coordinates.label')) + ->helperText(self::translateFormPath('coordinates.helper')) + ->rows(5) + ->required() + // relatively lax validation; a stricter pattern is somewhat unnecessary + ->regex('/^(COORD(:[NESW][\d\.]{13}){2}\n*){3,}$/'), + DateTimePicker::make('start_date') + ->label(self::translateFormPath('start_date.label')) + ->helperText(self::translateFormPath('start_date.helper')), + DateTimePicker::make('end_date') + ->label(self::translateFormPath('end_date.label')) + ->helperText(self::translateFormPath('end_date.helper')) + ->after('start_date'), + ]); + } + + public static function table(Table $table): Table + { + return $table + ->columns([ + Tables\Columns\TextColumn::make('airfield.code') + ->label(self::translateTablePath('columns.airfield')) + ->searchable() + ->sortable(), + Tables\Columns\TextColumn::make('name') + ->label(self::translateTablePath('columns.name')) + ->searchable(), + Tables\Columns\TextColumn::make('source') + ->label(self::translateTablePath('columns.source')) + ->searchable(), + ]) + ->actions([ + Tables\Actions\EditAction::make(), + Tables\Actions\DeleteAction::make(), + ]) + ->filters([ + Tables\Filters\Filter::make('expired') + ->toggle() + ->label(self::translateFilterPath('expired')) + ->query(fn (Builder $query) => $query->expired()), + Tables\Filters\TernaryFilter::make('activation') + ->label(self::translateFilterPath('activation.label')) + ->trueLabel(self::translateFilterPath('activation.true')) + ->falseLabel(self::translateFilterPath('activation.false')) + ->queries( + true: fn (Builder $query) => $query->active(), + false: fn (Builder $query) => $query->whereNot->active(), + blank: fn (Builder $query) => $query, + ), + ]); + } + + public static function getPages(): array + { + return [ + 'index' => Pages\ListSmrAreas::route('/'), + 'create' => Pages\CreateSmrArea::route('/create'), + 'view' => Pages\ViewSmrArea::route('/{record}'), + 'edit' => Pages\EditSmrArea::route('/{record}/edit'), + ]; + } + + protected static function translationPathRoot(): string + { + return 'smr_areas'; + } +} diff --git a/app/Filament/Resources/SmrAreaResource/Pages/CreateSmrArea.php b/app/Filament/Resources/SmrAreaResource/Pages/CreateSmrArea.php new file mode 100644 index 000000000..1e1033dc4 --- /dev/null +++ b/app/Filament/Resources/SmrAreaResource/Pages/CreateSmrArea.php @@ -0,0 +1,11 @@ +pluck("coordinates")->join("\n\n"), + 200, + ["Content-Type" => "text/plain"], + ); + } +} diff --git a/app/Models/SmrArea.php b/app/Models/SmrArea.php new file mode 100644 index 000000000..734fdfcbd --- /dev/null +++ b/app/Models/SmrArea.php @@ -0,0 +1,50 @@ +belongsTo(Airfield::class); + } + + public function scopeActive(Builder $query): Builder + { + return $query + ->where(fn (Builder $query) => $query + ->where('start_date', '<', now()) + ->orWhereNull('start_date')) + ->where(fn (Builder $query) => $query + ->where('end_date', '>', now()) + ->orWhereNull('end_date')); + } + + public function scopeExpired(Builder $query): Builder + { + return $query->where('end_date', '<', now()); + } +} diff --git a/app/Providers/AuthServiceProvider.php b/app/Providers/AuthServiceProvider.php index abce350c8..9b0d13485 100644 --- a/app/Providers/AuthServiceProvider.php +++ b/app/Providers/AuthServiceProvider.php @@ -19,6 +19,7 @@ use App\Models\Plugin\PluginLog; use App\Models\Runway\Runway; use App\Models\Sid; +use App\Models\SmrArea; use App\Models\Squawk\AirfieldPairing\AirfieldPairingSquawkRange; use App\Models\Squawk\Ccams\CcamsSquawkRange; use App\Models\Squawk\Orcam\OrcamSquawkRange; @@ -79,6 +80,7 @@ class AuthServiceProvider extends ServiceProvider Prenote::class => DefaultFilamentPolicy::class, Runway::class => DefaultFilamentPolicy::class, Sid::class => DefaultFilamentPolicy::class, + SmrArea::class => OperationsContributorPolicy::class, Stand::class => OperationsContributorPolicy::class, Terminal::class => OperationsContributorPolicy::class, UnitConspicuitySquawkCode::class => DefaultFilamentPolicy::class, diff --git a/database/migrations/2025_04_30_220010_create_smr_area_table.php b/database/migrations/2025_04_30_220010_create_smr_area_table.php new file mode 100644 index 000000000..d61478011 --- /dev/null +++ b/database/migrations/2025_04_30_220010_create_smr_area_table.php @@ -0,0 +1,37 @@ +id()->primary(); + $table->unsignedInteger('airfield_id'); + $table->string('name')->nullable(); + $table->string('source')->nullable(); + $table->text('coordinates'); + $table->dateTime('start_date')->nullable(); + $table->dateTime('end_date')->nullable(); + $table->timestamps(); + + $table->foreign('airfield_id') + ->references('id') + ->on('airfield') + ->cascadeOnDelete(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('smr_area'); + } +}; diff --git a/database/seeds/DatabaseSeeder.php b/database/seeds/DatabaseSeeder.php index a87575614..d6977c070 100644 --- a/database/seeds/DatabaseSeeder.php +++ b/database/seeds/DatabaseSeeder.php @@ -150,6 +150,9 @@ class DatabaseSeeder extends Seeder IntentionCodeSeeder::class => [ 'intention_codes', ], + SmrAreaSeeder::class => [ + 'smr_area', + ], ]; const OTHER_TABLES_TO_TRUNCATE = [ diff --git a/database/seeds/SmrAreaSeeder.php b/database/seeds/SmrAreaSeeder.php new file mode 100644 index 000000000..49a9a0803 --- /dev/null +++ b/database/seeds/SmrAreaSeeder.php @@ -0,0 +1,20 @@ + 1, + 'coordinates' => str_repeat("COORD:N000.00.00.000:E000.00.00.000\n", 3), + 'start_date' => null, + 'end_date' => null, + ]); + } +} diff --git a/lang/en/filter.php b/lang/en/filter.php index 87771a5a7..00a3cfa22 100644 --- a/lang/en/filter.php +++ b/lang/en/filter.php @@ -4,5 +4,6 @@ 'notifications' => require_once __DIR__ . '/notifications/filter.php', 'runways' => require_once __DIR__ . '/runways/filter.php', 'sids' => require_once __DIR__ . '/sids/filter.php', + 'smr_areas' => require_once __DIR__ . '/smr_areas/filter.php', 'stands' => require_once __DIR__ . '/stands/filter.php', ]; diff --git a/lang/en/form.php b/lang/en/form.php index 914351695..a10487c96 100644 --- a/lang/en/form.php +++ b/lang/en/form.php @@ -4,20 +4,21 @@ 'aircraft' => require_once __DIR__ . '/aircraft/form.php', 'airfields' => require_once __DIR__ . '/airfields/form.php', 'airlines' => require_once __DIR__ . '/airlines/form.php', + 'controllers' => require_once __DIR__ . '/controllers/form.php', 'fir_exit_points' => require_once __DIR__ . '/fir_exit_points/form.php', - 'intention' => require_once __DIR__ . '/intention/form.php', - 'stands' => require_once __DIR__ . '/stands/form.php', - 'users' => require_once __DIR__ . '/users/form.php', - 'sids' => require_once __DIR__ . '/sids/form.php', 'handoffs' => require_once __DIR__ . '/handoffs/form.php', 'holds' => require_once __DIR__ . '/holds/form.php', + 'intention' => require_once __DIR__ . '/intention/form.php', + 'navaids' => require_once __DIR__ . '/navaids/form.php', + 'notifications' => require_once __DIR__ . '/notifications/form.php', 'plugin' => require_once __DIR__ . '/plugin/form.php', 'prenotes' => require_once __DIR__ . '/prenotes/form.php', 'runways' => require_once __DIR__ . '/runways/form.php', - 'controllers' => require_once __DIR__ . '/controllers/form.php', - 'navaids' => require_once __DIR__ . '/navaids/form.php', - 'notifications' => require_once __DIR__ . '/notifications/form.php', + 'sids' => require_once __DIR__ . '/sids/form.php', + 'smr_areas' => require_once __DIR__ . '/smr_areas/form.php', 'squawks' => require_once __DIR__ . '/squawks/form.php', 'srd' => require_once __DIR__ . '/srd/form.php', + 'stands' => require_once __DIR__ . '/stands/form.php', 'terminals' => require_once __DIR__ . '/terminals/form.php', + 'users' => require_once __DIR__ . '/users/form.php', ]; diff --git a/lang/en/smr_areas/filter.php b/lang/en/smr_areas/filter.php new file mode 100644 index 000000000..530d6d71d --- /dev/null +++ b/lang/en/smr_areas/filter.php @@ -0,0 +1,10 @@ + [ + 'label' => 'Activation', + 'true' => 'Active', + 'false' => 'Inactive', + ], + 'expired' => 'Expired', +]; diff --git a/lang/en/smr_areas/form.php b/lang/en/smr_areas/form.php new file mode 100644 index 000000000..32c337e74 --- /dev/null +++ b/lang/en/smr_areas/form.php @@ -0,0 +1,28 @@ + [ + 'label' => 'Airfield', + 'helper' => 'The airfield with which this area is associated.', + ], + 'name' => [ + 'label' => 'Description', + 'helper' => 'A short description of what the area covers.', + ], + 'source' => [ + 'label' => 'Source', + 'helper' => 'The information source for this area (ID of relevant NOTAM, AIP SUP, etc.).', + ], + 'coordinates' => [ + 'label' => 'Coordinates', + 'helper' => 'The polygon defining this area, in "sline" format. Separate distinct polygons with blank lines.', + ], + 'start_date' => [ + 'label' => 'Start date', + 'helper' => 'The time from which the area should be displayed. If blank, the area will be active immediately.', + ], + 'end_date' => [ + 'label' => 'End date', + 'helper' => 'The time until which the area should be displayed. If blank, the area will be active indefinitely.', + ], +]; diff --git a/lang/en/smr_areas/table.php b/lang/en/smr_areas/table.php new file mode 100644 index 000000000..8ab1b4161 --- /dev/null +++ b/lang/en/smr_areas/table.php @@ -0,0 +1,9 @@ + [ + 'airfield' => 'Airfield', + 'name' => 'Description', + 'source' => 'Source', + ], +]; diff --git a/lang/en/table.php b/lang/en/table.php index a622af1b8..ab1814306 100644 --- a/lang/en/table.php +++ b/lang/en/table.php @@ -4,23 +4,24 @@ 'aircraft' => require_once __DIR__ . '/aircraft/table.php', 'airfields' => require_once __DIR__ . '/airfields/table.php', 'airlines' => require_once __DIR__ . '/airlines/table.php', + 'controllers' => require_once __DIR__ . '/controllers/table.php', 'dependencies' => require_once __DIR__ . '/dependencies/table.php', 'fir_exit_points' => require_once __DIR__ . '/fir_exit_points/table.php', - 'intention' => require_once __DIR__ . '/intention/table.php', - 'stands' => require_once __DIR__ . '/stands/table.php', - 'stand_assignments_history' => require_once __DIR__ . '/stand_assignments_history/table.php', - 'users' => require_once __DIR__ . '/users/table.php', - 'sids' => require_once __DIR__ . '/sids/table.php', 'handoffs' => require_once __DIR__ . '/handoffs/table.php', 'holds' => require_once __DIR__ . '/holds/table.php', - 'plugin' => require_once __DIR__ . '/plugin/table.php', - 'prenotes' => require_once __DIR__ . '/prenotes/table.php', - 'controllers' => require_once __DIR__ . '/controllers/table.php', + 'intention' => require_once __DIR__ . '/intention/table.php', 'navaids' => require_once __DIR__ . '/navaids/table.php', 'notifications' => require_once __DIR__ . '/notifications/table.php', + 'plugin' => require_once __DIR__ . '/plugin/table.php', + 'prenotes' => require_once __DIR__ . '/prenotes/table.php', 'runways' => require_once __DIR__ . '/runways/table.php', + 'sids' => require_once __DIR__ . '/sids/table.php', + 'smr_areas' => require_once __DIR__ . '/smr_areas/table.php', 'squawks' => require_once __DIR__ . '/squawks/table.php', 'srd' => require_once __DIR__ . '/srd/table.php', + 'stand_assignments_history' => require_once __DIR__ . '/stand_assignments_history/table.php', + 'stands' => require_once __DIR__ . '/stands/table.php', 'terminals' => require_once __DIR__ . '/terminals/table.php', + 'users' => require_once __DIR__ . '/users/table.php', 'versions' => require_once __DIR__ . '/versions/table.php', ]; diff --git a/routes/api.php b/routes/api.php index cd4dc44cd..04023bcde 100644 --- a/routes/api.php +++ b/routes/api.php @@ -303,6 +303,8 @@ function () { // Enroute releases Route::get('release/enroute/types', 'ReleaseController@enrouteReleaseTypeDependency'); + Route::get("smr-areas", "SmrAreaController@getFormattedSmrAreas"); + // Sids Route::get('sid/dependency', 'SidController@getSidsDependency'); diff --git a/tests/app/Filament/SmrAreaResourceTest.php b/tests/app/Filament/SmrAreaResourceTest.php new file mode 100644 index 000000000..70a870648 --- /dev/null +++ b/tests/app/Filament/SmrAreaResourceTest.php @@ -0,0 +1,197 @@ + 1]) + ->assertSet('data.airfield_id', 1); + } + + public function testItCreatesAnArea() + { + Livewire::test(CreateSmrArea::class) + ->set('data.airfield_id', 2) + ->set('data.coordinates', str_repeat($this::TEST_COORDINATE, 3)) + ->call('create') + ->assertHasNoErrors(); + + $this->assertDatabaseHas('smr_area', ['airfield_id' => 2]); + } + + public function testItDoesntCreateAnAreaNoAirfieldId() + { + Livewire::test(CreateSmrArea::class) + ->set('data.coordinates', str_repeat($this::TEST_COORDINATE, 3)) + ->call('create') + ->assertHasErrors(['data.airfield_id']); + } + + public function testItDoesntCreateAnAreaNoCoordinates() + { + Livewire::test(CreateSmrArea::class) + ->set('data.airfield_id', 1) + ->call('create') + ->assertHasErrors(['data.coordinates']); + } + + public function testItDoesntCreateAnAreaTooFewCoordinates() + { + Livewire::test(CreateSmrArea::class) + ->set('data.airfield_id', 1) + ->set('data.coordinates', $this::TEST_COORDINATE) + ->call('create') + ->assertHasErrors(['data.coordinates']); + } + + public function testItDoesntCreateAnAreaInvalidCoordinates() + { + Livewire::test(CreateSmrArea::class) + ->set('data.airfield_id', 1) + ->set('data.coordinates', str_repeat("COORD:INVALID\n", 3)) + ->call('create') + ->assertHasErrors(['data.coordinates']); + } + + public function testItLoadsDataForEdit() + { + Livewire::test(EditSmrArea::class, ['record' => 1]) + ->assertSet('data.airfield_id', 1); + } + + public function testItEditsAnArea() + { + Livewire::test(EditSmrArea::class, ['record' => 1]) + ->set('data.airfield_id', 3) + ->set('data.source', 'Some source') + ->call('save') + ->assertHasNoErrors(); + + $this->assertDatabaseHas( + 'smr_area', + [ + 'id' => 1, + 'airfield_id' => 3, + 'source' => 'Some source', + ] + ); + } + + public function testItDoesntEditAnAreaNoAirfieldId() + { + Livewire::test(EditSmrArea::class, ['record' => 1]) + ->set('data.airfield_id') + ->set('data.coordinates', str_repeat($this::TEST_COORDINATE, 3)) + ->call('save') + ->assertHasErrors(['data.airfield_id']); + } + + public function testItDoesntEditAnAreaNoCoordinates() + { + Livewire::test(EditSmrArea::class, ['record' => 1]) + ->set('data.airfield_id', 1) + ->set('data.coordinates') + ->call('save') + ->assertHasErrors(['data.coordinates']); + } + + public function testItDoesntEditAnAreaTooFewCoordinates() + { + Livewire::test(EditSmrArea::class, ['record' => 1]) + ->set('data.airfield_id', 1) + ->set('data.coordinates', $this::TEST_COORDINATE) + ->call('save') + ->assertHasErrors(['data.coordinates']); + } + + public function testItDoesntEditAnAreaInvalidCoordinates() + { + Livewire::test(EditSmrArea::class, ['record' => 1]) + ->set('data.airfield_id', 1) + ->set('data.coordinates', str_repeat("COORD:INVALID\n", 3)) + ->call('save') + ->assertHasErrors(['data.coordinates']); + } + + protected function getCreateText(): string + { + return 'Create SMR Area'; + } + + protected function getEditRecord(): Model + { + return SmrArea::findOrFail(1); + } + + protected function getEditText(): string + { + return 'Edit SMR Area'; + } + + protected function getIndexText(): array + { + return ['SMR Areas']; + } + + protected function getViewText(): string + { + return 'View SMR Area'; + } + + protected function getViewRecord(): Model + { + return $this->getEditRecord(); + } + + protected static function resourceClass(): string + { + return SmrAreaResource::class; + } + + protected static function resourceRecordClass(): string + { + return SmrArea::class; + } + + protected static function resourceId(): int|string + { + return 1; + } + + protected static function writeResourcePageActions(): array + { + return [ + 'create', + ]; + } + + protected static function resourceListingClass(): string + { + return ListSmrAreas::class; + } + + protected static function writeResourceTableActions(): array + { + return [ + 'edit', + 'delete', + ]; + } +} diff --git a/tests/app/Http/Controllers/SmrAreaControllerTest.php b/tests/app/Http/Controllers/SmrAreaControllerTest.php new file mode 100644 index 000000000..bd7d660f7 --- /dev/null +++ b/tests/app/Http/Controllers/SmrAreaControllerTest.php @@ -0,0 +1,42 @@ + 1, + 'coordinates' => 'COORD:ACTIVE:AREA1', + 'start_date' => null, + 'end_date' => null, + ]); + SmrArea::create([ + 'airfield_id' => 1, + 'coordinates' => 'COORD:ACTIVE:AREA2', + 'start_date' => Carbon::now()->subDay(), + 'end_date' => Carbon::now()->addDay(), + ]); + SmrArea::create([ + 'airfield_id' => 1, + 'coordinates' => 'COORD:INACTIVE:AREA', + 'start_date' => null, + 'end_date' => Carbon::now()->subDay(), + ]); + + $this->makeUnauthenticatedApiRequest(self::METHOD_GET, 'smr-areas') + ->assertOk() + ->assertSeeText('COORD:ACTIVE:AREA1') + ->assertSeeText('COORD:ACTIVE:AREA2') + ->assertDontSeeText('INACTIVE') + ->assertDontSeeText("1\nCOORD") + ->assertDontSeeText('1COORD') + ->assertDontSeeText("2\nCOORD") + ->assertDontSeeText('2COORD'); + } +} diff --git a/tests/app/Models/SmrAreaTest.php b/tests/app/Models/SmrAreaTest.php new file mode 100644 index 000000000..96edbb79d --- /dev/null +++ b/tests/app/Models/SmrAreaTest.php @@ -0,0 +1,72 @@ + 1, + 'coordinates' => '', + 'start_date' => null, + 'end_date' => null, + ]); + } + + private function checkCases(Builder $query, array $cases) + { + $area = $this->createTestSmrArea(); + foreach ($cases as [$stt, $end, $expected]) { + $area->start_date = $stt ? Carbon::now()->addDays($stt) : null; + $area->end_date = $end ? Carbon::now()->addDays($end) : null; + $area->save(); + + $this->assertEquals( + $expected, $query->get()->contains($area), + sprintf("expected [%d, %d] -> %d", $stt, $end, $expected), + ); + } + } + + public function testItFindsExpiredAreas() + { + $this->checkCases(SmrArea::expired(), [ + [null, null, false], + [null, -1, true], + [null, 2, false], + [ -2, null, false], + [ -2, -1, true], + [ -2, 2, false], + [ 1, null, false], + [ 1, 2, false], + ]); + } + + public function testItFindsActiveAreas() + { + $this->checkCases(SmrArea::active(), [ + [null, null, true], + [null, -1, false], + [null, 2, true], + [ -2, null, true], + [ -2, -1, false], + [ -2, 2, true], + [ 1, null, false], + [ 1, 2, false], + ]); + } + + public function testItIgnoresDeletedAreas() + { + $area = $this->createTestSmrArea(); + $area->delete(); + $this->assertFalse(SmrArea::active()->get()->contains($area)); + $this->assertFalse(SmrArea::expired()->get()->contains($area)); + } +}