From 05cf41b84ab6f664db4f1ee7f27c675ea6235776 Mon Sep 17 00:00:00 2001
From: Jan-Hendrik Willms <tleilax+studip@gmail.com>
Date: Tue, 15 Nov 2022 13:12:49 +0000
Subject: [PATCH] lock times for consultation blocks, fixes #1264

Closes #1264

Merge request studip/studip!785
---
 app/controllers/consultation/admin.php        |  2 ++
 app/views/consultation/admin/create.php       | 12 ++++++++++
 app/views/consultation/admin/edit.php         | 15 +++++++++++-
 app/views/consultation/admin/index.php        | 10 ++++----
 app/views/consultation/admin/ungrouped.php    |  3 ++-
 app/views/consultation/block-description.php  |  2 ++
 app/views/consultation/block-locked.php       |  6 +++++
 app/views/consultation/overview/index.php     | 21 +++++++++++++----
 app/views/consultation/overview/ungrouped.php |  8 ++++---
 app/views/consultation/slot-occupation.php    |  9 ++++++++
 .../5.3.6_add_consultation_lock_time.php      | 23 +++++++++++++++++++
 .../Routes/Consultations/BookingsCreate.php   |  4 ++++
 .../JsonApi/Schemas/ConsultationBlock.php     |  2 ++
 .../JsonApi/Schemas/ConsultationSlot.php      |  9 ++++++++
 lib/models/ConsultationBlock.php              |  3 +++
 lib/models/ConsultationSlot.php               | 23 +++++++++++++++++++
 .../assets/stylesheets/scss/consultation.scss |  7 ++++--
 tests/jsonapi/ConsultationHelper.php          | 12 ++++++----
 ...sultationsBookingCreateBySlotIndexTest.php | 19 +++++++++++++++
 .../ConsultationsBookingCreateTest.php        | 19 +++++++++++++++
 20 files changed, 189 insertions(+), 20 deletions(-)
 create mode 100644 app/views/consultation/block-locked.php
 create mode 100644 db/migrations/5.3.6_add_consultation_lock_time.php

diff --git a/app/controllers/consultation/admin.php b/app/controllers/consultation/admin.php
index 8f354b2c91a..1eecc29cce6 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 19a34e41c59..31f2b6fec32 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 5f0aec9f18d..2017c321e8c 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 3ad692bfa15..52a352cd213 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 d0a94c03fd7..d41d43dd8fe 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 4d037ff5c4f..d0b455e1574 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 00000000000..6d54742a121
--- /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 c209daadb91..dcad6084c75 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 1a850905b7f..b4d5c62b9f0 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 656c3ca413d..035222d2c4b 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 00000000000..0eba290fac8
--- /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 a7347ab43b4..dd3566187d0 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 4e4e8d72e12..4e3d28c4ea6 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 92678f55322..8452a4a8372 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 37168ed0fc5..c3373e3c1e0 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 f1063ce1161..ab1dfa7ea3f 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 8c5bc37ae66..246838d8770 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 820de793e58..0fdd40b1b63 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 2d9be45ee50..56ef1838ca7 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 e8a85c9aa61..54ad2b4865b 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();
-- 
GitLab