diff --git a/app/controllers/consultation/admin.php b/app/controllers/consultation/admin.php index 8f354b2c91a851ca00003af9f86b37cc70db0ea9..1eecc29cce6019fdce9142b41610a2993bb21009 100644 --- a/app/controllers/consultation/admin.php +++ b/app/controllers/consultation/admin.php @@ -199,6 +199,7 @@ class Consultation_AdminController extends ConsultationController $block->confirmation_text = trim(Request::get('confirmation-text')) ?: null; $block->note = Request::get('note'); $block->size = Request::int('size', 1); + $block->lock_time = Request::int('lock_time'); $slots = $block->createSlots(Request::int('duration'), $pause_time, $pause_duration); if (count($slots) === 0) { @@ -387,6 +388,7 @@ class Consultation_AdminController extends ConsultationController $this->block->show_participants = Request::bool('show-participants', false); $this->block->require_reason = Request::option('require-reason'); $this->block->confirmation_text = trim(Request::get('confirmation-text')); + $this->block->lock_time = Request::int('lock_time'); foreach ($this->block->slots as $slot) { $slot->note = ''; diff --git a/app/views/consultation/admin/create.php b/app/views/consultation/admin/create.php index 19a34e41c59be86fef91d5fa385817c92bf8de71..31f2b6fec3281de446193e02ddb69a0b945745a6 100644 --- a/app/views/consultation/admin/create.php +++ b/app/views/consultation/admin/create.php @@ -140,6 +140,18 @@ $intervals = [ value="<?= Request::int('pause_duration', 15) ?>" min="1"> </label> + + <label> + <input type="checkbox" name="lock" value="1" data-shows=".lock-inputs" data-activates=".lock-inputs input"> + <?= _('Termine für Buchungen sperren?') ?> + </label> + + <label class="lock-inputs"> + <?= _('Wieviele Stunden vor Beginn des Blocks sollen die Termine für Buchungen gesperrt werden?') ?> + <input type="number" name="lock_time" + value="<?= htmlReady(Request::int('lock_time', 24)) ?>" + min="1"> + </label> </fieldset> <? if ($responsible): ?> diff --git a/app/views/consultation/admin/edit.php b/app/views/consultation/admin/edit.php index 5f0aec9f18d2891c47decb3bfc01f881b3e82cda..2017c321e8ce2d62f9e2e2f7bccad00ae18df2e4 100644 --- a/app/views/consultation/admin/edit.php +++ b/app/views/consultation/admin/edit.php @@ -27,7 +27,20 @@ <textarea name="note"><?= htmlReady($block->note ) ?></textarea> </label> - <? if ($responsible): ?> + <label> + <input type="checkbox" name="lock" value="1" data-shows=".lock-inputs" data-activates=".lock-inputs input" + <? if ($block->lock_time) echo 'checked'; ?>> + <?= _('Termine für Buchungen sperren?') ?> + </label> + + <label class="lock-inputs"> + <?= _('Wieviele Stunden vor Beginn des Blocks sollen die Termine für Buchungen gesperrt werden?') ?> + <input type="number" name="lock_time" + value="<?= htmlReady($block->lock_time) ?>" + min="1"> + </label> + + <? if ($responsible): ?> <?= $this->render_partial('consultation/admin/block-responsibilities.php', compact('responsible', 'block')) ?> <? endif; ?> diff --git a/app/views/consultation/admin/index.php b/app/views/consultation/admin/index.php index 3ad692bfa1583029773222ec82a70fab5b12cb00..52a352cd213a9ce8b725104ef106800e078f09e1 100644 --- a/app/views/consultation/admin/index.php +++ b/app/views/consultation/admin/index.php @@ -24,11 +24,11 @@ <form action="<?= $controller->bulk($page, $current_action === 'expired') ?>" method="post"> <table class="default consultation-overview"> <colgroup> - <col width="24px"> - <col width="10%"> - <col width="12%"> + <col style="width: 24px"> + <col style="width: 10%"> + <col style="width: 12%"> <col> - <col width="48px"> + <col style="width: 48px"> </colgroup> <thead> <tr> @@ -56,7 +56,7 @@ <th class="actions"> <?= ActionMenu::get()->setContext(strval($block['block']))->addLink( $controller->editURL($block['block'], 0, $page), - _('Bearbeiten'), + _('Block bearbeiten'), Icon::create('edit'), ['data-dialog' => 'size=auto'] )->addLink( diff --git a/app/views/consultation/admin/ungrouped.php b/app/views/consultation/admin/ungrouped.php index d0a94c03fd76ec819a3511210b7feb0a4ce2cfbf..d41d43dd8fede524f37771d99cc077c0ad2ef38e 100644 --- a/app/views/consultation/admin/ungrouped.php +++ b/app/views/consultation/admin/ungrouped.php @@ -62,6 +62,7 @@ date('H:i', $block->start), date('H:i', $block->end) ) ?> + <?= $this->render_partial('consultation/block-locked.php', compact('block')) ?> </td> <td> <? if (count($block->responsibilities) > 0): ?> @@ -83,7 +84,7 @@ <td class="actions"> <?= ActionMenu::get()->setContext(strval($block))->addLink( $controller->editURL($block, 0, $page), - _('Information bearbeiten'), + _('Block bearbeiten'), Icon::create('edit'), ['data-dialog' => 'size=auto'] )->addLink( diff --git a/app/views/consultation/block-description.php b/app/views/consultation/block-description.php index 4d037ff5c4fa862226d3156670eadc3e7518479a..d0b455e1574e97b9fdcb77af6d8a672ad4247c3f 100644 --- a/app/views/consultation/block-description.php +++ b/app/views/consultation/block-description.php @@ -6,6 +6,8 @@ date('H:i', $block->end) ) ?> +<?= $this->render_partial('consultation/block-locked.php', compact('block')) ?> + (<?= formatLinks($block->room) ?>) <? if ($block->show_participants): ?> diff --git a/app/views/consultation/block-locked.php b/app/views/consultation/block-locked.php new file mode 100644 index 0000000000000000000000000000000000000000..6d54742a121f7c8f640424552fef144ecde1eb5b --- /dev/null +++ b/app/views/consultation/block-locked.php @@ -0,0 +1,6 @@ +<? if ($block->lock_time): ?> + <?= tooltipIcon(sprintf( + _('Dieser Block wird %u Stunden vor Beginn für Buchungen gesperrt.'), + $block->lock_time + )) ?> +<? endif; ?> diff --git a/app/views/consultation/overview/index.php b/app/views/consultation/overview/index.php index c209daadb91799a075ac76fa6894408620cd83bf..dcad6084c7552d20059dcc15b75fecf357ec8682 100644 --- a/app/views/consultation/overview/index.php +++ b/app/views/consultation/overview/index.php @@ -1,3 +1,14 @@ +<?php +/** + * @var ConsultationBlock[] $blocks + * @var Consultation_OverviewController $controller + * @var int $count + * @var int $limit + * @var int $page + * + * @var callable $displayNote + */ +?> <? if (count($blocks) === 0): ?> <?= MessageBox::info(_('Aktuell werden keine Termine angeboten.'))->hideClose() ?> @@ -6,10 +17,10 @@ <table class="default"> <colgroup> - <col width="10%"> - <col width="10%"> + <col style="width: 10%"> + <col style="width: 10%"> <col> - <col width="24px"> + <col style="width: 24px"> </colgroup> <thead> <tr> @@ -45,10 +56,12 @@ <a href="<?= $controller->cancel($block, $slot) ?>" data-dialog="size=auto"> <?= Icon::create('trash')->asImg(tooltip2(_('Termin absagen'))) ?> </a> - <? elseif (!$slot->isOccupied()): ?> + <? elseif ($slot->userMayCreateBookingForSlot()): ?> <a href="<?= $controller->book($block, $slot) ?>" data-dialog="size=auto"> <?= Icon::create('add')->asImg(tooltip2(_('Termin reservieren'))) ?> </a> + <? else: ?> + <?= Icon::create('add', Icon::ROLE_INACTIVE)->asImg(tooltip2(_('Dieser Termin ist für Buchungen gesperrt.'))) ?> <? endif; ?> </td> </tr> diff --git a/app/views/consultation/overview/ungrouped.php b/app/views/consultation/overview/ungrouped.php index 1a850905b7f2dc1a199a3e1584298235343a2885..b4d5c62b9f03271abc0f6f0a96e4fe8629611cf3 100644 --- a/app/views/consultation/overview/ungrouped.php +++ b/app/views/consultation/overview/ungrouped.php @@ -73,12 +73,14 @@ <td class="actions"> <? if ($slot->isOccupied($GLOBALS['user']->id)): ?> <a href="<?= $controller->cancel($block, $slot) ?>" data-dialog="size=auto"> - <?= Icon::create('remove/consultation')->asImg(tooltip2(_('Termin absagen'))) ?> + <?= Icon::create('trash')->asImg(tooltip2(_('Termin absagen'))) ?> </a> - <? elseif (!$slot->isOccupied()): ?> + <? elseif ($slot->userMayCreateBookingForSlot()): ?> <a href="<?= $controller->book($block, $slot) ?>" data-dialog="size=auto"> - <?= Icon::create('add/consultation')->asImg(tooltip2(_('Termin reservieren'))) ?> + <?= Icon::create('add')->asImg(tooltip2(_('Termin reservieren'))) ?> </a> + <? else: ?> + <?= Icon::create('add', Icon::ROLE_INACTIVE)->asImg(tooltip2(_('Dieser Termin ist für Buchungen gesperrt.'))) ?> <? endif; ?> </td> </tr> diff --git a/app/views/consultation/slot-occupation.php b/app/views/consultation/slot-occupation.php index 656c3ca413df1f7417c8290f902fae077ca4cbe5..035222d2c4bf40d5f64a2d514bfed8ee1155601c 100644 --- a/app/views/consultation/slot-occupation.php +++ b/app/views/consultation/slot-occupation.php @@ -1,3 +1,8 @@ +<?php +/** + * @var ConsultationSlot $slot + */ +?> <? if ($slot->isOccupied()): ?> <span class="consultation-occupied"> <? if ($slot->block->size > 1): ?> @@ -12,6 +17,10 @@ <?= _('belegt') ?> <? endif; ?> </span> +<? elseif ($slot->isLocked()): ?> + <span class="consultation-slot-not-bookable"> + <?= _('nicht buchbar') ?> + </span> <? else: ?> <span class="consultation-free"> <? if ($slot->block->size > 1): ?> diff --git a/db/migrations/5.3.6_add_consultation_lock_time.php b/db/migrations/5.3.6_add_consultation_lock_time.php new file mode 100644 index 0000000000000000000000000000000000000000..0eba290fac8bca4e692b9287160a6f5ab491c064 --- /dev/null +++ b/db/migrations/5.3.6_add_consultation_lock_time.php @@ -0,0 +1,23 @@ +<?php +final class AddConsultationLockTime extends Migration +{ + public function description() + { + return 'Adds a lock time for consultation blocks that prevents slots ' + . 'from being booked based on the current time.'; + } + + protected function up() + { + $query = "ALTER TABLE `consultation_blocks` + ADD COLUMN `lock_time` INT(11) UNSIGNED DEFAULT NULL AFTER `size`"; + DBManager::get()->exec($query); + } + + protected function down() + { + $query = "ALTER TABLE `consultation_blocks` + DROP COLUMN `lock_time`"; + DBManager::get()->exec($query); + } +} diff --git a/lib/classes/JsonApi/Routes/Consultations/BookingsCreate.php b/lib/classes/JsonApi/Routes/Consultations/BookingsCreate.php index a7347ab43b4aa9e54aa928971bc918738a4e12e1..dd3566187d0f3bc71b238a933e8fac73f6f162a3 100644 --- a/lib/classes/JsonApi/Routes/Consultations/BookingsCreate.php +++ b/lib/classes/JsonApi/Routes/Consultations/BookingsCreate.php @@ -29,6 +29,10 @@ class BookingsCreate extends JsonApiController throw new ConflictException('The slot is already occupied'); } + if (!$slot->userMayCreateBookingForSlot($booking_user)) { + throw new ConflictException('The slot is locked for bookings'); + } + $booking = \ConsultationBooking::create([ 'slot_id' => $slot->id, 'user_id' => $booking_user->id, diff --git a/lib/classes/JsonApi/Schemas/ConsultationBlock.php b/lib/classes/JsonApi/Schemas/ConsultationBlock.php index 4e4e8d72e1258eb84d129a92a932c31822db61e0..4e3d28c4ea62bf0b1e0a94fd6e9ac2637bb6f97d 100644 --- a/lib/classes/JsonApi/Schemas/ConsultationBlock.php +++ b/lib/classes/JsonApi/Schemas/ConsultationBlock.php @@ -26,6 +26,8 @@ class ConsultationBlock extends SchemaProvider 'start' => date('c', $resource->start), 'end' => date('c', $resource->end), + 'lock-time' => $resource->lock_time, + 'size' => (int) $resource->size, 'show-participants' => (bool) $resource->show_participants, 'require-reason' => $resource->require_reason, diff --git a/lib/classes/JsonApi/Schemas/ConsultationSlot.php b/lib/classes/JsonApi/Schemas/ConsultationSlot.php index 92678f553224ab8acc3a49f010361c748d0a4620..8452a4a8372900affd56e41ea7057dfdb2cf76cf 100644 --- a/lib/classes/JsonApi/Schemas/ConsultationSlot.php +++ b/lib/classes/JsonApi/Schemas/ConsultationSlot.php @@ -17,6 +17,12 @@ class ConsultationSlot extends SchemaProvider return $resource->id; } + /** + * @param \ConsultationSlot $resource + * @param ContextInterface $context + * + * @return iterable + */ public function getAttributes($resource, ContextInterface $context): iterable { $attributes = [ @@ -26,6 +32,9 @@ class ConsultationSlot extends SchemaProvider 'start_time' => date('c', $resource->start_time), 'end_time' => date('c', $resource->end_time), + 'is-bookable' => !$resource->isOccupied() && !$resource->isLocked(), + 'is-locked' => $resource->isLocked(), + 'mkdate' => date('c', $resource->mkdate), 'chdate' => date('c', $resource->chdate), ]; diff --git a/lib/models/ConsultationBlock.php b/lib/models/ConsultationBlock.php index 37168ed0fc5bfbff05d6b17a970a651f8c05c30f..c3373e3c1e08576e04daa76b68f924c090fbfb2f 100644 --- a/lib/models/ConsultationBlock.php +++ b/lib/models/ConsultationBlock.php @@ -23,10 +23,13 @@ * @property string $confirmation_text database column * @property string $note database column * @property string $size database column + * @property int $lock_time * @property int $mkdate database column * @property int $chdate database column * * @property bool $has_bookings computed column + * @property string $range_display + * @property bool $is_expired * @property Range $range computed column * @property ConsultationSlot[]|SimpleORMapCollection $slots has_many ConsultationSlot * @property ConsultationResponsibility[]|SimpleCollection $responsibilities has_many ConsultationResponsibility diff --git a/lib/models/ConsultationSlot.php b/lib/models/ConsultationSlot.php index f1063ce1161f9c56a67861a621cc6393314a1f48..ab1dfa7ea3fe66d1bbb881cf7c346dfd94548b92 100644 --- a/lib/models/ConsultationSlot.php +++ b/lib/models/ConsultationSlot.php @@ -146,6 +146,17 @@ class ConsultationSlot extends SimpleORMap : (bool) $this->bookings->findOneBy('user_id', $user_id); } + /** + * Returns whether the slot is locked for bookings. + * + * @return bool + */ + public function isLocked(): bool + { + return $this->block->lock_time + && strtotime("-{$this->block->lock_time} hours", $this->block->start) < time(); + } + /** * Creates a Stud.IP calendar event relating to the slot. * @@ -284,6 +295,18 @@ class ConsultationSlot extends SimpleORMap } } + /** + * Returns whether the given user may create a booking for this slot. + */ + public function userMayCreateBookingForSlot(\User $user = null): bool + { + $user = $user ?? User::findCurrent(); + + return ConsultationBooking::userMayCreateBookingForRange($this->block->range, $user) + && !$this->isOccupied() + && !$this->isLocked(); + } + /** * @return string A string representation of the consultation slot. diff --git a/resources/assets/stylesheets/scss/consultation.scss b/resources/assets/stylesheets/scss/consultation.scss index 8c5bc37ae66c3c267e5eebe186b36e838dfb70d0..246838d877083149200e1924d7c3d30fe26140e5 100644 --- a/resources/assets/stylesheets/scss/consultation.scss +++ b/resources/assets/stylesheets/scss/consultation.scss @@ -23,10 +23,13 @@ } } .consultation-free { - color: $green; + color: var(--green); } .consultation-occupied { - color: $red; + color: var(--red); +} +.consultation-slot-not-bookable { + color: var(--light-gray-color); } .consultation-overview { diff --git a/tests/jsonapi/ConsultationHelper.php b/tests/jsonapi/ConsultationHelper.php index 820de793e58e367560ee6cf90eec1c785257def5..0fdd40b1b63d7db81801866ae658568b7777f1a4 100644 --- a/tests/jsonapi/ConsultationHelper.php +++ b/tests/jsonapi/ConsultationHelper.php @@ -41,19 +41,23 @@ trait ConsultationHelper return User::find($credentials['id']); } - protected function createBlockWithSlotsForRange(Range $range): ConsultationBlock + protected function createBlockWithSlotsForRange(Range $range, array $additional_data = []): ConsultationBlock { + $hour = date('H'); + $begin = strtotime("today {$hour}:00:00"); + $end = strtotime('+2 hours', $begin); + $blocks = ConsultationBlock::generateBlocks( $range, - strtotime('today 8:00'), - strtotime('today 10:00'), + $begin, + $end, date('w'), 1 ); $blocks = iterator_to_array($blocks); $block = reset($blocks); - $block->setData(self::$BLOCK_DATA); + $block->setData(array_merge(self::$BLOCK_DATA, $additional_data)); $block->slots->exchangeArray($block->createSlots(15)); foreach ($block->slots as $slot) { diff --git a/tests/jsonapi/ConsultationsBookingCreateBySlotIndexTest.php b/tests/jsonapi/ConsultationsBookingCreateBySlotIndexTest.php index 2d9be45ee5015f5a3dc30b222ccd450199e9893e..56ef1838ca79f04e53303bca6667003096fcda59 100644 --- a/tests/jsonapi/ConsultationsBookingCreateBySlotIndexTest.php +++ b/tests/jsonapi/ConsultationsBookingCreateBySlotIndexTest.php @@ -6,6 +6,7 @@ use WoohooLabs\Yang\JsonApi\Response\JsonApiResponse; require_once __DIR__ . '/ConsultationHelper.php'; +// TODO: Test locked blocks class ConsultationsBookingCreateBySlotIndexTest extends Codeception\Test\Unit { use ConsultationHelper; @@ -26,6 +27,24 @@ class ConsultationsBookingCreateBySlotIndexTest extends Codeception\Test\Unit ); } + public function testAutorMayCreateNotCreateBookingDueToLock(): void + { + $credentials = $this->tester->getCredentialsForTestDozent(); + $range = User::find($credentials['id']); + + $block = $this->createBlockWithSlotsForRange($range, ['lock_time' => 2]); + $slot = $this->getSlotFromBlock($block); + + $response = $this->createBooking( + $credentials, + $slot, + $this->tester->getCredentialsForTestAutor()['id'], + null + ); + + $this->assertEquals(409, $response->getStatusCode()); + } + public function testSlotIsOccupied(): void { $credentials = $this->tester->getCredentialsForTestDozent(); diff --git a/tests/jsonapi/ConsultationsBookingCreateTest.php b/tests/jsonapi/ConsultationsBookingCreateTest.php index e8a85c9aa61849f588e4a58fca6c9006e5f820e8..54ad2b4865bd6985dd3647a108959436deafa71e 100644 --- a/tests/jsonapi/ConsultationsBookingCreateTest.php +++ b/tests/jsonapi/ConsultationsBookingCreateTest.php @@ -7,6 +7,7 @@ use WoohooLabs\Yang\JsonApi\Response\JsonApiResponse; require_once __DIR__ . '/ConsultationHelper.php'; +// TODO: Test locked blocks class ConsultationsBookingCreateTest extends Codeception\Test\Unit { use ConsultationHelper; @@ -27,6 +28,24 @@ class ConsultationsBookingCreateTest extends Codeception\Test\Unit ); } + public function testAutorMayCreateNotCreateBookingDueToLock(): void + { + $credentials = $this->tester->getCredentialsForTestDozent(); + $range = User::find($credentials['id']); + + $block = $this->createBlockWithSlotsForRange($range, ['lock_time' => 2]); + $slot = $this->getSlotFromBlock($block); + + $response = $this->createBooking( + $credentials, + $slot, + $this->tester->getCredentialsForTestAutor()['id'], + null + ); + + $this->assertEquals(409, $response->getStatusCode()); + } + public function testSlotIsOccupied(): void { $credentials = $this->tester->getCredentialsForTestDozent();