diff --git a/config/routes.php b/config/routes.php index 16bcd971..8e500162 100644 --- a/config/routes.php +++ b/config/routes.php @@ -71,6 +71,11 @@ $route->addGroup('/angeltypes', function (RouteCollector $route): void { $route->get('/about', 'AngelTypesController@about'); }); +// Shifts +$route->addGroup('/shifts', function (RouteCollector $route): void { + $route->get('/random', 'ShiftsController@random'); +}); + // News $route->get('/meetings', 'NewsController@meetings'); $route->addGroup( diff --git a/includes/pages/user_shifts.php b/includes/pages/user_shifts.php index 2b38d9ca..06b18f2e 100644 --- a/includes/pages/user_shifts.php +++ b/includes/pages/user_shifts.php @@ -281,7 +281,9 @@ function view_user_shifts() $end_day = $shiftsFilter->getEnd()->format('Y-m-d'); $end_time = $shiftsFilter->getEnd()->format('H:i'); + $canSignUpForShifts = true; if (config('signup_requires_arrival') && !$user->state->arrived) { + $canSignUpForShifts = false; info(render_user_arrived_hint((bool) $user->state->user_info)); } @@ -344,7 +346,11 @@ function view_user_shifts() 'set_last_4h' => __('last 4h'), 'set_next_4h' => __('next 4h'), 'set_next_8h' => __('next 8h'), - 'buttons' => button( + 'random' => auth()->can('user_shifts') && $canSignUpForShifts ? button( + url('/shifts/random'), + icon('dice-4-fill') . __('shifts.random') + ) : '', + 'dashboard' => button( public_dashboard_link(), icon('speedometer2') . __('Public Dashboard') ), diff --git a/resources/lang/de_DE/additional.po b/resources/lang/de_DE/additional.po index 1975b54c..5e68b2b1 100644 --- a/resources/lang/de_DE/additional.po +++ b/resources/lang/de_DE/additional.po @@ -254,6 +254,9 @@ msgstr "" "Da die gelöschte Schicht bereits vergangen ist, " "haben wir einen entsprechenden Arbeitseinsatz hinzugefügt." +msgid "notification.shift.no_next_found" +msgstr "Es wurde keine verfügbare Schicht gefunden." + msgid "user.edit.success" msgstr "Benutzer erfolgreich bearbeitet." diff --git a/resources/lang/de_DE/default.po b/resources/lang/de_DE/default.po index 104844cb..3677eac9 100644 --- a/resources/lang/de_DE/default.po +++ b/resources/lang/de_DE/default.po @@ -222,6 +222,9 @@ msgstr "Das Aufbau Start Datum muss vor dem Abbau Ende Datum liegen." msgid "Public Dashboard" msgstr "Öffentliches Dashboard" +msgid "shifts.random" +msgstr "Zufällige Schicht" + msgid "%s has been subscribed to the shift." msgstr "%s wurde in die Schicht eingetragen." diff --git a/resources/lang/en_US/additional.po b/resources/lang/en_US/additional.po index 3e7d1dc1..7be350ba 100644 --- a/resources/lang/en_US/additional.po +++ b/resources/lang/en_US/additional.po @@ -253,6 +253,9 @@ msgstr "" "Since the deleted shift was already done, " "we added a worklog entry instead, to keep your work hours correct." +msgid "notification.shift.no_next_found" +msgstr "There is no available shift." + msgid "user.edit.success" msgstr "User edited successfully." diff --git a/resources/lang/en_US/default.po b/resources/lang/en_US/default.po index cec17e52..167da913 100644 --- a/resources/lang/en_US/default.po +++ b/resources/lang/en_US/default.po @@ -749,6 +749,9 @@ msgstr "Count" msgid "general.created_at" msgstr "Created at" +msgid "shifts.random" +msgstr "Zufällige Schicht" + msgid "shifts.history" msgstr "Shifts history" diff --git a/resources/views/pages/user-shifts.html b/resources/views/pages/user-shifts.html index 91504522..00cb53f3 100644 --- a/resources/views/pages/user-shifts.html +++ b/resources/views/pages/user-shifts.html @@ -47,7 +47,10 @@
- %buttons% + %random% +
+
+ %dashboard%
diff --git a/src/Controllers/ShiftsController.php b/src/Controllers/ShiftsController.php new file mode 100644 index 00000000..7f25a07e --- /dev/null +++ b/src/Controllers/ShiftsController.php @@ -0,0 +1,119 @@ +auth->user(); + $nextFreeShifts = $this->getNextFreeShifts($user); + + if ($nextFreeShifts->isEmpty()) { + $this->addNotification('notification.shift.no_next_found', NotificationType::WARNING); + return $this->redirect->to($this->url->to('/shifts')); + } + + /** @var Shift $randomShift */ + $randomShift = $nextFreeShifts + ->collect() + // Prefer soon starting shifts + ->groupBy('start') + ->first() + // Select one at random + ->random(); + + return $this->redirect->to($this->url->to('/shifts', ['action' => 'view', 'shift_id' => $randomShift->id])); + } + + protected function getNextFreeShifts(User $user): Collection | DbCollection + { + $angelTypes = $user + ->userAngelTypes() + ->select('angel_types.id') + ->whereNested(function (Builder $query): void { + $query + ->where('angel_types.restricted', false) + ->orWhereNot('confirm_user_id', false); + }); + $shifts = $user->shiftEntries()->select('shift_id'); + + $freeShifts = Shift::query() + ->select('shifts.*') + // Load needed from shift if no schedule configured, else from room + ->leftJoin('schedule_shift', 'schedule_shift.shift_id', '=', 'shifts.id') + ->leftJoin('needed_angel_types', function (JoinClause $query): void { + $query->on('needed_angel_types.shift_id', '=', 'shifts.id') + ->whereNull('schedule_shift.shift_id'); + }) + ->leftJoin('needed_angel_types AS nas', function (JoinClause $query): void { + $query->on('nas.location_id', '=', 'shifts.location_id') + ->whereNotNull('schedule_shift.shift_id'); + }) + // Not already signed in + ->whereNotIn('shifts.id', $shifts) + // Same angel types + ->where(function (EloquentBuilder $query) use ($angelTypes): void { + $query + ->whereIn('needed_angel_types.angel_type_id', $angelTypes) + ->orWhereIn('nas.angel_type_id', $angelTypes); + }) + // Starts soon + ->where('shifts.start', '>', Carbon::now()) + // Where help needed + ->where(function (Builder $query): void { + $query + ->from('shift_entries') + ->selectRaw('COUNT(*)') + ->where(fn(Builder $query) => $this->queryShiftEntries($query)); + }, '<', Shift::query()->raw('COALESCE(needed_angel_types.count, nas.count)')) + ->limit(10) + ->orderBy('start'); + + return $freeShifts->get(); + } + + protected function queryShiftEntries(Builder $query): void + { + $query->select('id') + ->from('shift_entries') + ->where(function (Builder $query): void { + $query->where('shift_entries.shift_id', $query->raw('needed_angel_types.shift_id')) + ->where('shift_entries.angel_type_id', $query->raw('needed_angel_types.angel_type_id')); + }) + ->orWhere(function (Builder $query): void { + $query->where('shift_entries.shift_id', $query->raw('nas.shift_id')) + ->where('shift_entries.angel_type_id', $query->raw('nas.angel_type_id')); + }) + ->groupBy(['shift_entries.shift_id', 'shift_entries.angel_type_id']); + } +} diff --git a/tests/Unit/Controllers/ShiftsControllerTest.php b/tests/Unit/Controllers/ShiftsControllerTest.php new file mode 100644 index 00000000..3f5baf40 --- /dev/null +++ b/tests/Unit/Controllers/ShiftsControllerTest.php @@ -0,0 +1,122 @@ +createModels(); + + $this->setExpects($this->redirect, 'to', ['http://localhost/shifts'], $this->response); + $this->setExpects($this->auth, 'user', null, $this->user); + + $controller = new ShiftsController($this->auth, $this->redirect, $this->url); + + $return = $controller->random(); + $this->assertEquals($this->response, $return); + $this->assertHasNotification('notification.shift.no_next_found', NotificationType::WARNING); + } + + /** + * @covers \Engelsystem\Controllers\ShiftsController::random + * @covers \Engelsystem\Controllers\ShiftsController::getNextFreeShifts + * @covers \Engelsystem\Controllers\ShiftsController::queryShiftEntries + */ + public function testRandom(): void + { + $this->createModels(); + $this->setExpects($this->auth, 'user', null, $this->user, $this->atLeastOnce()); + $start = Carbon::now()->addHour(); + + $otherUser = User::factory()->create(); + [$userAngelType, $otherAngelType] = AngelType::factory(2)->create(); + [$possibleShift1, $possibleShift2, $otherAngelTypeShift, $alreadySubscribedShift] = Shift::factory(4) + ->create(['start' => $start]); + $this->user->userAngelTypes()->attach($userAngelType, ['confirm_user_id' => $this->user->id]); + NeededAngelType::factory()->create([ + 'shift_id' => $possibleShift1->id, + 'angel_type_id' => $userAngelType->id, + 'count' => 2, + ]); + NeededAngelType::factory()->create([ + 'shift_id' => $possibleShift2->id, + 'angel_type_id' => $userAngelType->id, + 'count' => 1, + ]); + NeededAngelType::factory()->create([ + 'shift_id' => $otherAngelTypeShift->id, + 'angel_type_id' => $otherAngelType->id, + 'count' => 3, + ]); + ShiftEntry::factory()->create([ + 'shift_id' => $alreadySubscribedShift->id, + 'angel_type_id' => $userAngelType->id, + 'user_id' => $this->user->id, + ]); + + $otherUser->userAngelTypes()->attach($userAngelType, ['confirm_user_id' => $otherUser->id]); + ShiftEntry::factory()->create([ + 'shift_id' => $possibleShift1->id, + 'angel_type_id' => $userAngelType->id, + 'user_id' => $otherUser, + ]); + + $this->redirect->expects($this->exactly(10)) + ->method('to') + ->willReturnCallback(function (string $url) use ($possibleShift1, $possibleShift2) { + parse_str(parse_url($url)['query'], $parameters); + $this->assertTrue(Str::startsWith($url, 'http://localhost/shifts')); + $this->assertArrayHasKey('shift_id', $parameters); + $shiftId = $parameters['shift_id']; + $this->assertTrue(in_array($shiftId, [$possibleShift1->id, $possibleShift2->id])); + return $this->response; + }); + + $controller = new ShiftsController($this->auth, $this->redirect, $this->url); + + $return = $controller->random(); + $this->assertEquals($this->response, $return); + + // Try multiple times + for ($i = 1; $i < 10; $i++) { + $controller->random(); + } + } + + protected function createModels(): void + { + $this->user = User::factory()->create(); + + $this->auth = $this->createMock(Authenticator::class); + + $this->redirect = $this->createMock(Redirector::class); + + $this->url = $this->app->make(UrlGeneratorInterface::class); + } +}