diff --git a/.kateproject b/.kateproject
new file mode 100644
index 00000000..b9ffc1c6
--- /dev/null
+++ b/.kateproject
@@ -0,0 +1,9 @@
+{
+ "lspclient": {
+ "servers": {
+ "php" : {
+ "root": "../moodle/"
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/lbplanner/classes/enums/WEEKDAY.php b/lbplanner/classes/enums/WEEKDAY.php
new file mode 100644
index 00000000..7d5a5b3c
--- /dev/null
+++ b/lbplanner/classes/enums/WEEKDAY.php
@@ -0,0 +1,66 @@
+.
+/**
+ * enum for weekdays
+ * (cringe, ik, but we need these defined concretely)
+ *
+ * @package local_lbplanner
+ * @subpackage enums
+ * @copyright 2024 NecodeIT
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace local_lbplanner\enums;
+
+// TODO: revert to native enums once we migrate to php8.
+
+use local_lbplanner\polyfill\Enum;
+
+/**
+ * All the days of the week.
+ * All seven of them.
+ * Yup.
+ */
+class WEEKDAY extends Enum {
+ /**
+ * monday
+ */
+ const MONDAY = 1;
+ /**
+ * tuesday
+ */
+ const TUESDAY = 2;
+ /**
+ * wednesday
+ */
+ const WEDNESDAY = 3;
+ /**
+ * thursday
+ */
+ const THURSDAY = 4;
+ /**
+ * friday
+ */
+ const FRIDAY = 5;
+ /**
+ * saturday
+ */
+ const SATURDAY = 6;
+ /**
+ * sunday
+ */
+ const SUNDAY = 7;
+}
diff --git a/lbplanner/classes/helpers/slot_helper.php b/lbplanner/classes/helpers/slot_helper.php
new file mode 100644
index 00000000..f487d4b2
--- /dev/null
+++ b/lbplanner/classes/helpers/slot_helper.php
@@ -0,0 +1,270 @@
+.
+/**
+ * Provides helper classes for any tables related with the slot booking function of the app
+ *
+ * @package local_lbplanner
+ * @subpackage helpers
+ * @copyright 2024 NecodeIT
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace local_lbplanner\helpers;
+
+use DateInterval;
+use DateTime;
+use DateTimeImmutable;
+use DateTimeInterface;
+use external_api;
+use local_lbplanner\enums\WEEKDAY;
+use local_lbplanner\model\{slot, reservation, slot_filter};
+
+/**
+ * Provides helper methods for any tables related with the planning function of the app
+ */
+class slot_helper {
+ /**
+ * how far into the future a user can reserve a slot
+ */
+ const RESERVATION_RANGE_USER = 3;
+ /**
+ * how far into the future a supervisor can reserve a slot for a user
+ */
+ const RESERVATION_RANGE_SUPERVISOR = 7;
+ /**
+ * school units according to untis, in H:i format
+ */
+ const SCHOOL_UNITS = [
+ null,
+ '08:00',
+ '08:50',
+ '09:50',
+ '10:40',
+ '11:30',
+ '12:30',
+ '13:20',
+ '14:10',
+ '15:10',
+ '16:00',
+ '17:00', // All units after this point are 45min long instead of the usual 50.
+ '17:45', // We will assume 50min anyway because it's easier that way.
+ '18:45',
+ '19:30',
+ '20:15',
+ '21:00',
+ ];
+ /**
+ * local_lbplanner_slots table.
+ */
+ const TABLE_SLOTS = 'local_lbplanner_slots';
+ /**
+ * local_lbplanner_reservations table.
+ */
+ const TABLE_RESERVATIONS = 'local_lbplanner_reservations';
+ /**
+ * local_lbplanner_slot_courses table.
+ */
+ const TABLE_SLOT_FILTERS = 'local_lbplanner_slot_courses';
+ /**
+ * local_lbplanner_supervisors table.
+ */
+ const TABLE_SUPERVISORS = 'local_lbplanner_supervisors';
+
+ /**
+ * Returns a list of all slots.
+ *
+ * @return slot[] An array of the slots.
+ */
+ public static function get_all_slots(): array {
+ global $DB;
+ $slots = $DB->get_records(self::TABLE_SLOTS, []);
+
+ $slotsobj = [];
+ foreach ($slots as $slot) {
+ array_push($slotsobj, new slot(...$slot));
+ }
+
+ return $slotsobj;
+ }
+
+ /**
+ * Returns a list of all slots belonging to a supervisor.
+ * @param int $supervisorid userid of the supervisor in question
+ *
+ * @return slot[] An array of the slots.
+ */
+ public static function get_supervisor_slots(int $supervisorid): array {
+ global $DB;
+
+ $slots = $DB->get_records_sql(
+ 'SELECT slot.* FROM {'.self::TABLE_SLOTS.'} as slot'.
+ 'INNER JOIN '.self::TABLE_SUPERVISORS.' as supervisor ON supervisor.slotid=slot.id'.
+ 'WHERE supervisor.userid=?',
+ [$supervisorid]
+ );
+
+ $slotsobj = [];
+ foreach ($slots as $slot) {
+ array_push($slotsobj, new slot(...$slot));
+ }
+
+ return $slotsobj;
+ }
+
+ /**
+ * Returns a singular slot.
+ * @param int $slotid ID of the slot
+ *
+ * @return slot the requested slot
+ */
+ public static function get_slot(int $slotid): slot {
+ global $DB;
+ $slot = $DB->get_record(self::TABLE_SLOTS, ['id' => $slotid]);
+
+ return new slot(...$slot);
+ }
+
+ /**
+ * Returns reservations for a slot.
+ * @param int $slotid ID of the slot
+ *
+ * @return reservation[] the requested reservations
+ */
+ public static function get_reservations_for_slot(int $slotid): array {
+ global $DB;
+ $reservations = $DB->get_records(self::TABLE_RESERVATIONS, ['slotid' => $slotid]);
+
+ $reservationsobj = [];
+ foreach ($reservations as $reservation) {
+ $reservation['date'] = new DateTimeImmutable($reservation['date']);
+ array_push($reservationsobj, new reservation(...$reservation));
+ }
+
+ return $reservationsobj;
+ }
+
+ /**
+ * Returns filters for a slot.
+ * @param int $slotid ID of the slot
+ *
+ * @return slot_filter[] the requested filters
+ */
+ public static function get_filters_for_slot(int $slotid): array {
+ global $DB;
+ $filters = $DB->get_records(self::TABLE_SLOT_FILTERS, ['slotid' => $slotid]);
+
+ $filtersobj = [];
+ foreach ($filters as $filter) {
+ array_push($filtersobj, new slot_filter(...$filter));
+ }
+
+ return $filtersobj;
+ }
+
+ /**
+ * Filters an array of slots for the slots that the user can theoretically reserve
+ * NOTE: not taking into account time or fullness, only filters i.e. users' class and courses
+ * @param slot[] $allslots the slots to filter
+ * @param mixed $user a user object - e.g. $USER or a user object from the database
+ * @return slot[] the filtered slot array
+ */
+ public static function filter_slots_for_user(array $allslots, mixed $user): array {
+ $mycourses = external_api::call_external_function('local_lbplanner_courses_get_all_courses', ['userid' => $user->id]);
+ $mycourseids = [];
+ foreach ($mycourses as $course) {
+ array_push($mycourseids, $course->courseid);
+ }
+
+ $slots = [];
+ foreach ($allslots as $slot) {
+ $filters = self::get_filters_for_slot($slot->id);
+ foreach ($filters as $filter) {
+ // Checking for course ID.
+ if (!is_null($filter->courseid) && !in_array($filter->courseid, $mycourseids)) {
+ continue;
+ }
+ // TODO: replace address with cohorts.
+ // Checking for vintage.
+ if (!is_null($filter->vintage) && $user->address !== $filter->vintage) {
+ continue;
+ }
+ // If all filters passed, add slot to my slots and break.
+ array_push($slots, $slot);
+ break;
+ }
+ }
+ return $slots;
+ }
+
+ /**
+ * Filters an array of slots for a timerange around now.
+ * @param slot[] $allslots the slots to filter
+ * @param int $range how many days in the future the slot is allowed to be
+ * @return slot[] the filtered slot array
+ */
+ public static function filter_slots_for_time(array $allslots, int $range): array {
+ $now = new DateTimeImmutable();
+ $slots = [];
+ // Calculate date and time each slot happens next, and add it to the return list if within reach from today.
+ foreach ($allslots as $slot) {
+ $slotdatetime = self::calculate_slot_datetime($slot, $now);
+
+ if ($now->diff($slotdatetime)->days <= $range) {
+ array_push($slots, $slot->prepare_for_api());
+ }
+ }
+ return $slots;
+ }
+
+ /**
+ * calculates when a slot is to happen next
+ * @param slot $slot the slot
+ * @param DateTimeInterface $now the point in time representing now
+ * @return DateTimeImmutable the next time this slot will occur
+ */
+ public static function calculate_slot_datetime(slot $slot, DateTimeInterface $now): DateTimeImmutable {
+ $slotdaytime = self::SCHOOL_UNITS[$slot->startunit];
+ // NOTE: format and fromFormat use different date formatting conventions
+ $slotdatetime = DateTime::createFromFormat('YY-MM-DD tHH:MM', $now->format('Y-m-d ').$slotdaytime);
+ // Move to next day this weekday occurs (doesn't move if it's the same as today).
+ $slotdatetime->modify('this '.WEEKDAY::name_from($slot->weekday));
+
+ // Check if slot is before now (because time of day and such) and move it a week into the future if so.
+ if ($now->diff($slotdatetime)->invert === 1) {
+ $slotdatetime->add(new DateInterval('P1W'));
+ }
+
+ return new DateTimeImmutable($slotdatetime);
+ }
+
+ /**
+ * Returns a list of all slots belonging to a supervisor.
+ * @param int $supervisorid userid of the supervisor in question
+ *
+ * @return slot[] An array of the slots.
+ */
+ public static function check_slot_supervisor(int $supervisorid, int $slotid): bool {
+ global $DB;
+
+ $result = $DB->get_record_sql(
+ 'SELECT supervisor.userid FROM '.self::TABLE_SUPERVISORS.' as supervisor'.
+ 'WHERE supervisor.userid=? AND supervisor.slotid=?',
+ [$supervisorid, $slotid]
+ );
+
+ return $result !== false;
+ }
+}
diff --git a/lbplanner/classes/model/reservation.php b/lbplanner/classes/model/reservation.php
new file mode 100644
index 00000000..681fc3e4
--- /dev/null
+++ b/lbplanner/classes/model/reservation.php
@@ -0,0 +1,146 @@
+.
+/**
+ * Model for a reservation
+ *
+ * @package local_lbplanner
+ * @subpackage helpers
+ * @copyright 2024 NecodeIT
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace local_lbplanner\model;
+
+use DateTimeImmutable;
+
+use external_single_structure;
+use external_value;
+
+use local_lbplanner\model\slot;
+use local_lbplanner\helpers\slot_helper;
+
+/**
+ * Model class for reservation
+ */
+class reservation {
+ /**
+ * @var int $id ID of reservation
+ */
+ public int $id;
+ /**
+ * @var int $slotid ID of the linked slot
+ */
+ public int $slotid;
+ /**
+ * @var DateTimeImmutable $date date this reservation is on (time will be ignored)
+ */
+ public DateTimeImmutable $date;
+ /**
+ * @var int $userid ID of the user this reservation is for
+ */
+ public int $userid;
+ /**
+ * @var int $reserverid ID of the user who submitted this reservation (either pupil or supervisor)
+ */
+ public int $reserverid;
+ /**
+ * @var ?slot $slot the linked slot (gets filled in by helper functions)
+ */
+ private ?slot $slot;
+ /**
+ * @var ?DateTimeImmutable $datetime the date this reservation is for, with time filled in
+ */
+ private ?DateTimeImmutable $datetime;
+
+ /**
+ * Constructs a reservation
+ * @param int $id ID of reservation
+ * @param int $slotid ID of the linked slot
+ * @param DateTimeImmutable $date date this reservation is on (time will be ignored)
+ * @param int $userid ID of the user this reservation is for
+ * @param int $reserverid ID of the user who submitted this reservation (either pupil or supervisor)
+ * @link slot
+ */
+ public function __construct(int $id, int $slotid, DateTimeImmutable $date, int $userid, int $reserverid) {
+ $this->id = $id;
+ $this->slotid = $slotid;
+ $this->date = $date;
+ $this->userid = $userid;
+ $this->reserverid = $reserverid;
+ $this->slot = null;
+ }
+
+ /**
+ * Returns the associated slot.
+ *
+ * @return slot the associated slot
+ */
+ public function get_slot(): slot {
+ if (is_null($this->slot)) {
+ $this->slot = slot_helper::get_slot($this->slotid);
+ }
+
+ return $this->slot;
+ }
+
+ /**
+ * Prepares data for the DB endpoint.
+ *
+ * @return object a representation of this reservation and its data
+ */
+ public function prepare_for_db(): object {
+ $obj = new \stdClass();
+
+ $obj->slotid = $this->slotid;
+ $obj->date = $this->date;
+ $obj->userid = $this->userid;
+ $obj->reserverid = $this->reserverid;
+
+ return $obj;
+ }
+
+ /**
+ * Prepares data for the API endpoint.
+ *
+ * @return array a representation of this reservation and its data
+ */
+ public function prepare_for_api(): array {
+ return [
+ 'id' => $this->id,
+ 'slotid' => $this->slotid,
+ 'datetime' => $this->date->format('Y-m-d'),
+ 'userid' => $this->userid,
+ 'reserverid' => $this->reserverid,
+ ];
+ }
+
+ /**
+ * Returns the data structure of a reservation for the API.
+ *
+ * @return external_single_structure The data structure of a reservation for the API.
+ */
+ public static function api_structure(): external_single_structure {
+ return new external_single_structure(
+ [
+ 'id' => new external_value(PARAM_INT, 'reservation ID'),
+ 'slotid' => new external_value(PARAM_INT, 'ID of associated slot'),
+ 'date' => new external_value(PARAM_TEXT, 'date of the reservation in YYYY-MM-DD (as per ISO-8601)'),
+ 'userid' => new external_value(PARAM_INT, 'ID of the user this reservation is for'),
+ 'reserverid' => new external_value(PARAM_INT, 'ID of the user who submitted this reservation'),
+ ]
+ );
+ }
+}
diff --git a/lbplanner/classes/model/slot.php b/lbplanner/classes/model/slot.php
new file mode 100644
index 00000000..8e2fa1e0
--- /dev/null
+++ b/lbplanner/classes/model/slot.php
@@ -0,0 +1,177 @@
+.
+/**
+ * Model for a slot
+ *
+ * @package local_lbplanner
+ * @subpackage helpers
+ * @copyright 2024 NecodeIT
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace local_lbplanner\model;
+
+use local_lbplanner\enums\WEEKDAY;
+use local_lbplanner\helpers\slot_helper;
+
+use external_single_structure;
+use external_value;
+
+/**
+ * Model class for slot
+ */
+class slot {
+ /**
+ * @var int $id ID of slot
+ */
+ public int $id;
+ /**
+ * @var int $startunit Unit this slot starts in
+ */
+ public int $startunit;
+ /**
+ * @var int $duration duration of slot in units
+ */
+ public int $duration;
+ /**
+ * @var int $weekday weekday this slot occurs in
+ */
+ public int $weekday;
+ /**
+ * @var string $room room this slot is for
+ */
+ public string $room;
+ /**
+ * @var int $size how many pupils fit in this slot
+ */
+ public int $size;
+ /**
+ * @var ?int $fullness how many pupils have already reserved this slot (gets filled in by helper functions)
+ */
+ private ?int $fullness;
+ /**
+ * @var ?bool $forcuruser whether the current user has reserved this slot (gets filled in by helper functions)
+ */
+ private ?bool $forcuruser;
+
+ /**
+ * Constructs a new Slot
+ * @param int $id ID of slot
+ * @param int $startunit Unit this slot starts in
+ * @param int $duration duration of slot in units
+ * @param int $weekday weekday this slot occurs in
+ * @param string $room room this slot is for
+ * @param int $size how many pupils fit in this slot
+ * @link slot_helper::SCHOOL_UNITS
+ * @link WEEKDAY
+ */
+ public function __construct(int $id, int $startunit, int $duration, int $weekday, string $room, int $size) {
+ $this->id = $id;
+ assert($startunit > 0);
+ $this->startunit = $startunit;
+ assert($duration > 0);
+ $this->duration = $duration;
+ $this->weekday = WEEKDAY::from($weekday);
+ assert(strlen($room) > 0 && strlen($room) <= 7);
+ $this->room = $room;
+ assert($size >= 0); // Make it technically possible to not allow any students in a room to temporarily disable the slot.
+ $this->size = $size;
+ $this->fullness = null;
+ $this->forcuruser = null;
+ }
+
+ /**
+ * Returns how many reservations there are for this slot.
+ *
+ * @return int fullness
+ */
+ public function get_fullness(): int {
+ if (is_null($this->fullness)) {
+ $this->check_reservations();
+ }
+
+ return $this->fullness;
+ }
+
+ /**
+ * Returns whether the current user has a reservation for this slot.
+ *
+ * @return bool forcuruser
+ */
+ public function get_forcuruser(): bool {
+ if (is_null($this->forcuruser)) {
+ $this->check_reservations();
+ }
+
+ return $this->forcuruser;
+ }
+
+ /**
+ * Prepares data for the API endpoint.
+ *
+ * @return array a representation of this slot and its data
+ */
+ public function prepare_for_api(): array {
+ return [
+ 'id' => $this->id,
+ 'startunit' => $this->startunit,
+ 'duration' => $this->duration,
+ 'weekday' => $this->weekday,
+ 'room' => $this->room,
+ 'size' => $this->size,
+ 'fullness' => $this->get_fullness(),
+ 'forcuruser' => $this->get_forcuruser(),
+ ];
+ }
+
+ /**
+ * Returns the data structure of a slot for the API.
+ *
+ * @return external_single_structure The data structure of a slot for the API.
+ */
+ public static function api_structure(): external_single_structure {
+ return new external_single_structure(
+ [
+ 'id' => new external_value(PARAM_INT, 'slot ID'),
+ 'startunit' => new external_value(PARAM_INT, 'unit this slot starts in (8:00 is unit 1)'),
+ 'duration' => new external_value(PARAM_INT, 'duration of the slot in units'),
+ 'weekday' => new external_value(PARAM_INT, 'The day this unit repeats weekly: '.WEEKDAY::format()),
+ 'room' => new external_value(PARAM_TEXT, 'The room this slot is for'),
+ 'size' => new external_value(PARAM_INT, 'total capacity of the slot'),
+ 'fullness' => new external_value(PARAM_INT, 'how many people have already reserved this slot'),
+ 'forcuruser' => new external_value(PARAM_BOOL, 'whether the current user has reserved this slot'),
+ ]
+ );
+ }
+
+ /**
+ * Queries reservations for this slot and fills in internal data with that info.
+ */
+ private function check_reservations(): void {
+ global $USER;
+ $reservations = slot_helper::get_reservations_for_slot($this->id);
+
+ $this->fullness = count($reservations);
+
+ foreach ($reservations as $reservation) {
+ if ($reservation->userid === $USER['id']) {
+ $this->forcuruser = true;
+ return;
+ }
+ }
+ $this->forcuruser = false;
+ }
+}
diff --git a/lbplanner/classes/model/slot_filter.php b/lbplanner/classes/model/slot_filter.php
new file mode 100644
index 00000000..d798f2c7
--- /dev/null
+++ b/lbplanner/classes/model/slot_filter.php
@@ -0,0 +1,64 @@
+.
+/**
+ * Model for a filter for slots
+ *
+ * @package local_lbplanner
+ * @subpackage helpers
+ * @copyright 2024 NecodeIT
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace local_lbplanner\model;
+
+/**
+ * Model class for a filter for slots
+ */
+class slot_filter {
+ /**
+ * @var int $id ID of filter
+ */
+ public int $id;
+ /**
+ * @var int $id ID of linked slot
+ */
+ public int $slotid;
+ /**
+ * @var ?int $id ID of linked course or null if any
+ */
+ public ?int $courseid;
+ /**
+ * @var ?string $vintage linked class or null if any
+ */
+ public ?string $vintage;
+
+ /**
+ * Constructs new slot_filter
+ * @param int $id ID of filter
+ * @param int $slotid ID of linked slot
+ * @param ?int $courseid ID of linked course or null if any
+ * @param ?string $vintage linked class or null if any
+ */
+ public function __construct(int $id, int $slotid, ?int $courseid, ?string $vintage) {
+ $this->id = $id;
+ $this->slotid = $slotid;
+ $this->courseid = $courseid;
+ if (!is_null($vintage)) {
+ assert(strlen($vintage) <= 7);
+ }
+ $this->vintage = $vintage;
+ }
+}
diff --git a/lbplanner/classes/polyfill/Enum.php b/lbplanner/classes/polyfill/Enum.php
index 1d8b7b19..e5c4d2a3 100644
--- a/lbplanner/classes/polyfill/Enum.php
+++ b/lbplanner/classes/polyfill/Enum.php
@@ -35,15 +35,36 @@ class Enum {
/**
* tries to match the passed value to one of the enum values
* @param mixed $value the value to be matched
- * @return mixed either the matching enum value or null if not found
+ * @param bool $try whether to return null (true) or throw an error (false) if not found
+ * @return ?EnumCase the matching enum case or null if not found and $try==true
+ * @throws ValueError if not found and $try==false
*/
- public static function try_from(mixed $value): mixed {
+ private static function find(mixed $value, bool $try): ?EnumCase {
foreach (static::cases() as $case) {
if ($case->value === $value) {
- return $value;
+ return $case;
}
}
- return null;
+
+ if ($try) {
+ return null;
+ } else {
+ throw new ValueError("value {$value} cannot be represented as a value in enum ".static::class);
+ }
+ }
+ /**
+ * tries to match the passed value to one of the enum values
+ * @param mixed $value the value to be matched
+ * @return mixed either the matching enum value or null if not found
+ */
+ public static function try_from(mixed $value): mixed {
+ // TODO: replace with nullsafe operator in php8.
+ $case = static::find($value, true);
+ if (is_null($case)) {
+ return null;
+ } else {
+ return $case->value;
+ }
}
/**
* tries to match the passed value to one of the enum values
@@ -52,13 +73,31 @@ public static function try_from(mixed $value): mixed {
* @throws ValueError if not found
*/
public static function from(mixed $value): mixed {
- foreach (static::cases() as $case) {
- if ($case->value === $value) {
- return $value;
- }
+ return static::find($value, false)->value;
+ }
+ /**
+ * tries to match the passed value to one of the enum values
+ * @param mixed $value the value to be matched
+ * @return string the matching enum case name
+ * @throws mixed either the matching enum case name or null if not found
+ */
+ public static function try_name_from(mixed $value): ?string {
+ // TODO: replace with nullsafe operator in php8.
+ $case = static::find($value, true);
+ if (is_null($case)) {
+ return null;
+ } else {
+ return $case->name;
}
-
- throw new ValueError("value {$value} cannot be represented as a value in enum ".static::class);
+ }
+ /**
+ * tries to match the passed value to one of the enum values
+ * @param mixed $value the value to be matched
+ * @return string the matching enum case name
+ * @throws ValueError if not found
+ */
+ public static function name_from(mixed $value): string {
+ return static::find($value, false)->name;
}
/**
* Returns an array of all the cases that exist in this enum
@@ -76,7 +115,7 @@ public static function cases(): array {
/**
* Formats all possible enum values into a string
* Example:
- * (31=>RED,32=>GREEN,33=>YELLOW)
+ * [31=>RED,32=>GREEN,33=>YELLOW]
* @return string the resulting string
*/
public static function format(): string {
diff --git a/lbplanner/db/install.xml b/lbplanner/db/install.xml
index f0d274fb..13c855e2 100644
--- a/lbplanner/db/install.xml
+++ b/lbplanner/db/install.xml
@@ -114,5 +114,58 @@