From 53964c53a28529501a13835e276acc5166c89fc6 Mon Sep 17 00:00:00 2001 From: Peter Date: Sun, 24 Nov 2024 16:42:07 +0100 Subject: [PATCH] feat: Time-controlled status for Planned Maintenances / Schedules (#119) Co-authored-by: swoga <3697291+swoga@users.noreply.github.com> Co-authored-by: James Brooks --- database/factories/ScheduleFactory.php | 18 +---- ...2024_10_13_214300_drop_schedule_status.php | 32 +++++++++ database/seeders/DatabaseSeeder.php | 11 ++- src/Filament/Resources/ScheduleResource.php | 17 +---- .../Controllers/Api/ScheduleController.php | 4 +- .../StatusPage/StatusPageController.php | 2 +- src/Http/Resources/Schedule.php | 4 ++ src/Models/Schedule.php | 44 +++++++----- tests/Feature/Api/ScheduleTest.php | 70 +++---------------- .../Actions/Schedule/CreateScheduleTest.php | 4 +- tests/Unit/Models/ScheduleTest.php | 31 ++++---- 11 files changed, 107 insertions(+), 130 deletions(-) create mode 100644 database/migrations/2024_10_13_214300_drop_schedule_status.php diff --git a/database/factories/ScheduleFactory.php b/database/factories/ScheduleFactory.php index 42ae2b2f..ad523306 100644 --- a/database/factories/ScheduleFactory.php +++ b/database/factories/ScheduleFactory.php @@ -2,7 +2,6 @@ namespace Cachet\Database\Factories; -use Cachet\Enums\ScheduleStatusEnum; use Cachet\Models\Schedule; use Illuminate\Database\Eloquent\Factories\Factory; @@ -22,16 +21,15 @@ public function definition(): array { return [ 'name' => 'Incident Schedule', - 'status' => ScheduleStatusEnum::upcoming, 'scheduled_at' => now()->addDays(7), - 'completed_at' => null, + 'completed_at' => now()->addDays(14), ]; } public function completed(): self { return $this->state([ - 'status' => ScheduleStatusEnum::complete, + 'scheduled_at' => now()->subMinutes(45), 'completed_at' => now()->subMinutes(30), ]); } @@ -39,7 +37,6 @@ public function completed(): self public function inProgress(): self { return $this->state([ - 'status' => ScheduleStatusEnum::in_progress, 'scheduled_at' => now()->subMinutes(30), 'completed_at' => null, ]); @@ -48,7 +45,6 @@ public function inProgress(): self public function inTheFuture(): self { return $this->state([ - 'status' => ScheduleStatusEnum::upcoming->value, 'scheduled_at' => now()->addDays(30), 'completed_at' => null, ]); @@ -57,17 +53,7 @@ public function inTheFuture(): self public function inThePast(): self { return $this->state([ - 'status' => ScheduleStatusEnum::upcoming, 'scheduled_at' => now()->subDays(30)->subHours(2), - 'completed_at' => null, - ]); - } - - public function completedInThePast(): self - { - return $this->state([ - 'status' => ScheduleStatusEnum::complete, - 'scheduled_at' => now()->addDays(30)->subHours(2), 'completed_at' => now()->subDays(30), ]); } diff --git a/database/migrations/2024_10_13_214300_drop_schedule_status.php b/database/migrations/2024_10_13_214300_drop_schedule_status.php new file mode 100644 index 00000000..52ddb58a --- /dev/null +++ b/database/migrations/2024_10_13_214300_drop_schedule_status.php @@ -0,0 +1,32 @@ +dropColumn('status'); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::table('incidents', function (Blueprint $table) { + $table->tinyInteger('status')->unsigned()->default(0); + }); + } +}; diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index 05f2e6ea..ad43101c 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -50,11 +50,18 @@ public function run(): void 'email_verified_at' => now(), ]); + Schedule::create([ + 'name' => 'Documentation Maintenance', + 'message' => 'We will be conducting maintenance on our documentation servers. Documentation may not be available during this time.', + 'scheduled_at' => now()->subHours(12)->subMinutes(45), + 'completed_at' => now()->subHours(12), + ]); + Schedule::create([ 'name' => 'Database Maintenance', 'message' => 'We will be conducting maintenance on our database servers. You may experience degraded performance during this time.', - 'scheduled_at' => now()->addHours(6), - 'status' => ScheduleStatusEnum::upcoming, + 'scheduled_at' => now()->addHours(24), + 'completed_at' => null, ]); $componentGroup = ComponentGroup::create([ diff --git a/src/Filament/Resources/ScheduleResource.php b/src/Filament/Resources/ScheduleResource.php index 379e7749..14905da3 100644 --- a/src/Filament/Resources/ScheduleResource.php +++ b/src/Filament/Resources/ScheduleResource.php @@ -25,17 +25,6 @@ public static function form(Form $form): Form Forms\Components\TextInput::make('name') ->label(__('Name')) ->required(), - Forms\Components\Select::make('status') - ->label(__('Status')) - ->required() - ->options(ScheduleStatusEnum::class) - ->default(ScheduleStatusEnum::upcoming) - ->afterStateUpdated(function (Forms\Set $set, int|ScheduleStatusEnum $state): void { - if (ScheduleStatusEnum::parse($state) !== ScheduleStatusEnum::complete) { - $set('completed_at', null); - } - }) - ->live(), Forms\Components\MarkdownEditor::make('message') ->label(__('Message')) ->columnSpanFull(), @@ -45,9 +34,7 @@ public static function form(Form $form): Form ->label(__('Scheduled at')) ->required(), Forms\Components\DateTimePicker::make('completed_at') - ->label(__('Completed at')) - ->visible(fn (Forms\Get $get): bool => ScheduleStatusEnum::parse($get('status')) === ScheduleStatusEnum::complete) - ->required(fn (Forms\Get $get): bool => ScheduleStatusEnum::parse($get('status')) === ScheduleStatusEnum::complete), + ->label(__('Completed at')), ])->columnSpan(1), ])->columns(4); } @@ -99,7 +86,7 @@ public static function table(Table $table): Table ->required(), ]) ->color('success') - ->action(fn (Schedule $record, array $data) => $record->update(['completed_at' => $data['completed_at'], 'status' => ScheduleStatusEnum::complete])), + ->action(fn (Schedule $record, array $data) => $record->update(['completed_at' => $data['completed_at']])), Tables\Actions\EditAction::make(), ]) ->bulkActions([ diff --git a/src/Http/Controllers/Api/ScheduleController.php b/src/Http/Controllers/Api/ScheduleController.php index 031a8e37..b005e566 100644 --- a/src/Http/Controllers/Api/ScheduleController.php +++ b/src/Http/Controllers/Api/ScheduleController.php @@ -22,8 +22,8 @@ public function index() { $schedules = QueryBuilder::for(Schedule::class) ->allowedIncludes(['components']) - ->allowedFilters(['name', 'status']) - ->allowedSorts(['name', 'status', 'id', 'scheduled_at', 'completed_at']) + ->allowedFilters(['name']) + ->allowedSorts(['name', 'id', 'scheduled_at', 'completed_at']) ->simplePaginate(request('per_page', 15)); return ScheduleResource::collection($schedules); diff --git a/src/Http/Controllers/StatusPage/StatusPageController.php b/src/Http/Controllers/StatusPage/StatusPageController.php index e21ade42..46fcfebf 100644 --- a/src/Http/Controllers/StatusPage/StatusPageController.php +++ b/src/Http/Controllers/StatusPage/StatusPageController.php @@ -30,7 +30,7 @@ public function index(): View ->withCount('incidents') ->get(), - 'schedules' => Schedule::query()->inTheFuture()->orderBy('scheduled_at')->get(), + 'schedules' => Schedule::query()->incomplete()->orderBy('scheduled_at')->get(), ]); } diff --git a/src/Http/Resources/Schedule.php b/src/Http/Resources/Schedule.php index 5f62a5ef..3de384cd 100644 --- a/src/Http/Resources/Schedule.php +++ b/src/Http/Resources/Schedule.php @@ -21,6 +21,10 @@ public function toAttributes(Request $request): array 'human' => optional($this->scheduled_at)->diffForHumans(), 'string' => optional($this->scheduled_at)->toDateTimeString(), ], + 'completed' => [ + 'human' => optional($this->completed_at)->diffForHumans(), + 'string' => optional($this->completed_at)->toDateTimeString(), + ], 'created' => [ 'human' => optional($this->created_at)->diffForHumans(), 'string' => optional($this->created_at)->toDateTimeString(), diff --git a/src/Models/Schedule.php b/src/Models/Schedule.php index 900c8dba..0d68a093 100644 --- a/src/Models/Schedule.php +++ b/src/Models/Schedule.php @@ -4,6 +4,7 @@ use Cachet\Enums\ScheduleStatusEnum; use Illuminate\Database\Eloquent\Builder; +use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsToMany; @@ -16,7 +17,6 @@ class Schedule extends Model use HasFactory, SoftDeletes; protected $casts = [ - 'status' => ScheduleStatusEnum::class, 'scheduled_at' => 'datetime', 'completed_at' => 'datetime', ]; @@ -24,11 +24,26 @@ class Schedule extends Model protected $fillable = [ 'name', 'message', - 'status', 'scheduled_at', 'completed_at', ]; + /** + * Get the status of the schedule. + */ + public function status(): Attribute + { + return Attribute::get(function () { + $now = Carbon::now(); + + return match (true) { + $this->scheduled_at->gte($now) => ScheduleStatusEnum::upcoming, + $this->completed_at === null => ScheduleStatusEnum::in_progress, + default => ScheduleStatusEnum::complete, + }; + }); + } + /** * Get the components affected by this schedule. */ @@ -53,7 +68,7 @@ public function formattedMessage(): string */ public function scopeIncomplete(Builder $query): Builder { - return $query->whereIn('status', ScheduleStatusEnum::incomplete()) + return $query->whereDate('scheduled_at', '>=', Carbon::now()) ->whereNull('completed_at'); } @@ -62,9 +77,11 @@ public function scopeIncomplete(Builder $query): Builder */ public function scopeInProgress(Builder $query): Builder { - return $query->where('scheduled_at', '<=', Carbon::now()) - ->where('status', '=', ScheduleStatusEnum::in_progress) - ->whereNull('completed_at'); + return $query->whereDate('scheduled_at', '<=', Carbon::now()) + ->where(function (Builder $query) { + $query->whereDate('completed_at', '>=', Carbon::now()) + ->orWhereNull('completed_at'); + }); } /** @@ -72,8 +89,7 @@ public function scopeInProgress(Builder $query): Builder */ public function scopeInTheFuture(Builder $query): Builder { - return $query->whereIn('status', ScheduleStatusEnum::upcoming()) - ->whereDate('scheduled_at', '>=', Carbon::now()); + return $query->whereDate('scheduled_at', '>=', Carbon::now()); } /** @@ -81,16 +97,6 @@ public function scopeInTheFuture(Builder $query): Builder */ public function scopeInThePast(Builder $query): Builder { - return $query->whereIn('status', ScheduleStatusEnum::upcoming()) - ->where('scheduled_at', '<=', Carbon::now()); - } - - /** - * Scopes schedules to those completed in the past. - */ - public function scopeCompletedPreviously(Builder $query): Builder - { - return $query->where('status', '=', ScheduleStatusEnum::complete) - ->where('completed_at', '<=', Carbon::now()); + return $query->where('completed_at', '<=', Carbon::now()); } } diff --git a/tests/Feature/Api/ScheduleTest.php b/tests/Feature/Api/ScheduleTest.php index f03c2ac7..73dcb466 100644 --- a/tests/Feature/Api/ScheduleTest.php +++ b/tests/Feature/Api/ScheduleTest.php @@ -1,6 +1,5 @@ assertJsonPath('data.4.attributes.id', 4); }); -it('can sort schedules by status', function () { - Schedule::factory(3)->sequence( - ['status' => 1], - ['status' => 2], - ['status' => 0], - )->create(); - - $response = getJson('/status/api/schedules?sort=status'); - - $response->assertJsonPath('data.0.attributes.id', 3); - $response->assertJsonPath('data.1.attributes.id', 1); - $response->assertJsonPath('data.2.attributes.id', 2); -}); - it('can sort schedules by scheduled at date', function () { Schedule::factory(4)->sequence( ['scheduled_at' => '2022-01-01'], @@ -130,26 +115,6 @@ $response->assertJsonPath('data.0.attributes.id', $schedule->id); }); -it('can filter schedules by status', function () { - Schedule::factory(20)->create([ - 'status' => ScheduleStatusEnum::upcoming, - ]); - $schedule = Schedule::factory()->create([ - 'status' => ScheduleStatusEnum::complete, - ]); - - $query = http_build_query([ - 'filter' => [ - 'status' => ScheduleStatusEnum::complete->value, - ], - ]); - - $response = getJson('/status/api/schedules?'.$query); - - $response->assertJsonCount(1, 'data'); - $response->assertJsonPath('data.0.attributes.id', $schedule->id); -}); - it('can get a schedule', function () { $schedule = Schedule::factory()->create(); @@ -165,8 +130,8 @@ $response = postJson('/status/api/schedules', [ 'name' => 'New Scheduled Maintenance', 'message' => 'Something will go wrong.', - 'status' => 1, 'scheduled_at' => $scheduleAt = now()->addWeek()->toDateTimeString(), + 'completed_at' => $completedAt = now()->addWeek()->addDay()->toDateTimeString(), ]); $response->assertCreated(); @@ -177,6 +142,9 @@ 'scheduled' => [ 'string' => $scheduleAt, ], + 'completed' => [ + 'string' => $completedAt, + ], ], ], ]); @@ -189,8 +157,8 @@ $response = postJson('/status/api/schedules', [ 'name' => 'New Scheduled Maintenance', 'message' => 'Something will go wrong.', - 'status' => 1, 'scheduled_at' => $scheduleAt = now()->addWeek()->toDateTimeString(), + 'completed_at' => $completedAt = now()->addWeek()->addDay()->toDateTimeString(), 'components' => [ ['id' => $componentA->id, 'status' => 3], ['id' => $componentB->id, 'status' => 4], @@ -205,6 +173,9 @@ 'scheduled' => [ 'string' => $scheduleAt, ], + 'completed' => [ + 'string' => $completedAt, + ], ], ], ]); @@ -218,8 +189,8 @@ $response->assertInvalid(array_keys($response->json('errors'))); })->with([ fn () => ['name' => 'Missing Message', 'message' => null], - fn () => ['name' => null, 'message' => 'Missing Name & Invalid Status', 'status' => 999], - fn () => ['name' => 'Invalid Scheduled At', 'message' => 'Something', 'status' => 1, 'scheduled_at' => 'invalid'], + fn () => ['name' => null, 'message' => 'Missing Name & Missing Scheduled At'], + fn () => ['name' => 'Invalid Scheduled At', 'message' => 'Something', 'scheduled_at' => 'invalid'], ]); it('can update a schedule', function () { @@ -228,7 +199,6 @@ $response = putJson('/status/api/schedules/'.$schedule->id, [ 'name' => 'Updated Scheduled Maintenance', 'message' => 'Something went wrong.', - 'status' => 2, ]); $response->assertOk(); @@ -249,7 +219,6 @@ $response = putJson('/status/api/schedules/'.$schedule->id.'?include=components', [ 'name' => 'Updated Scheduled Maintenance', 'message' => 'Something went wrong.', - 'status' => 2, 'components' => [ ['id' => $componentA->id, 'status' => 3], ['id' => $componentB->id, 'status' => 4], @@ -266,25 +235,6 @@ ]); }); -it('can update a schedule while passing null data', function (array $payload) { - $schedule = Schedule::factory()->create(); - - $response = putJson('/status/api/schedules/'.$schedule->id, $payload); - - $response->assertJson([ - 'data' => [ - 'attributes' => [ - 'name' => 'Updated Incident', - 'status' => [ - 'value' => 2, - ], - ], - ], - ]); -})->with([ - fn () => ['name' => 'Updated Incident', 'status' => 2], -]); - it('can delete a schedule', function () { $schedule = Schedule::factory()->create(); diff --git a/tests/Unit/Actions/Schedule/CreateScheduleTest.php b/tests/Unit/Actions/Schedule/CreateScheduleTest.php index 98ee23d8..a6a5a114 100644 --- a/tests/Unit/Actions/Schedule/CreateScheduleTest.php +++ b/tests/Unit/Actions/Schedule/CreateScheduleTest.php @@ -7,8 +7,8 @@ $data = [ 'name' => 'My Scheduled Maintenance', 'message' => 'Something will go down...', - 'status' => 0, // Upcoming... 'scheduled_at' => '2023-09-01 12:00:00', + 'completed_at' => '2023-10-01 12:00:00', ]; $schedule = app(CreateSchedule::class)->handle($data); @@ -23,8 +23,8 @@ $data = [ 'name' => 'My Scheduled Maintenance', 'message' => 'Something will go down...', - 'status' => 0, // Upcoming... 'scheduled_at' => '2023-09-01 12:00:00', + 'completed_at' => '2023-10-01 12:00:00', ]; [$componentA, $componentB] = Component::factory()->count(2)->create(); diff --git a/tests/Unit/Models/ScheduleTest.php b/tests/Unit/Models/ScheduleTest.php index 4b2d86bb..0689e065 100644 --- a/tests/Unit/Models/ScheduleTest.php +++ b/tests/Unit/Models/ScheduleTest.php @@ -17,14 +17,9 @@ }); it('can get incomplete schedules', function () { - [$scheduleA] = Schedule::factory() - ->count(3) - ->sequence( - ['status' => ScheduleStatusEnum::in_progress], - ['status' => ScheduleStatusEnum::upcoming], - ['status' => ScheduleStatusEnum::complete], - ) - ->create(); + $scheduleA = Schedule::factory()->inTheFuture()->create(); + Schedule::factory()->inProgress()->create(); + Schedule::factory()->inThePast()->create(); expect(Schedule::query()->incomplete()->get()) ->toHaveCount(2) @@ -51,7 +46,6 @@ it('can get schedules from the past', function () { Schedule::factory()->inTheFuture()->create(); - Schedule::factory()->inThePast()->completed()->create(); $scheduleInPast = Schedule::factory()->inThePast()->create(); expect(Schedule::query()->inThePast()->get()) @@ -59,9 +53,20 @@ ->first()->id->toBe($scheduleInPast->id); }); -it('can get schedules previously completed', function () { - Schedule::factory()->inThePast()->completed()->count(2)->create(); +it('can determine a schedule\'s upcoming status', function () { + $schedule = Schedule::factory()->inTheFuture()->create(); - expect(Schedule::query()->completedPreviously()->get()) - ->toHaveCount(2); + expect($schedule)->status->toBe(ScheduleStatusEnum::upcoming); +}); + +it('can determine a schedule\'s in-progress status', function () { + $schedule = Schedule::factory()->inProgress()->create(); + + expect($schedule)->status->toBe(ScheduleStatusEnum::in_progress); +}); + +it('can determine a schedule\'s completed status', function () { + $schedule = Schedule::factory()->completed()->create(); + + expect($schedule)->status->toBe(ScheduleStatusEnum::complete); });