diff options
| author | Philipp Schüttlöffel <schuettloeffel@zqs.uni-hannover.de> | 2024-09-24 10:53:31 +0200 |
|---|---|---|
| committer | Philipp Schüttlöffel <schuettloeffel@zqs.uni-hannover.de> | 2024-09-24 10:53:31 +0200 |
| commit | 4459dd7917f4d1c34f40bb68f0e991e9c3d53e4c (patch) | |
| tree | 5c07151ae61276d334e88f6309c30d439a85c12e /lib/models/resources/Resource.php | |
| parent | da0022e5c1abbf9825ae76debaabdff7e8623bb4 (diff) | |
| parent | 97a188592c679890a25c37ab78463add76a52ff7 (diff) | |
Merge branch 'main' into issue-3911issue-3911
Diffstat (limited to 'lib/models/resources/Resource.php')
| -rw-r--r-- | lib/models/resources/Resource.php | 2965 |
1 files changed, 2965 insertions, 0 deletions
diff --git a/lib/models/resources/Resource.php b/lib/models/resources/Resource.php new file mode 100644 index 0000000..32fce2b --- /dev/null +++ b/lib/models/resources/Resource.php @@ -0,0 +1,2965 @@ +<?php + +/** + * Resource.php - model class for a resource + * + * The Resource class is the base class of the new + * Room and Resource management system in Stud.IP. + * It provides core functionality for handling general resources + * and can be derived for handling special resources. + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License as + * published by the Free Software Foundation; either version 2 of + * the License, or (at your option) any later version. + * + * @author Moritz Strohm <strohm@data-quest.de> + * @copyright 2017-2019 + * @license http://www.gnu.org/licenses/gpl-2.0.html GPL version 2 + * @category Stud.IP + * @package resources + * @since 4.5 + * + * @property string $id database column + * @property string $parent_id database column + * @property string $category_id database column + * @property int|null $level database column + * @property string $name database column + * @property I18NString|null $description database column + * @property int $requestable database column + * @property int $lockable database column + * @property int $mkdate database column + * @property int $chdate database column + * @property int $sort_position database column + * @property SimpleORMapCollection|ResourceProperty[] $properties has_many ResourceProperty + * @property SimpleORMapCollection|ResourcePermission[] $permissions has_many ResourcePermission + * @property SimpleORMapCollection|ResourceRequest[] $requests has_many ResourceRequest + * @property SimpleORMapCollection|ResourceBooking[] $bookings has_many ResourceBooking + * @property SimpleORMapCollection|Resource[] $children has_many Resource + * @property ResourceCategory $category belongs_to ResourceCategory + * @property Resource $parent belongs_to Resource + * @property mixed $class_name additional field + */ +class Resource extends SimpleORMap implements StudipItem +{ + protected static function configure($config = []) + { + $config['db_table'] = 'resources'; + + $config['belongs_to']['category'] = [ + 'class_name' => ResourceCategory::class, + 'foreign_key' => 'category_id', + 'assoc_func' => 'find' + ]; + + $config['has_many']['properties'] = [ + 'class_name' => ResourceProperty::class, + 'assoc_foreign_key' => 'resource_id', + 'on_delete' => 'delete', + 'on_store' => 'store' + ]; + + $config['has_many']['permissions'] = [ + 'class_name' => ResourcePermission::class, + 'assoc_foreign_key' => 'resource_id', + 'on_delete' => 'delete', + 'on_store' => 'store' + ]; + + $config['has_many']['requests'] = [ + 'class_name' => ResourceRequest::class, + 'assoc_foreign_key' => 'resource_id', + 'on_delete' => 'delete', + 'on_store' => 'store' + ]; + + $config['has_many']['bookings'] = [ + 'class_name' => ResourceBooking::class, + 'assoc_foreign_key' => 'resource_id', + 'on_delete' => 'delete', + 'on_store' => 'store' + ]; + + $config['has_many']['children'] = [ + 'class_name' => Resource::class, + 'assoc_func' => 'findChildren', + 'on_delete' => 'delete', + 'on_store' => 'store' + ]; + + $config['belongs_to']['parent'] = [ + 'class_name' => Resource::class, + 'foreign_key' => 'parent_id' + ]; + + $config['i18n_fields']['description'] = true; + + $config['additional_fields']['class_name'] = ['category', 'class_name']; + $config['registered_callbacks']['before_store'][] = 'cbValidate'; + + parent::configure($config); + } + + /** + * This is a cache for permissions that users have on resources. + * It is meant to reduce the database requests in cases where the + * same permission is queried a lot of times. + */ + protected static $permission_cache; + + /** + * Returns the children of a resource. + * The children are converted to an instance of the derived class, + * if they are not instances of the default Resource class. + */ + public static function findChildren($resource_id) + { + $children = self::findBySql( + 'parent_id = :parent_id ORDER BY name ASC', + ['parent_id' => $resource_id] + ); + + if (!$children) { + return []; + } + + foreach ($children as &$child) { + $child = $child->getDerivedClassInstance(); + } + return $children; + } + + /** + * Returns a translation of the resource class name. + * The translated name can be singular or plural, depending + * on the value of the parameter $item_count. + * + * @param int $item_count The amount of items the translation shall be + * made for. This is only used to determine, if a singular or a + * plural form shall be returned. + * + * @return string The translated form of the class name, either in + * singular or plural. + * + */ + public static function getTranslatedClassName($item_count = 1) + { + return ngettext( + 'Ressource', + 'Ressourcen', + $item_count + ); + } + + /** + * Retrieves all resources which don't have a parent resource assigned. + * Such resources are called root resources since they are roots of + * a resource hierarchy (or a resource tree). + * + * @return Resource[] An array of Resource objects + * which are root resources. + */ + public static function getRootResources() + { + return self::findBySql("parent_id = '' ORDER BY name"); + } + + /** + * A method for overloaded classes so that they can define properties + * that are required for that resource class. + * + * @return string[] An array with the names of the required properties. + * Example: The properties with the names "foo", "bar" and "baz" + * are required properties. The array would have the following content: + * [ + * 'foo', + * 'bar', + * 'baz' + * ] + */ + public static function getRequiredProperties() + { + return []; + } + + + /** + * Returns the part of the URL for getLink and getURL which will be + * placed inside the calls to URLHelper::getLink and URLHelper::getURL + * in these methods. + * + * @param string $action The action for the resource. + * @param string $id The ID of the resource. + * + * @return string The URL path for the specified action. + * @throws InvalidArgumentException If $resource_id is empty. + * + */ + protected static function buildPathForAction($action = 'show', $id = null) + { + $actions_without_id = ['add']; + if (!$id && !in_array($action, $actions_without_id)) { + throw new InvalidArgumentException( + _('Zur Erstellung der URL fehlt eine Ressourcen-ID!') + ); + } + + switch ($action) { + case 'show': + return 'dispatch.php/resources/resource/index/' . $id; + case 'add': + return 'dispatch.php/resources/resource/add'; + case 'edit': + return 'dispatch.php/resources/resource/edit/' . $id; + case 'files': + return 'dispatch.php/resources/resource/files/' . $id . '/'; + case 'permissions': + return 'dispatch.php/resources/resource/permissions/' . $id; + case 'temporary_permissions': + return 'dispatch.php/resources/resource/temporary_permissions/' . $id; + case 'booking_plan': + return 'dispatch.php/resources/room_planning/booking_plan/' . $id; + case 'request_plan': + return 'dispatch.php/resources/room_planning/request_plan/' . $id; + case 'semester_plan': + return 'dispatch.php/resources/room_planning/semester_plan/' . $id; + case 'assign-undecided': + return 'dispatch.php/resources/booking/add/' . $id; + case 'assign': + return 'dispatch.php/resources/booking/add/' . $id . '/0'; + case 'reserve': + return 'dispatch.php/resources/booking/add/' . $id . '/1'; + case 'lock': + return 'dispatch.php/resources/booking/add/' . $id . '/2'; + case 'delete_bookings': + return 'dispatch.php/resources/resource/delete_bookings/' . $id; + case 'export_bookings': + return 'dispatch.php/resources/export/resource_bookings/' . $id; + case 'delete': + return 'dispatch.php/resources/resource/delete/' . $id; + default: + return 'dispatch.php/resources/resource/show/' . $id; + } + } + + /** + * Returns the appropriate link for the resource action that shall be + * executed on a resource. + * + * @param string $action The action which shall be executed. + * For default Resources the actions 'show', 'add', 'edit' and 'delete' + * are defined. + * @param string $id The ID of the resource on which the specified + * action shall be executed. + * @param array $link_parameters Optional parameters for the link. + * + * @return string The Link for the resource action. + * @throws InvalidArgumentException If $resource_id is empty. + * + */ + public static function getLinkForAction( + $action = 'show', + $id = null, + $link_parameters = [] + ) + { + return URLHelper::getLink( + self::buildPathForAction($action, $id), + $link_parameters + ); + } + + /** + * Returns the appropriate URL for the resource action that shall be + * executed on a resource. + * + * @param string $action The action which shall be executed. + * For default Resources the actions 'show', 'add', 'edit' and 'delete' + * are defined. + * @param string $id The ID of the resource on which the specified + * action shall be executed. + * @param array $url_parameters Optional parameters for the URL. + * + * @return string The URL for the resource action. + * @throws InvalidArgumentException If $resource_id is empty. + * + */ + public static function getURLForAction( + $action = 'show', + $id = null, + $url_parameters = [] + ) + { + return URLHelper::getURL( + self::buildPathForAction($action, $id), + $url_parameters + ); + } + + /** + * The SORM store method is overloaded to assure that the right level + * attribute is stored. + */ + public function store() + { + //Set the level attribute according to the parent's + //level attribute. If no parents are defined + //set the level to zero. + if ($this->parent_id && $this->parent) { + $this->level = $this->parent->level + 1; + } else { + $this->level = 0; + } + + //Store the folder, if it hasn't been stored before: + + $folder = $this->getFolder(); + if ($folder) { + $folder->store(); + } + + return parent::store(); + } + + public function delete() + { + //Delete the folder: + + $folder = $this->getFolder(false); + if ($folder) { + $folder->delete(); + } + + return parent::delete(); + } + + public function cbValidate() + { + if (!$this->category_id) { + throw new InvalidResourceException( + sprintf( + _('Die Ressource %s ist keiner Ressourcenkategorie zugeordnet!'), + $this->name + ) + ); + } + return true; + } + + + /** + * @see StudipItem::__toString + */ + public function __toString() + { + return $this->getFullName(); + } + + + /** + * Retrieves the folder for this resource. + * + * @param bool $create_if_missing Whether to create a folder (true) or + * not (false) in case no folder exists for this resource. + * Defaults to true. + * + * @returns ResourceFolder|null Either a ResourceFolder instance or null + * in case no such instance can be retrieved or created. + */ + public function getFolder($create_if_missing = true) + { + $folder = Folder::findOneByRange_id($this->id); + + if ($folder) { + $folder = $folder->getTypedFolder(); + + if ($folder instanceof ResourceFolder) { + //Only return ResourceFolder instances. + return $folder; + } + } elseif ($create_if_missing) { + $folder = $this->createFolder(); + if ($folder instanceof ResourceFolder) { + return $folder; + } + } + //In all other cases return null: + return null; + } + + public function setFolder(ResourceFolder $folder) + { + if ($this->isNew()) { + $this->store(); + } + + $folder->range_id = $this->id; + $folder->range_type = 'Resource'; + + return $folder->store(); + } + + public function createFolder() + { + if ($this->isNew()) { + $this->id = $this->getNewId(); + } + + $folder = Folder::createTopFolder( + $this->id, + 'Resource', + 'ResourceFolder' + ); + + if ($folder) { + $folder = $folder->getTypedFolder(); + if ($folder) { + $folder->store(); + return $folder; + } + } + + return null; + } + + /** + * Returns a list of property names that are required + * for the resource class. + * + * @return string[] An array with the property names. + */ + public function getRequiredPropertyNames() + { + return []; + } + + + /** + * This is a simplified version of the createBooking method. + * @param User $user + * @param DateTime $begin + * @param DateTime $end + * @param int $preparation_time + * @param string $description + * @param string $internal_comment + * @param int $booking_type + * @return ResourceBooking + * @see Resource::createBooking + */ + public function createSimpleBooking( + User $user, + DateTime $begin, + DateTime $end, + $preparation_time = 0, + $description = '', + $internal_comment = '', + $booking_type = ResourceBooking::TYPE_NORMAL + ) + { + return $this->createBooking( + $user, + $user->id, + [ + [ + 'begin' => $begin, + 'end' => $end + ] + ], + null, + 0, + null, + $preparation_time, + $description, + $internal_comment, + $booking_type + ); + } + + /** + * Creates bookings from a request. + * @param User $user + * @param ResourceRequest $request The request from which + * a resource booking shall be built. + * @param int $preparation_time + * @param string $description + * @param string $internal_comment + * @param int $booking_type + * @param bool $prepend_preparation_time . If this is set to true, + * the preparation time will end before the start of the + * requested time. If $prepend_preparation_time is set to false + * (the default) the preparation time starts with the start of the + * requested time. + * @param bool $notify_lecturers + * @return ResourceBooking[] A list of resource bookings + * matching the request. + * @throws ResourceRequestException if the request could not be marked + * as resolved. + * + * @throws ResourceUnavailableException if the resource cannot be assigned + * in at least one of the time ranges specified by the resource request. + */ + public function createBookingFromRequest( + User $user, + ResourceRequest $request, + $preparation_time = 0, + $description = '', + $internal_comment = '', + $booking_type = ResourceBooking::TYPE_NORMAL, + $prepend_preparation_time = false, + $notify_lecturers = false + ) + { + $course_dates = $request->getAffectedDates(); + + $bookings = []; + if ($course_dates) { + foreach ($course_dates as $course_date) { + $booking = $this->createBooking( + $user, + $course_date->id, + [ + [ + 'begin' => ( + $prepend_preparation_time + ? $course_date->date - $preparation_time + : $course_date->date + ), + 'end' => $course_date->end_time + ] + ], + null, + 0, + $course_date->end_time, + $preparation_time, + $description, + $internal_comment, + $booking_type + ); + + if ($booking instanceof ResourceBooking) { + $bookings[] = $booking; + } + } + } elseif (count($request->appointments)) { + //It is a request for multiple single dates. + //Such requests are resolved into multiple bookings. + foreach ($request->appointments as $appointment) { + $begin = ( + $prepend_preparation_time + ? $appointment->appointment->date - $preparation_time + : $appointment->appointment->date + ); + $end = $appointment->appointment->end_time; + + $booking = $this->createBooking( + $user, + $appointment->appointment_id, + [ + [ + 'begin' => $begin, + 'end' => $end + ] + ], + null, + 0, + $end, + $preparation_time, + $description, + $internal_comment, + $booking_type + ); + + if ($booking instanceof ResourceBooking) { + $bookings[] = $booking; + } + } + } else { + //No date objects for the request. + //It is a simple request: + $booking = $this->createBooking( + $user, + $request->user->id, + [ + [ + 'begin' => ( + $prepend_preparation_time + ? $request->begin - $preparation_time + : $request->begin + ), + 'end' => $request->end + ] + ], + null, + 0, + $request->end, + $preparation_time, + $description, + $internal_comment, + $booking_type + ); + + if ($booking instanceof ResourceBooking) { + $bookings[] = $booking; + } + } + + if (!$request->closeRequest($notify_lecturers)) { + throw new ResourceRequestException( + _('Die Anfrage konnte nicht als bearbeitet markiert werden!') + ); + } + + return $bookings; + } + + /** + * A factory method for creating a ResourceBooking object + * for this resource. + * + * @param User $user The user who wishes to create a resource booking. + * @param string $range_id The ID of the user (or the Stud.IP object) + * which owns the ResourceBooking. + * @param array[][] $time_ranges The time ranges for the booking. + * At least one time range has to be specified using unix timestamps + * or DateTime objects. + * This array has the following structure: + * [ + * [ + * 'begin' => The begin timestamp or DateTime object. + * 'end' => The end timestamp or DateTime object. + * ] + * ] + * @param DateInterval|null $repetition_interval The repetition interval + * for the new booking. This must be a DateInterval object if + * repetitions shall be stored. + * Otherwise this parameter must be set to null. + * @param int $repeat_amount The amount of repetitions. + * This parameter is only regarded if $repetition_interval contains + * a DateInterval object. + * In case repetitions are specified by their end date set this + * parameter to 0. + * @param DateTime|string|null $repetition_end_date The end date of the + * repetition. This can either be an unix timestamp or a DateTime object + * and will only be regarded if $repetition_interval contains a + * DateInterval object. + * In case repetitions are specified by their amount set this + * parameter to null. + * @param int $repetition_amount (obsolete, has no effect) + * @param int $preparation_time The preparation time which is needed before + * the real start time. This will be substracted + * from the begin timestamp and stored in an extra column of the + * resource_bookings table. + * @param string $description An optional description for the booking. + * This fields was previously known as "user_free_name". + * @param string $internal_comment An optional comment for the + * booking which is intended to be used internally + * in the room and resource administration staff. + * @param int $booking_type The booking type. + * 0 = normal booking + * 1 = reservation + * 2 = lock booking + * @param bool $force_booking If this parameter is set to true, + * overlapping bookings are removed before storing this booking. + * + * @return ResourceBooking object. + * @throws InvalidArgumentException If no time ranges are specified + * or if there is an error regarding the time ranges. + * @throws ResourceBookingRangeException If $range_id is not set. + * @throws ResourceBookingOverlapException If the booking overlaps + * with another booking or a resource lock. + * @throws ResourcePermissionException If the specified user does not + * have sufficient permissions to create a resource booking. + * @throws ResourceBookingException If the repetition interval + * is invalid or if the resource booking cannot be stored. + * + */ + public function createBooking( + User $user, + $range_id = null, + $time_ranges = [], + $repetition_interval = null, + $repetition_amount = 0, + $repetition_end_date = null, + $preparation_time = 0, + $description = '', + $internal_comment = '', + $booking_type = ResourceBooking::TYPE_NORMAL, + $force_booking = false + ) + { + if (!is_array($time_ranges)) { + throw new InvalidArgumentException( + _('Es wurden keine Zeitbereiche für die Buchung angegeben!') + ); + } + + $booking_begin = null; + $booking_end = null; + + //Check if each entry of the $time_intervals array is in the right + //format and if it contains either timestamps or DateTime objects. + //After that the time ranges are checked for validity (begin > end) + //and if there are locks or bookings in one of the time ranges. + //Furthermore all reservations that are affected by this booking + //are collected so that the persons who made the reservations + //can be informed about the new booking. + $affected_reservations = []; + foreach ($time_ranges as $index => $time_range) { + $begin = $time_range['begin']; + $end = $time_range['end']; + + if ($begin === null || $end === null) { + throw new InvalidArgumentException( + _('Mindestens eines der Zeitintervalls ist im falschen Format!') + ); + } + + if (!($begin instanceof DateTime)) { + $b = new DateTime(); + $b->setTimestamp($begin); + $begin = $b; + } + if (!($end instanceof DateTime)) { + $e = new DateTime(); + $e->setTimestamp($end); + $end = $e; + } + + $real_begin = clone $begin; + if ($preparation_time > 0) { + $real_begin = $real_begin->sub( + new DateInterval('PT' . $preparation_time . 'S') + ); + } + + if ($real_begin > $end) { + throw new InvalidArgumentException( + _('Der Startzeitpunkt darf nicht hinter dem Endzeitpunkt liegen!') + ); + } + + $duration = $end->getTimestamp() - $begin->getTimestamp(); + $min_duration = Config::get()->RESOURCES_MIN_BOOKING_TIME; + if ($min_duration && ($duration < ($min_duration * 60))) { + throw new InvalidArgumentException( + sprintf( + _('Die minimale Buchungsdauer von %1$d Minuten wurde unterschritten!'), + $min_duration + ) + ); + } + + if ($index == array_keys($time_ranges)[0]) { + $booking_begin = clone $begin; + $booking_end = clone $end; + } + + if ($repetition_interval instanceof DateInterval) { + //We must calculate the end of the repetition interval + //by using $repetition_amount or $repetition_end_date. + $repetition_end = null; + if ($repetition_end_date instanceof DateTime) { + $repetition_end = $repetition_end_date; + } elseif ($repetition_end_date) { + //convert $repetition_end_date to a DateTime object: + $red = new DateTime(); + $red->setTimestamp($repetition_end_date); + $repetition_end = $red; + } else { + //$repetition_end_date is not set: Use $repetition_amount. + //Add the repetition interval $repetition_amount times + //to the $real_begin DateTime object to get the end date + //of the repetition: + $repetition = clone $real_begin; + for ($i = 0; $i < $repetition_amount; $i++) { + $repetition = $repetition->add($repetition_interval); + } + $repetition_end = $repetition; + } + + $current_date = clone $real_begin; + + //Check for each repetition if the resource is available + //or locked: + while ($current_date <= $repetition_end) { + $current_begin = clone $current_date; + $current_end = clone $current_date; + $current_end->setTime( + intval($end->format('H')), + intval($end->format('i')), + intval($end->format('s')) + ); + + if ($current_begin < $current_end) { + $affected_reservations = array_merge( + ResourceBooking::findByResourceAndTimeRanges( + $this, + [ + [ + 'begin' => $current_begin->getTimestamp(), + 'end' => $current_end->getTimestamp(), + ] + ], + [1, 3] + ), + $affected_reservations + ); + } + + $current_date = $current_date->add($repetition_interval); + } + } else { + $affected_reservations = array_merge( + ResourceBooking::findByResourceAndTimeRanges( + $this, + [ + [ + 'begin' => $real_begin->getTimestamp(), + 'end' => $end->getTimestamp(), + ] + ], + [1, 3] + ), + $affected_reservations + ); + } + } + + $booking = new ResourceBooking(); + $booking->resource_id = $this->id; + $booking->booking_user_id = $user->id; + $booking->range_id = $range_id; + $booking->description = $description; + $booking->begin = $booking_begin->getTimestamp(); + $booking->end = $booking_end->getTimestamp(); + + if ($repetition_interval instanceof DateInterval) { + if ($repetition_end_date) { + if ($repetition_end_date instanceof DateTime) { + $booking->repeat_end = $repetition_end_date->getTimestamp(); + } else { + $booking->repeat_end = $repetition_end_date; + } + } + + $booking->repetition_interval = $repetition_interval->format('P%YY%MM%DD'); + } + + if ($preparation_time) { + $booking->preparation_time = $preparation_time; + } else { + $booking->preparation_time = '0'; + } + $booking->internal_comment = $internal_comment; + $booking->booking_type = (int)$booking_type; + + //We can finally store the new booking. + + try { + $booking->store($force_booking); + } catch (ResourceBookingOverlapException $e) { + if ($begin->format('Ymd') == $end->format('Ymd')) { + throw new ResourceBookingException( + sprintf( + _('%1$s: Die Buchung vom %2$s bis %3$s konnte wegen Überlappungen nicht gespeichert werden: %4$s'), + $this->getFullName(), + $begin->format('d.m.Y H:i'), + $end->format('H:i'), + $e->getMessage() + ) + ); + } else { + throw new ResourceBookingException( + sprintf( + _('%1$s: Die Buchung vom %2$s bis %3$s konnte wegen Überlappungen nicht gespeichert werden: %4$s'), + $this->getFullName(), + $begin->format('d.m.Y H:i'), + $end->format('d.m.Y H:i'), + $e->getMessage() + ) + ); + } + } catch (Exception $e) { + if ($begin->format('Ymd') == $end->format('Ymd')) { + throw new ResourceBookingException( + sprintf( + _('%1$s: Die Buchung vom %2$s bis %3$s konnte aus folgendem Grund nicht gespeichert werden: %4$s'), + $this->getFullName(), + $begin->format('d.m.Y H:i'), + $end->format('H:i'), + $e->getMessage() + ) + ); + } else { + throw new ResourceBookingException( + sprintf( + _('%1$s: Die Buchung vom %2$s bis %3$s konnte aus folgendem Grund nicht gespeichert werden: %4$s'), + $this->getFullName(), + $begin->format('d.m.Y H:i'), + $end->format('d.m.Y H:i'), + $e->getMessage() + ) + ); + } + } + + return $booking; + } + + /** + * This method creates a simple request for this resource. + * A simple request is not bound to a date, metadate + * or course object and its time ranges. Instead the time + * range is specified directly. + * Note that simple resource requests do not support recurrence. + * + * @param User $user The user who wishes to create a simple request. + * @param DateTime $begin The begin timestamp of the request. + * @param DateTime $end The end timestamp of the request. + * @param string $comment A comment for the resource request. + * @param int $preparation_time The requested preparation time before + * the begin of the requested time range. This parameter must be + * specified in seconds. Only positive values are accepted. + * + * @return ResourceRequest A resource request object. + * @throws AccessDeniedException If the user is not permitted + * to request this resource. + * @throws InvalidArgumentException If the the timestamps provided by + * $begin and $end are invalid or if $begin is greater than or equal + * to $end which results in an invalid time range. + * @throws ResourceUnavailableException If the resource is not available + * in the selected time range. + * @throws ResourceRequestException If the resource request + * cannot be stored. + * + */ + public function createSimpleRequest( + User $user, + DateTime $begin, + DateTime $end, + $comment = '', + $preparation_time = 0 + ) + { + //All users are permitted to create a request, + //if the resource is requestable. + + if (!$this->requestable) { + throw new InvalidArgumentException( + _('Diese Ressource kann nicht angefragt werden!') + ); + } + + if ($begin > $end) { + throw new InvalidArgumentException( + _('Der Startzeitpunkt darf nicht hinter dem Endzeitpunkt liegen!') + ); + } elseif ($begin == $end) { + throw new InvalidArgumentException( + _('Startzeitpunkt und Endzeitpunkt sind identisch!') + ); + } + + if (!$this->isAvailable($begin, $end)) { + throw new ResourceUnavailableException( + sprintf( + _('Die Ressource %1$s ist im Zeitraum von %2$s bis %3$s nicht verfügbar!'), + $this->name, + $begin->format('d.m.Y H:i'), + $end->format('d.m.Y H:i') + ) + ); + } + + $request = new ResourceRequest(); + $request->resource_id = $this->id; + $request->category_id = $this->category_id; + $request->user_id = $user->id; + + $request->begin = $begin->getTimestamp(); + $request->end = $end->getTimestamp(); + $request->preparation_time = ( + $preparation_time > 0 + ? $preparation_time + : 0 + ); + + $request->closed = '0'; + $request->comment = $comment; + + if (!$request->store()) { + throw new ResourceRequestException( + sprintf( + _('Die Anfrage zur Ressource %s konnte nicht gespeichert werden!'), + $this->name + ) + ); + } + + return $request; + } + + + /** + * This method creates a resource request for this resource. + * + * @param User $user The user who wishes to create a request. + * @param string|array $date_range_ids One or more IDs of Stud.IP objects + * which can provide at least one time range. + * Objects which fulfill this requirement are + * course dates (CourseDate objects), + * cycle dates (SeminarCycleDate objects) + * and courses (Course objects). + * If only one ID is provided it can be passed as string. + * If multiple IDs are provided they have to be passed as array. + * @param string $comment A comment for the resource request. + * @param mixed[] $properties The wishable properties + * for the resource request. The format of the array is as follows: + * [ + * 'property name' => 'property state' + * ] + * @param int $preparation_time The requested preparation time before + * the begin of the requested time range. This parameter must be + * specified in seconds. Only positive values are accepted. + * + * @return ResourceRequest A resource request object. + * @throws InvalidArgumentException If $date_range_id is not set. + * or no object which can provide at least one time range + * can be found with the specified ID. + * @throws ResourceNoTimeRangeException If no time range can be found + * by looking at the object, specified by its ID in $date_range_id. + * @throws ResourceUnavailableException If the resource is not available + * in the selected time range. + * @throws ResourceRequestException If the resource request + * cannot be stored. + * + */ + public function createRequest( + User $user, + $date_range_ids = null, + $comment = '', + $properties = [], + $preparation_time = 0 + ) + { + if (!$date_range_ids) { + throw new InvalidArgumentException( + _('Es wurde keine ID eines Objektes angegeben, welches Zeiträume für eine Ressourcenanfrage liefern kann!') + ); + } + + if (!$this->requestable) { + throw new InvalidArgumentException( + _('Diese Ressource kann nicht angefragt werden!') + ); + } + + //We must get the date ranges by looking at $date_range_id + //and the object which lies behind that ID. + + if (!is_array($date_range_ids)) { + $date_range_ids = [$date_range_ids]; + } + + $time_ranges = []; + foreach ($date_range_ids as $date_range_id) { + $time_ranges = array_merge( + $time_ranges, + ResourceManager::getTimeRangesFromRangeId( + $date_range_id + ) + ); + } + + if (!$time_ranges) { + //We couldn't find any time range. + throw new ResourceNoTimeRangeException( + sprintf( + _('Es konnte kein Zeitbereich für die Anfrage der Ressource %s gefunden werden.'), + $this->name + ) + ); + } + + //Default resource request handling: + //Check if the resource is available in all requested time ranges. + + foreach ($time_ranges as $time_range) { + if (!$this->isAvailable($time_range[0], $time_range[1])) { + throw new ResourceUnavailableException( + sprintf( + _('Die Ressource %1$s ist im Zeitraum vom %2$s bis %3$s nicht verfügbar!'), + $this->name, + $time_range[0]->format('d.m.Y H:i'), + $time_range[1]->format('d.m.Y H:i') + ) + ); + } + } + + //We must check, if all the properties exist: + if ($properties and is_array($properties)) { + foreach ($properties as $property_name => $property_state) { + $property_object = ResourcePropertyDefinition::findByName( + $property_name + ); + + if (!$property_object) { + throw new ResourcePropertyException( + sprintf( + _('Die Ressourceneigenschaft %s ist nicht definiert!'), + $property_name + ) + ); + } elseif (count($property_object) > 1) { + throw new ResourcePropertyException( + sprintf( + _('Es gibt mehrere Ressourceneigenschaften mit dem Namen %s!'), + $property_name + ) + ); + } + + //$property_object is an array of ResourcePropertyDefinition objects: + $property_data[] = [ + 'object' => $property_object[0], + 'state' => $property_state + ]; + } + } + + $request = new ResourceRequest(); + $request->resource_id = $this->id; + $request->category_id = $this->category_id; + $request->user_id = $user->id; + $request->comment = $comment; + $request->preparation_time = ( + $preparation_time > 0 + ? $preparation_time + : 0 + ); + $request->closed = '0'; + + //Resolve the date range ID and set the + //appropriate field in the request object: + if (count($date_range_ids) <= 1) { + $course_date = CourseDate::find($date_range_ids[0]); + if ($course_date) { + $request->termin_id = $course_date->id; + } else { + $cycle_date = SeminarCycleDate::find($date_range_ids[0]); + if ($cycle_date) { + $request->metadate_id = $cycle_date->id; + } else { + $course = Course::find($date_range_ids[0]); + if ($course) { + $request->course_id = $course->id; + } + } + } + + if (!$request->store()) { + throw new ResourceRequestException( + sprintf( + _('Die Anfrage zur Ressource %s konnte nicht gespeichert werden!'), + $this->name + ) + ); + } + } else { + if (!$request->store()) { + throw new ResourceRequestException( + sprintf( + _('Die Anfrage zur Ressource %s konnte nicht gespeichert werden!'), + $this->name + ) + ); + } + + //More than one entry: + //We must use ResourceBookingAppointment objects. + foreach ($date_range_ids as $date_range_id) { + $appointment_id = null; + $course_date = CourseDate::find($date_range_id); + if ($course_date) { + $appointment_id = $course_date->id; + } else { + $cycle_date = SeminarCycleDate::find($date_range_id); + if ($cycle_date) { + $appointment_id = $cycle_date->id; + } else { + $course = Course::find($date_range_id); + if ($course) { + $appointment_id = $course->id; + } + } + } + + if ($appointment_id) { + $rra = new ResourceRequestAppointment(); + $rra->request_id = $request->id; + $rra->appointment_id = $appointment_id; + if (!$rra->store()) { + throw new ResourceRequestException( + _('Die Terminzuordnungen zur Anfrage konnten nicht gespeichert werden!') + ); + } + } + } + } + + //The request has been created: Now we need to link the properties: + if(!empty($property_data)) { + foreach ($property_data as $property) { + $rrp = new ResourceRequestProperty(); + $rrp->request_id = $request->id; + $rrp->property_id = $property['object']->id; + $rrp->state = intval($property['state']); + if (!$rrp->store()) { + throw new InvalidResourceRequestException( + sprintf( + _('%1$s: Die Eigenschaft %2$s zur Anfrage konnte nicht gespeichert werden!'), + $this->getFullName(), + $property['object']->name + ) + ); + } + } + } + return $request; + } + + /** + * Creates a lock booking for this resource. + * + * @param User $user The user who wishes to create a lock booking. + * @param DateTime $begin The begin of the lock time range. + * @param DateTime $end The end of the lock time range. + * @param string $internal_comment An optional comment for the + * lock booking which is intended to be used internally + * in the room and resource administration staff. + * + * @return ResourceBooking A ResourceBooking object. + * @throws ResourceUnavailableException If a lock booking already + * exists in the specified time range. + * + * @throws AccessDeniedException If the user does not have sufficient + * permissions to lock this resource. + */ + public function createLock( + User $user, + DateTime $begin, + DateTime $end, + $internal_comment = '' + ) + { + if (!$this->userHasPermission($user, 'admin', [$begin, $end])) { + throw new AccessDeniedException( + sprintf( + _('%s: Unzureichende Berechtigungen zum Erstellen einer Sperrbuchung!'), + $this->getFullName() + ) + ); + } + + if ($this->isLocked($begin, $end)) { + throw new ResourceUnavailableException( + sprintf( + _('%1$s: Im Zeitbereich von %2$s bis %3$s gibt es bereits Sperrbuchungen!'), + $this->getFullName(), + $begin->format('d.m.Y H:i'), + $end->format('d.m.Y H:i') + ) + ); + } + + $lock = new ResourceBooking(); + $lock->booking_type = ResourceBooking::TYPE_LOCK; + $lock->range_id = $user->id; + $lock->resource_id = $this->id; + $lock->begin = $begin->getTimestamp(); + $lock->end = $end->getTimestamp(); + $lock->internal_comment = $internal_comment; + + if (!$lock->store()) { + throw new ResourceBookingException( + sprintf( + _('%1$s: Fehler beim Speichern der Sperrbuchung für den Zeitbereich von %2$s bis %3$s!'), + $begin->format('d.m.Y H:i'), + $end->format('d.m.Y H:i') + ) + ); + } + + return $lock; + } + + /** + * Retrieves the properties grouped by their property groups + * and in the order specified in that group. + * + * @param string[] excluded_properties An array with the names + * of the properties that shall be excluded from the result set. + * + * @return array An array with the group names as keys and the properties + * in the second array dimension. The structure of the array + * is as follows: + * [ + * group1 name => [ + * property1, + * property2, + * ... + * ], + * group2 name => [ + * ... + * ] + * ] + */ + public function getGroupedProperties($excluded_properties = []) + { + if (is_array($excluded_properties) && count($excluded_properties)) { + $properties = ResourceProperty::findBySql( + "INNER JOIN resource_property_definitions rpd + USING (property_id) + LEFT JOIN resource_property_groups rpg + ON rpd.property_group_id = rpg.id + WHERE + resource_properties.resource_id = :resource_id + AND + rpd.name NOT IN ( :excluded_properties ) + ORDER BY + rpg.position ASC, rpg.name ASC, + rpd.property_group_pos ASC, rpd.name ASC", + [ + 'resource_id' => $this->id, + 'excluded_properties' => $excluded_properties + ] + ); + } else { + $properties = ResourceProperty::findBySql( + "INNER JOIN resource_property_definitions rpd + USING (property_id) + LEFT JOIN resource_property_groups rpg + ON rpd.property_group_id = rpg.id + WHERE + resource_properties.resource_id = :resource_id + ORDER BY + rpg.position ASC, rpg.name ASC, + rpd.property_group_pos ASC, rpd.name ASC", + [ + 'resource_id' => $this->id + ] + ); + } + + if (!$properties) { + return []; + } + + $property_groups = []; + foreach ($properties as $property) { + if (!$property->state) { + continue; + } + $group_name = ''; + if (!empty($property->definition->group->name)) { + $group_name = $property->definition->group->name; + } + if (empty($property_groups[$group_name]) || !is_array($property_groups[$group_name])) { + $property_groups[$group_name] = []; + } + $property_groups[$group_name][] = $property; + } + + return $property_groups; + } + + + /** + * Determines wheter this resource has a property + * with the specified name. + * + * @param string $name The name of the resource property. + * + * @return bool True, if this resource has a property with + * the specified name, false otherwise. + */ + public function propertyExists($name = '') + { + if (!$name) { + return false; + } + + $db = DBManager::get(); + + $exists_stmt = $db->prepare( + "SELECT TRUE FROM resource_properties + INNER JOIN resource_property_definitions rpd + ON resource_properties.property_id = rpd.property_id + WHERE resource_properties.resource_id = :resource_id + AND rpd.name = :name"); + + $exists_stmt->execute( + [ + 'resource_id' => $this->id, + 'name' => $name + ] + ); + + $exists = $exists_stmt->fetchColumn(0); + + return (bool)$exists; + } + + /** + * Retrieves a ResourceProperty object for a property of this resource + * which has the specified name. If the property has not been set for this + * resource, but is defined for this resource's category, a new + * ResourceProperty object will be created, stored and returned. + * + * @param string $name The name of the resource property. + * + * @return ResourceProperty|null Either a ResourceProperty object for + * the resource property matching the specified name or null, + * if no resource property with the specified name can be found. + * @throws InvalidResourceCategoryException If this resource category + * doesn't match the category of the resource object. + * + */ + public function getPropertyObject(string $name) + { + if (!$this->propertyExists($name)) { + if ($name === 'geo_coordinates') { + return null; + } + //A property with the name $name does not exist for this + //resource object. If it is a defined property + //we can still try to create it: + + if ($this->category->hasProperty($name)) { + $property = $this->category->createDefinedResourceProperty( + $this, + $name + ); + + $property->store(); + return $property; + } else { + return null; + } + } + return ResourceProperty::findOneBySql( + "INNER JOIN resource_property_definitions rpd + ON resource_properties.property_id = rpd.property_id + WHERE resource_properties.resource_id = :resource_id + AND rpd.name = :name", + [ + 'resource_id' => $this->id, + 'name' => $name + ] + ); + } + + /** + * Returns all info-label properties + * + * @return SimpleCollection + */ + public function getInfolabelProperties() + { + return SimpleCollection::createFromArray( + ResourceProperty::findBySQL('INNER JOIN `resource_property_definitions` USING (`property_id`) + WHERE `info_label` = 1 AND `state` != "" AND `resource_id` = ?', [$this->id] + ) + ); + } + + /** + * Returns the state of the property specified by $name. + * If the property has not been set for this resource, but is defined + * for this resource's category, a new ResourceProperty object + * will be created, stored and its state will be returned. + * + * @param string $name The name of the resource property. + * + * @return string|null The state of the specified property or null + * if the propery can't be found. + */ + public function getProperty(string $name) + { + if (!$this->propertyExists($name)) { + //A property with the name $name does not exist for this + //resource object. If it is a defined property + //we can still try to create it: + + if ($this->category->hasProperty($name)) { + $property = $this->category->createDefinedResourceProperty( + $this, + $name, + '' + ); + + $property->store(); + return $property->state; + } else { + return null; + } + } + + $db = DBManager::get(); + + $value_stmt = $db->prepare( + "SELECT resource_properties.state FROM resource_properties + INNER JOIN resource_property_definitions rpd + ON resource_properties.property_id = rpd.property_id + WHERE resource_properties.resource_id = :resource_id + AND rpd.name = :name"); + + $value_stmt->execute( + [ + 'resource_id' => $this->id, + 'name' => $name + ] + ); + + $value = $value_stmt->fetchColumn(0); + + if (!$value) { + return null; + } + + return $value; + } + + /** + * Retrieves an object by the state of a property of this resource, + * specified by the property's name. + * This method is useful for properties of type user, institute + * or fileref. Those properties store IDs of User, Institute + * or FileRef objects. Therefore the IDs can be resolved directly + * to get the corresponding User, Institute or FileRef object directly. + * + * @param string $name The name of the resource property. + * + * @return SimpleORMap|null A SimpleORMap-based object or null, + * if no such object can be retrieved from the property's state. + */ + public function getPropertyRelatedObject(string $name) + { + //Get the property state first: + $property = $this->getPropertyObject($name); + + //Now we return the object which is referenced by the property's state: + + if ($property) { + switch ($property->definition->type) { + case 'user': + return User::find($property->state); + case 'institute': + return Institute::find($property->state); + case 'fileref' : + return FileRef::find($property->state); + default: + //For all other property types where we cannot create an object + //we return the raw state value: + return $property->state; + } + } + return null; + } + + /** + * Sets a specified property of this resource to the specified state. + * If the property has not been set for this resource, but is defined + * for this resource's category, a new ResourceProperty object + * will be created, stored and its state will be returned. + * + * @param string $name The name of the resource property. + * @param mixed $state The state of the resource property. + * @param User|null $user The user who wishes to set the property. + * + * @return bool True, if the property state could be set, false otherwise. + */ + public function setProperty(string $name, $state = '', $user = null) + { + if (!($user instanceof User)) { + $user = User::findCurrent(); + if (!$user) { + //We cannot continue without a user object! + return false; + } + } + + //Get the minimum permission level required for modifying the property: + + if (!$this->userHasPermission($user, 'admin')) { + throw new AccessDeniedException( + sprintf( + _('Unzureichende Berechtigungen zum Ändern der Ressource %s!'), + $this->name + ) + ); + } + if (!$this->category->userHasPropertyWritePermissions($name, $user, $this)) { + throw new AccessDeniedException( + sprintf( + _('Unzureichende Berechtigungen zum Ändern der Eigenschaft %s!'), + $name + ) + ); + } + + if (!$this->propertyExists($name)) { + //A property with the name $name does not exist for this + //resource object. If it is a defined property + //we can still try to create it: + + if ($this->category->hasProperty($name)) { + $property = $this->category->createDefinedResourceProperty( + $this, + $name, + $state + ); + return $property->store(); + } else { + return false; + } + } + + $property = $this->getPropertyObject($name); + + if ($property) { + $property->state = $state; + if ($property->isDirty()) { + return $property->store(); + } + return true; + } + + return false; + } + + /** + * Sets the properties (specified by their names) to the specified values. + * + * @param array $properties The properties array in the format "key-value". + * The array keys must contain the property name while the + * items of the array contain the values. + * Example: + * ['bar' => 'foo']: Sets the value 'foo' for the property + * with the name 'bar'. + * + * @param User|null $user The user who wishes to set the properties. + * If this is left empty, the current user will be used. + * + * @return array If properties cannot be set, their names (as key) and the + * error messages (if any) are returned. + * The array has the following structure: + * [ + * (property name) => (error message or empty string) + * ] + */ + public function setPropertiesByName(array $properties, User $user) + { + $failed_properties = []; + + if (!($user instanceof User)) { + $user = User::findCurrent(); + if (!$user) { + //No property can be set. + foreach ($properties as $name => $state) { + $failed_properties[$name] = ''; + } + return $failed_properties; + } + } + + foreach ($properties as $name => $state) { + try { + $this->setProperty($name, $state, $user); + } catch (Exception $e) { + $this->failed_properties[$name] = $e->getMessage(); + } + } + + return $failed_properties; + } + + /** + * Sets the properties (specified by their IDs) to the specified values. + * + * @param array $properties The properties array in the format "key-value". + * The array keys must contain the property-ID while the + * items of the array contain the values. + * Example: + * ['1' => 'foo']: Sets the value 'foo' for the property + * with the ID '1'. + * + * @param User|null $user The user who wishes to set the properties. + * If this is left empty, the current user will be used. + * + * @return array If properties cannot be set, their ids (as key) and the + * error messages (if any) are returned. + * The array has the following structure: + * [ + * (property-ID) => (error message or empty string) + * ] + */ + public function setPropertiesById(array $properties, User $user = null) + { + $failed_properties = []; + + if (!($user instanceof User)) { + $user = User::findCurrent(); + if (!$user) { + //No property can be set. + foreach ($properties as $id => $state) { + $failed_properties[$id] = ''; + } + return $failed_properties; + } + } + + foreach ($properties as $id => $state) { + $property = ResourcePropertyDefinition::find($id); + if (!$property) { + //Invalid property: + $this->failed_properties[$id] = + _('Die Eigenschaft wurde nicht gefunden!'); + continue; + } + try { + $this->setProperty($property->name, $state, $user); + } catch (Exception $e) { + $failed_properties[$id] = $e->getMessage(); + } + } + + return $failed_properties; + } + + /** + * Determines if the specified user has sufficient permissions to edit + * the property specified by its name. + * + * @param string $name The name of the resource property. + * @param user $user The user whose edit permissions shall be checked. + * + * @return bool True, if the user has edit permissions for the property, + * false otherwise. + */ + public function isPropertyEditable(string $name, User $user) + { + return $this->category->userHasPropertyWritePermissions($name, $user, $this); + } + + /** + * Sets the state of a property by its definition_id rather than its name. + * + * @param string $property_definition_id The definition-ID of the property. + * @param string $state The state of the property. + * + * @return bool True, if the property state can be stored, false otherwise. + * @throws ResourcePropertyStateException If the provided state is invalid + * for the specified resource property. + * + */ + public function setPropertyByDefinitionId($property_definition_id = null, $state = null) + { + if (!$property_definition_id and !$state) { + return false; + } + + //Get property definition: + $definition = ResourcePropertyDefinition::find($property_definition_id); + if (!$definition) { + return false; + } + + //Check if the state matches the property definition's rules: + $definition->validateState($state); + + //Check if the property for this resource already exists. + //If so, update it. Otherwise create it. + + $property = ResourceProperty::findOneBySql( + '(property_id = :property_id) AND (resource_id = :resource_id)', + [ + 'property_id' => $definition->id, + 'resource_id' => $this->id + ] + ); + + if (!$property) { + $property = new ResourceProperty(); + $property->property_id = $definition->id; + $property->resource_id = $this->id; + } + + $property->state = $state; + return $property->store(); + } + + /** + * Sets the property state by specifying an SimpleORMap object. + * This method is meant for resource properties of type user, + * institute or fileref. + * + * @param string $name The name of the resource property. + * @param SimpleORMap $object The object for the resource property. + * + * @return bool True, if the property has been saved, false otherwise. + */ + public function setPropertyRelatedObject(string $name, SimpleORMap $object) + { + //Get the property state first: + $property = $this->getPropertyObject($name); + + if (!$property) { + return false; + } + + //Now we return the object which is referenced by the property's state: + + switch ($property->definition->type) { + case 'user': + if (!($object instanceof User)) { + throw new ResourcePropertyException( + _("Eine Ressourceneigenschaft vom Typ 'user' benötigt ein Nutzer-Objekt zur Wertzuweisung!") + ); + } + break; + case 'institute': + if (!($object instanceof Institute)) { + throw new ResourcePropertyException( + _("Eine Ressourceneigenschaft vom Typ 'institute' benötigt ein Institut-Objekt zur Wertzuweisung!") + ); + } + break; + case 'fileref': + if (!($object instanceof FileRef)) { + throw new ResourcePropertyException( + _("Eine Ressourceneigenschaft vom Typ 'fileref' benötigt ein FileRef-Objekt zur Wertzuweisung!") + ); + } + break; + default: + break; + } + + //When no exception is thrown above we can set the object's ID + //as the property's state: + $property->state = $object->id; + + return $property->store(); + } + + /** + * Deletes a property for a resource. + * + * @param string $name The name of the property to be deleted. + * + * @param User $user The user who wishes to delete the property. + * @return number + */ + public function deleteProperty(string $name, User $user) + { + //Get the user object and the minimum permission level + //required for modifying the property: + + if (!$this->userHasPermission($user, 'admin')) { + throw new AccessDeniedException( + sprintf( + _('Unzureichende Berechtigungen zum Ändern der Ressource %s!'), + $this->name + ) + ); + } + if (!$this->category->userHasPropertyWritePermissions($name, $user)) { + throw new AccessDeniedException( + sprintf( + _('Unzureichende Berechtigungen zum Löschen der Eigenschaft %s!'), + $name + ) + ); + } + + return ResourceProperty::deleteBySql( + "INNER JOIN resource_property_definitions rpd + ON resource_properties.property_id = rpd.property_id + WHERE + rpd.name = :name AND resource_properties.resource_id = :resource_id", + [ + 'name' => $name, + 'resource_id' => $this->id + ] + ); + } + + /** + * Returns the path for the resource's image. + * If the resource has no image the path for a general + * resource icon will be returned. + * + * Classes derived from the Resource class should only re-implement + * this method if they have an alternative storage method for + * resource pictures than the Stud.IP file system. + * + * @return string The URL to the resource picture. + */ + public function getPictureUrl() + { + return ''; + } + + /** + * Returns the default picture for the resource class. + * + * Classes derived from Resource should re-implement this method + * if they want to get a different default picture than the resource icon. + * The call to getPictureUrl will call the getDefaultPictureUrl method + * from the derived class. + * + * @return string The URL to the picture. + */ + public function getDefaultPictureUrl() + { + return $this->getIcon()->asImagePath(); + } + + /** + * Returns the Icon for the resource class. + * + * Classes derived from Resource should re-implement this method + * if they want to get a different icon than the resource icon. + * @param string $role + * @return Icon The icon for the resource. + */ + public function getIcon($role = Icon::ROLE_INFO) + { + return Icon::create('resources', $role); + } + + /** + * Returns all properties in a two-dimensional array with the following + * property data inside of the second dimension: + * [ + * 'name' => (the property's name) + * 'display_name' => (the display name of the property) + * 'type' => (the property's type) + * 'state' => (the property's state) + * 'requestable' => (if the property is requestable or not (true or false)) + * ] + * + * @param bool $only_requestable_properties If only requestable properties + * shall be returned set this to true. If all properties shall be + * returned, set this to false. + * + * @return array[] A two-dimensional array containing property data. + */ + public function getPropertyArray($only_requestable_properties = false) + { + $property_array = []; + + if ($this->properties) { + foreach ($this->properties as $property) { + if ($only_requestable_properties) { + $category_property = ResourceCategoryProperty::findByNameAndCategoryId( + $property->name, + $this->category_id + ); + + if ($category_property) { + if ($category_property->requestable) { + $property_array[] = [ + 'name' => $property->name, + 'display_name' => $property->display_name, + 'type' => $property->type, + 'state' => $property->state, + 'requestable' => $property->isRequestable() + ]; + } + } + } else { + $property_array[] = [ + 'name' => $property->name, + 'display_name' => $property->display_name, + 'type' => $property->type, + 'state' => $property->state, + 'requestable' => $property->isRequestable() + ]; + } + } + } + return $property_array; + } + + /** + * Shortcut method for ResourceBooking::countByResourceAndTimeRanges. + * Determines whether normal resource bookings exist + * in the specified time range. + * + * @param DateTime $begin Time range start timestamp. + * + * @param DateTime $end Time range end timestamp. + * + * @param array $excluded_booking_ids The IDs of bookings that shall + * be excluded from the determination of the "assigned" status. + * + * @return bool True, if the resource is assigned in the specified + * time range, false otherwise. + */ + public function isAssigned( + DateTime $begin, + DateTime $end, + $excluded_booking_ids = [] + ) + { + return ResourceBooking::countByResourceAndTimeRanges( + $this, + [ + [ + 'begin' => $begin->getTimestamp(), + 'end' => $end->getTimestamp() + ] + ], + [0], + $excluded_booking_ids + ) > 0; + } + + /** + * Shortcut method for ResourceBooking::countByResourceAndTimeRanges. + * Determines whether resource reservations exist + * in the specified time range. + * + * @param DateTime $begin Time range start timestamp. + * + * @param DateTime $end Time range end timestamp. + * + * @param array $excluded_reservation_ids The IDs of reservation bookings that shall + * be excluded from the determination of the "reserved" status. + * + * @return bool True, if the resource is reserved in the specified + * time range, false otherwise. + */ + public function isReserved( + DateTime $begin, + DateTime $end, + $excluded_reservation_ids = [] + ) + { + //One second is added to the begin timestamp to avoid + //getting "false" overlaps where another booking ends on exactly + //the begin timestamp. + return ResourceBooking::countByResourceAndTimeRanges( + $this, + [ + [ + 'begin' => $begin->getTimestamp(), + 'end' => $end->getTimestamp() + ] + ], + [1, 3], + $excluded_reservation_ids + ) > 0; + } + + /** + * Shortcut method for ResourceBooking::countByResourceAndTimeRanges. + * Determines whether resource locks exist + * in the specified time range. + * + * @param DateTime $begin Time range start timestamp. + * + * @param DateTime $end Time range end timestamp. + * + * @param array $excluded_lock_ids The IDs of lock bookings that shall + * be excluded from the determination of the "locked" status. + * + * @return bool True, if the resource is locked in the specified + * time range, false otherwise. + */ + public function isLocked( + DateTime $begin, + DateTime $end, + $excluded_lock_ids = [] + ) + { + //One second is added to the begin timestamp to avoid + //getting "false" overlaps where another booking ends on exactly + //the begin timestamp. + return ResourceBooking::countByResourceAndTimeRanges( + $this, + [ + [ + 'begin' => $begin->getTimestamp(), + 'end' => $end->getTimestamp() + ] + ], + [2], + $excluded_lock_ids + ) > 0; + } + + /** + * Determines, if the resource is available (not assigned or locked) + * in a specified time range. + * + * @param DateTime $begin Time range start timestamp. + * @param DateTime $end Time range end timestamp. + * + * @param array $excluded_booking_ids The IDs of available bookings that shall + * be excluded from the determination of the "available" status. + * + * @return bool True, if the resource is available in the specified + * time range, false otherwise. + */ + public function isAvailable( + DateTime $begin, + DateTime $end, + $excluded_booking_ids = [] + ) + { + return ResourceBooking::countByResourceAndTimeRanges( + $this, + [ + [ + 'begin' => $begin->getTimestamp(), + 'end' => $end->getTimestamp() + ] + ], + [0, 2], + $excluded_booking_ids + ) == 0; + } + + /** + * Determines, if the resource is available (not assigned or locked) + * in the time ranges specified by a resource request. + * + * @param ResourceRequest $request A resource request object. + * + * @return bool True, if the resource is available in the + * time ranges of the resource request, false otherwise. + */ + public function isAvailableForRequest(ResourceRequest $request) + { + $time_intervals = $request->getTimeIntervals(true); + if (!$time_intervals) { + //Without a single time interval we cannot check + //if the resource is available. + return false; + } + foreach ($time_intervals as $time_interval) { + $begin = new DateTime(); + $end = new DateTime(); + $begin->setTimestamp($time_interval['begin']); + $end->setTimestamp($time_interval['end']); + + if (!$this->isAvailable($begin, $end)) { + //The resource is not available in the time interval. + //We can stop here and return false. + return false; + } + + //If code execution reaches this point the resource is + //available in all time intervals of the resource request: + return true; + } + } + + /** + * Returns the full (localised) name of the resource. + * + * @return string The full name of the resource. + */ + public function getFullName() + { + return sprintf( + _('Ressource %s'), + $this->name + ); + } + + /** + * Sets the permission for one user for this resource. + * + * @param User $user The user whose permission shall be set. + * @param string $perm The permission level for the specified user. + * The levels 'user', 'autor', 'tutor' and 'admin' are allowed. + * + * @return bool True, if the permission has been stored successfully, + * false otherwise. + */ + public function setUserPermission(User $user, $perm = 'autor') + { + if (!in_array($perm, ['user', 'autor', 'tutor', 'admin'])) { + return false; + } + + $perm_object = ResourcePermission::findOneBySql( + '(user_id = :user_id) AND (resource_id = :resource_id)', + [ + 'user_id' => $user->id, + 'resource_id' => $this->id + ] + ); + + if (!$perm_object) { + $perm_object = new ResourcePermission(); + $perm_object->user_id = $user->id; + $perm_object->resource_id = $this->id; + } + + $perm_object->perms = $perm; + $stored = (bool)$perm_object->store(); + if ($stored) { + if (!isset(self::$permission_cache[$this->id])) { + self::$permission_cache[$this->id] = []; + } + //Update the permission cache. + self::$permission_cache[$this->id][$user->id] = $perm; + } + return $stored; + } + + /** + * Deletes the permission a specified user has on this resource. + * + * @param User $user The user whose permission shall be deleted. + * + * @return bool True + */ + public function deleteUserPermission(User $user) + { + $deleted = ResourcePermission::deleteBySql( + '(user_id = :user_id) AND (resource_id = :resource_id)', + [ + 'user_id' => $user->id, + 'resource_id' => $this->id + ] + ); + + if ($deleted && is_array(self::$permission_cache[$this->id])) { + //Update the permission cache. + self::$permission_cache[$this->id][$user->id] = null; + } + + return true; + } + + /** + * Deletes all permissions of all users for this resource. + * + * @return bool True + */ + public function deleteAllPermissions() + { + ResourcePermission::deleteBySql( + 'resource_id = :resource_id', + [ + 'resource_id' => $this->id + ] + ); + + //Update the permission cache: + self::$permission_cache[$this->id] = []; + + return true; + } + + /** + * Retrieves the permission level a specified user + * has on this resource. + * + * Setting the optional $time_range parameter will also enable checks for + * temporary global permissions. + * + * @param User $user The user whose permission shall be retrieved. + * + * @param array $time_range (DateTime) This is an optional parameter that can + * be used to pass two DateTime objects to this method. The first object + * will be treated as the begin timestamp and the second one as the + * end timestamp. + * + * @param bool $permanent_only Whether to retrieve only permanent permissions + * (true) or permanent and temporary permissions (false). + * Defaults to false. + * + * @return string The permission level, expressed as string. + * The level can be 'user', 'autor', 'tutor' or 'admin'. + */ + public function getUserPermission(User $user, $time_range = [], $permanent_only = false) + { + if (ResourceManager::getGlobalResourcePermission($user) === 'admin') { + return 'admin'; + } + + + $perm_string = ''; + $temp_perm = null; + + $begin = time(); + $end = $begin; + //Check for a temporary permission first: + //check only against current timestamp + if (!$permanent_only) { + $temp_perm = ResourceTemporaryPermission::findOneBySql( + '(resource_id = :resource_id) AND (user_id = :user_id) + AND (begin <= :begin) AND (end >= :end)', + [ + 'resource_id' => $this->id, + 'user_id' => $user->id, + 'begin' => $begin, + 'end' => $end + ] + ); + } + + if ($temp_perm) { + $perm_string = $temp_perm->perms; + } else { + //No temporary permission exist or has been retrieved. + //Check for a "normal" permission. + $cached_perms = self::$permission_cache[$this->id][$user->id] ?? null; + if ($cached_perms === null) { + //The permission of the specified user is not in the + //permission cache. Load it from the database and store + //it in the permission cache before returning it. + $perms = ResourcePermission::findOneBySql( + '(resource_id = :resource_id) AND (user_id = :user_id)', + [ + 'resource_id' => $this->id, + 'user_id' => $user->id + ] + ); + if ($perms) { + if (!isset(self::$permission_cache[$this->id])) { + self::$permission_cache[$this->id] = []; + } + self::$permission_cache[$this->id][$user->id] = $perms->perms; + $perm_string = $perms->perms; + } + } else { + $perm_string = $cached_perms; + } + } + + if (!$perm_string) { + //A user which doesn't have special permissions for this resource + //can have global resource permissions: + $global_perm = ResourceManager::getGlobalResourcePermission($user); + if ($global_perm) { + //Set the permission cache: + if (!isset(self::$permission_cache[$this->id])) { + self::$permission_cache[$this->id] = []; + } + self::$permission_cache[$this->id][$user->id] = $global_perm; + } + $perm_string = $global_perm; + } + //Now we must check for global resource locks: + if ($perm_string && $time_range && $this->lockable) { + + if ($time_range[0] instanceof DateTime) { + $begin = $time_range[0]->getTimestamp(); + } else { + $begin = $time_range[0]; + } + if ($time_range[1] instanceof DateTime) { + $end = $time_range[1]->getTimestamp(); + } else { + $end = $time_range[1]; + } + if (GlobalResourceLock::isLocked($begin, $end)) { + //A permission level exists for the user. + //The user gets "user" permissions in case + //a global lock is active. + $perm_string = 'user'; + } + } + + //No global resource lock exists. We must return + //the permission string if it is set: + if ($perm_string) { + return $perm_string; + } + + return ''; + } + + /** + * Determines if a user has the specified permission. + * + * @param ?User $user The user whose permissions shall be checked on this + * resource object. May be null. + * @param string $permission The permission level. + * @param $time_range @TODO + * + * @return bool True, if the specified user has the specified permission, + * false otherwise. + */ + public function userHasPermission( + ?User $user, + string $permission = 'user', + array $time_range = [] + ) + { + if (!in_array($permission, ['user', 'autor', 'tutor', 'admin']) || $user === null) { + return false; + } + + + if (ResourceManager::getGlobalResourcePermission($user) === 'admin') { + return true; + } + + $perm_level = $this->getUserPermission($user, $time_range); + + if ($permission === 'user') { + //No check for global resource locks here: + //If only user permissions are requested we can safely grant them + //since 'user' users may only perform reading actions but + //no writing actions. + if (in_array($perm_level, ['user', 'autor', 'tutor', 'admin'])) { + return true; + } else { + return false; + } + } elseif ($permission === 'autor') { + if (in_array($perm_level, ['autor', 'tutor', 'admin'])) { + return true; + } else { + return false; + } + } elseif ($permission === 'tutor') { + if (in_array($perm_level, ['tutor', 'admin'])) { + return true; + } else { + return false; + } + } elseif ($permission === 'admin') { + if ($perm_level == 'admin') { + return true; + } else { + return false; + } + } + //Code execution should be finished at this point. + //If this point is reached the user has no permissions for the + //resource management system at all. + return false; + } + + /** + * Determines whether the user may create a child resource + * on this resource. + * + * @param User $user The user whose permission to create a child + * resource shall be checked. + * + * @return bool True, if the user may create a child resource + * on this resource, false otherwise. + */ + public function userMayCreateChild(User $user) + { + return $this->userHasPermission($user, 'admin'); + } + + /** + * Checks if the specified user has sufficient permissions to make resource + * requests, according to the setting RESOURCES_MIN_REQUEST_PERMISSION. + * This permission check is only relevant for creating requests that are not + * bound to a course. + * + * @param User $user The user whose request permissions shall be checked. + * + * @return bool True, if the user has request permissions, false otherwise. + */ + public function userHasRequestRights(User $user) + { + if (!Config::get()->RESOURCES_ALLOW_ROOM_REQUESTS) { + return false; + } + $min_perm = Config::get()->RESOURCES_MIN_REQUEST_PERMISSION; + if (!in_array($min_perm, ['', 'user', 'autor', 'tutor', 'admin'])) { + //Invalid permission level! + return false; + } + if (!$min_perm) { + //No minimum permission set: Every logged-in user + //can create requests. + return true; + } + return $this->userHasPermission($user, $min_perm); + } + + /** + * Determines whether the user may book the resource or not. + * An optional time range can be set to check the user's + * temporary permissions on another date than the current date. + * + * @param User $user The user whose booking permissions shall be checked. + * + * @param int|string|DateTime $begin The begin timestamp of the + * optional time range. + * + * @param int|string|DateTime $end The end timestamp of the + * optional time range. + * + * @return bool True, if the user may book the resource, false otherwise. + */ + public function userHasBookingRights( + User $user, + $begin = null, + $end = null + ) + { + if ($begin && $end) { + $time_range = [$begin, $end]; + } else { + $time_range = []; + } + + //Check the permissions on this resource and the global permissions: + return $this->userHasPermission($user, 'autor', $time_range); + } + + /** + * Determines if the booking plan of the resource is visible for a + * specified user. + * + * @param ?User $user The user whose permission to view the booking plan + * shall be determined. May be null. + * + * @param DateTime[] $time_range An optional time range for the + * permission check. + * @return bool True, if the user can see the resource booking plan, + * false otherwise. + * @see Resource::getUserPermission + * + */ + public function bookingPlanVisibleForUser(?User $user, $time_range = []) + { + return $this->userHasPermission($user, 'user', $time_range); + } + + /** + * Retrieves a parent resource object that matches the specified + * class name. The search stops when either a parent resource + * with the class name is found or when the root resource object + * is reached. + * + * @param string $class_name The class name of the parent. + * + * @return Resource|null Either a resource object or null + * in case a matching parent resource cannot be found. + */ + public function findParentByClassName($class_name = 'Resource') + { + $resource_ids = [$this->id]; + $resource = $this->parent; + + while ($resource) { + //We should check for circular hierarchies first + //to avoid an endless while loop: + if (in_array($resource->id, $resource_ids)) { + //We have a circular hierarchy: this resource is + //the parent of itself which is an invalid state! + throw new InvalidResourceException( + sprintf( + _('Zirkuläre Hierarchie: Die Ressource %1$s ist ein Elternknoten von sich selbst!'), + $resource->name + ) + ); + } + if (is_a($resource->class_name, $class_name, true)) { + //We have found a parent node which has the + //specified class name: return that parent. + return $resource; + } + //The current parent was not the one we were looking for. + //Therefore we must go one layer up in the resource + //hierarchy and continue search: + $resource_ids[] = $resource->id; + $resource = $resource->parent; + } + //The search was not successful: + //We have reached the root resource (whose parent_id field + //is set to an equivalend of NULL) and we haven't found a + //resource matching the specified class name. + return null; + } + + /** + * This method searches the hierarchy below this resource + * to find resources matching the specified class name. + * Via the optional parameter $depth the search can be limited + * to a specific amount of layers. + * + * @param string $class_name The name of the resource class + * where resources shall be found to. + * @param int $depth The (optional) maximum depth below this resource + * which shall be searched. + * @param bool $convert_objects True, if objects shall be converted to + * $class_name (default), false otherwise. + * @param bool $order_by_name Order the children by name. + * Defaults to true. + * + * @return Resource[] An array of resource objects or an empty array + * if no matching resources can be found. + */ + public function findChildrenByClassName( + $class_name = 'Resource', + $depth = 0, + $convert_objects = true, + $order_by_name = true + ) + { + $result = []; + if ($this->children) { + //this resource has children: iterate over them and + //check if they match the search criteria. + foreach ($this->children as $child) { + if (is_a($child->class_name, $class_name, true)) { + if ($convert_objects) { + $result[] = $child->getDerivedClassInstance(); + } else { + $result[] = $child; + } + } + if (($depth > 1) || ($depth == 0)) { + //Search the child and lower depth by one when calling this + //method on the child. + $result = array_merge( + $result, + $child->findChildrenByClassName( + $class_name, + (($depth > 1) ? $depth - 1 : 0), + $convert_objects + ) + ); + } + } + if ($order_by_name) { + usort( + $result, + function ($a, $b) { + if ($a->name == $b->name) { + return 0; + } elseif ($a->name < $b->name) { + return -1; + } else { + return 1; + } + } + ); + } + } + return $result; + } + + /** + * Adds a resource as child resource to this resource. + * + * @param Resource $resource The child resource. + * + * @return bool True on success, false on failure. + */ + public function addChild(Resource $resource) + { + $old_parent = $resource->parent; + $old_parent_id = $resource->parent_id; + + $resource->parent = $this; + $resource->parent_id = $this->id; + + if (!$resource->checkHierarchy()) { + //We must revert the parent fields since $resource + //may be used in other code pieces afterwards. + $resource->parent = $old_parent; + $resource->parent_id = $old_parent_id; + throw new InvalidArgumentException( + sprintf( + _('Die Ressource %1$s (Typ %2$s) kann nicht unterhalb der Ressource %3$s (Typ %4$s) platziert werden!'), + $resource->name, + $resource->class_name, + $this->name, + $this->class_name + ) + ); + } + if ($resource->isDirty()) { + //Only store the resource object if setting the parent_id field + //did change it: + return $resource->store(); + } + //The resource object hasn't changed by setting the parent_id field: + //We can return true. + return true; + } + + /** + * Get all resource requests for the resource in a given timeframe. + * + * @param DateTime $begin Begin of timeframe. + * @param DateTime $end End of timeframe. + * + * @return ResourceRequest[] An array of ResourceRequest objects. + */ + public function getOpenResourceRequests(DateTime $begin, DateTime $end) + { + //We must get all requests that either have a start and end date + //set or that have a start date, repeate end, repeat interval and + //repeat quantity set. + + return ResourceRequest::findByResourceAndTimeRanges( + $this, + [ + [ + 'begin' => $begin->getTimestamp(), + 'end' => $end->getTimestamp() + ] + ], + 0 + ); + } + + /** + * Get all resource bookings for the resource in a given timeframe. + * + * @param DateTime $begin Begin of timeframe. + * @param DateTime $end End of timeframe. + * @param array $booking_types + * + * @return ResourceBooking[] An array of ResourceBooking objects. + */ + public function getResourceBookings(DateTime $begin, DateTime $end, array $booking_types = [0]) + { + return ResourceBooking::findByResourceAndTimeRanges( + $this, + [ + [ + 'begin' => $begin->getTimestamp(), + 'end' => $end->getTimestamp() + ] + ], + $booking_types + ); + } + + + /** + * Get all resource locks for the resource in a given timeframe. + * + * @param DateTime $begin Begin of timeframe. + * @param DateTime $end End of timeframe. + * + * @return ResourceBooking[] An array of ResourceBooking objects. + */ + public function getResourceLocks(DateTime $begin, DateTime $end) + { + return ResourceBooking::findByResourceAndTimeRanges( + $this, + [ + [ + $begin->getTimestamp(), + $end->getTimestamp() + ] + ], + [2] + ); + } + + + /** + * Determines if files are attached to this resource. + * If a folder exists for this resource its files are counted. + * Depending on whether the folder has files in it or not + * this method returns true or false. + * + * @return bool True, if there are files attached to this resource, + * false otherwise. + */ + public function hasFiles() + { + $folder = Folder::findOneBySql( + 'range_id = :range_id', + [ + 'range_id' => $this->id + ] + ); + + if (!$folder) { + return false; + } + + //Since files from resources shall always be stored in the + //Stud.IP file system we can skip the conversion from Folder + //to FolderType and count the FileRef-objects for this resource + //directly in the database. Since resource folders do not + //have subfolders we will count any file of the resource: + return FileRef::countBySql( + 'folder_id = :folder_id', + [ + 'folder_id' => $folder->id + ] + ) > 0; + } + + /** + * Converts a Resource object to an object of a specialised resource class. + * + * @return Resource|other An object of a specialised resource class + * or a Resource object, if the resource is a standard resource + * with the class_name 'Resource' in its resource category. + * If the derived resource class is not available, an instance of + * BrokenResource is returned. + */ + public function getDerivedClassInstance() + { + $class_name = $this->class_name; + + if ($class_name == 'Resource') { + //It is a standard resource which is managed by this class. + return $this; + } + + if (is_subclass_of($class_name, 'Resource')) { + $converted_resource = $class_name::buildExisting( + $this->toRawArray() + ); + return $converted_resource; + } else { + //$class_name does not contain the name of a subclass + //of Resource. That's an error! + $broken_resource = BrokenResource::buildExisting( + $this->toRawArray() + ); + return $broken_resource; + } + } + + /** + * Checks if the place in the resource hierarchy (resource tree) + * is correct for this resource. + * This method has no function in this class but can be filled + * with logic in one of the classes derived from Resource. + * + * @return bool True, if this resource is correctly placed, + * false otherwise. + * @throws NoResourceClassException + * if the class name of this resource is not a derived class + * of the Resource class. + * + */ + public function checkHierarchy() + { + if ($this->class_name == 'Resource') { + //Objects of the Resource class are always in the right + //place of the resource hierarchy. + return true; + } + + //The object does not use the Resource class name and uses + //a derived class instead. We must check the hierarchy + //using the checkHierarchy method of the derived class. + + $converted_resource = $this->getDerivedClassInstance(); + return $converted_resource->checkHierarchy(); + } + + /** + * Returns the link for an action for this resource. + * This is the non-static variant of Resource::getLinkForAction. + * + * @param string $action The action which shall be executed. + * For default Resources the actions 'show', 'add', 'edit' and 'delete' + * are defined. + * @param array $link_parameters Optional parameters for the link. + * @return string @TODO + */ + public function getActionLink($action = 'show', $link_parameters = []) + { + //We must check the class name and call the appropriate + //getLinkForAction method for derived classes: + + $class_name = $this->class_name; + if (is_subclass_of($class_name, 'Resource')) { + return $class_name::getLinkForAction( + $action, + $this->id, + $link_parameters + ); + } else { + return self::getLinkForAction( + $action, + $this->id, + $link_parameters + ); + } + } + + /** + * Returns the URL for an action for this resource. + * This is the non-static variant of Resource::getURLForAction. + * + * @param string $action The action which shall be executed. + * For default Resources the actions 'show', 'add', 'edit' and 'delete' + * are defined. + * @param array $url_parameters Optional parameters for the URL. + * @return string @TODO + */ + public function getActionURL($action = 'show', $url_parameters = []) + { + //We must check the class name and call the appropriate + //getURLForAction method for derived classes: + + $class_name = $this->class_name; + if (is_subclass_of($class_name, 'Resource')) { + return $class_name::getURLForAction( + $action, + $this->id, + $url_parameters + ); + } else { + return self::getURLForAction( + $action, + $this->id, + $url_parameters + ); + } + } + + public function getItemName($long_format = true) + { + if ($long_format) { + //In some cases the general Resource class may be used + //when the resource objects are in fact instances + //of derived classes. To make sure that the correct prefix + //is always displayed, we retrieve the derived class first + //before returning the name: + $derived_class = $this->getDerivedClassInstance(); + return $derived_class->getFullName(); + } else { + return $this->name; + } + } + + public function getItemURL() + { + return $this->getActionURL('show'); + } + + public function getItemAvatarURL() + { + return Icon::create('resources', Icon::ROLE_INFO)->asImagePath(); + } + + + public function getLink() : StudipLink + { + return new StudipLink($this->getActionURL(), $this->name, Icon::create('resources')); + } +} |
