diff options
author | Richard Steinmetz <richard@steinmetz.cloud> | 2023-11-13 17:36:24 +0100 |
---|---|---|
committer | Christoph Wurst <christoph@winzerhof-wurst.at> | 2023-11-23 17:18:49 +0100 |
commit | 8191295f66cdea5da7854bfad01ad9540c4a55f4 (patch) | |
tree | 93fd27fe91ab0f40b4c765139a957b420806dedf /apps | |
parent | 953382e937a4085c1099449b29c40c7aab02fc3e (diff) | |
download | nextcloud-server-8191295f66cdea5da7854bfad01ad9540c4a55f4.tar.gz nextcloud-server-8191295f66cdea5da7854bfad01ad9540c4a55f4.zip |
feat(dav): dispatch out-of-office started and ended events
Signed-off-by: Richard Steinmetz <richard@steinmetz.cloud>
Diffstat (limited to 'apps')
-rw-r--r-- | apps/dav/composer/composer/autoload_classmap.php | 4 | ||||
-rw-r--r-- | apps/dav/composer/composer/autoload_static.php | 4 | ||||
-rw-r--r-- | apps/dav/lib/BackgroundJob/OutOfOfficeEventDispatcherJob.php | 92 | ||||
-rw-r--r-- | apps/dav/lib/CalDAV/TimezoneService.php | 98 | ||||
-rw-r--r-- | apps/dav/lib/Controller/AvailabilitySettingsController.php | 15 | ||||
-rw-r--r-- | apps/dav/lib/Db/Absence.php | 11 | ||||
-rw-r--r-- | apps/dav/lib/Db/AbsenceMapper.php | 25 | ||||
-rw-r--r-- | apps/dav/lib/Db/Property.php | 53 | ||||
-rw-r--r-- | apps/dav/lib/Db/PropertyMapper.php | 57 | ||||
-rw-r--r-- | apps/dav/lib/Service/AbsenceService.php | 75 | ||||
-rw-r--r-- | apps/dav/tests/integration/Db/PropertyMapperTest.php | 55 | ||||
-rw-r--r-- | apps/dav/tests/unit/BackgroundJob/OutOfOfficeEventDispatcherJobTest.php | 175 | ||||
-rw-r--r-- | apps/dav/tests/unit/CalDAV/TimezoneServiceTest.php | 161 |
13 files changed, 786 insertions, 39 deletions
diff --git a/apps/dav/composer/composer/autoload_classmap.php b/apps/dav/composer/composer/autoload_classmap.php index 7891cad42eb..ef28133ed06 100644 --- a/apps/dav/composer/composer/autoload_classmap.php +++ b/apps/dav/composer/composer/autoload_classmap.php @@ -18,6 +18,7 @@ return array( 'OCA\\DAV\\BackgroundJob\\CleanupInvitationTokenJob' => $baseDir . '/../lib/BackgroundJob/CleanupInvitationTokenJob.php', 'OCA\\DAV\\BackgroundJob\\EventReminderJob' => $baseDir . '/../lib/BackgroundJob/EventReminderJob.php', 'OCA\\DAV\\BackgroundJob\\GenerateBirthdayCalendarBackgroundJob' => $baseDir . '/../lib/BackgroundJob/GenerateBirthdayCalendarBackgroundJob.php', + 'OCA\\DAV\\BackgroundJob\\OutOfOfficeEventDispatcherJob' => $baseDir . '/../lib/BackgroundJob/OutOfOfficeEventDispatcherJob.php', 'OCA\\DAV\\BackgroundJob\\PruneOutdatedSyncTokensJob' => $baseDir . '/../lib/BackgroundJob/PruneOutdatedSyncTokensJob.php', 'OCA\\DAV\\BackgroundJob\\RefreshWebcalJob' => $baseDir . '/../lib/BackgroundJob/RefreshWebcalJob.php', 'OCA\\DAV\\BackgroundJob\\RegisterRegenerateBirthdayCalendars' => $baseDir . '/../lib/BackgroundJob/RegisterRegenerateBirthdayCalendars.php', @@ -100,6 +101,7 @@ return array( 'OCA\\DAV\\CalDAV\\Search\\Xml\\Request\\CalendarSearchReport' => $baseDir . '/../lib/CalDAV/Search/Xml/Request/CalendarSearchReport.php', 'OCA\\DAV\\CalDAV\\Status\\Status' => $baseDir . '/../lib/CalDAV/Status/Status.php', 'OCA\\DAV\\CalDAV\\Status\\StatusService' => $baseDir . '/../lib/CalDAV/Status/StatusService.php', + 'OCA\\DAV\\CalDAV\\TimezoneService' => $baseDir . '/../lib/CalDAV/TimezoneService.php', 'OCA\\DAV\\CalDAV\\Trashbin\\DeletedCalendarObject' => $baseDir . '/../lib/CalDAV/Trashbin/DeletedCalendarObject.php', 'OCA\\DAV\\CalDAV\\Trashbin\\DeletedCalendarObjectsCollection' => $baseDir . '/../lib/CalDAV/Trashbin/DeletedCalendarObjectsCollection.php', 'OCA\\DAV\\CalDAV\\Trashbin\\Plugin' => $baseDir . '/../lib/CalDAV/Trashbin/Plugin.php', @@ -210,6 +212,8 @@ return array( 'OCA\\DAV\\Db\\AbsenceMapper' => $baseDir . '/../lib/Db/AbsenceMapper.php', 'OCA\\DAV\\Db\\Direct' => $baseDir . '/../lib/Db/Direct.php', 'OCA\\DAV\\Db\\DirectMapper' => $baseDir . '/../lib/Db/DirectMapper.php', + 'OCA\\DAV\\Db\\Property' => $baseDir . '/../lib/Db/Property.php', + 'OCA\\DAV\\Db\\PropertyMapper' => $baseDir . '/../lib/Db/PropertyMapper.php', 'OCA\\DAV\\Direct\\DirectFile' => $baseDir . '/../lib/Direct/DirectFile.php', 'OCA\\DAV\\Direct\\DirectHome' => $baseDir . '/../lib/Direct/DirectHome.php', 'OCA\\DAV\\Direct\\Server' => $baseDir . '/../lib/Direct/Server.php', diff --git a/apps/dav/composer/composer/autoload_static.php b/apps/dav/composer/composer/autoload_static.php index fdfe7feb6d9..73e680ef2e8 100644 --- a/apps/dav/composer/composer/autoload_static.php +++ b/apps/dav/composer/composer/autoload_static.php @@ -33,6 +33,7 @@ class ComposerStaticInitDAV 'OCA\\DAV\\BackgroundJob\\CleanupInvitationTokenJob' => __DIR__ . '/..' . '/../lib/BackgroundJob/CleanupInvitationTokenJob.php', 'OCA\\DAV\\BackgroundJob\\EventReminderJob' => __DIR__ . '/..' . '/../lib/BackgroundJob/EventReminderJob.php', 'OCA\\DAV\\BackgroundJob\\GenerateBirthdayCalendarBackgroundJob' => __DIR__ . '/..' . '/../lib/BackgroundJob/GenerateBirthdayCalendarBackgroundJob.php', + 'OCA\\DAV\\BackgroundJob\\OutOfOfficeEventDispatcherJob' => __DIR__ . '/..' . '/../lib/BackgroundJob/OutOfOfficeEventDispatcherJob.php', 'OCA\\DAV\\BackgroundJob\\PruneOutdatedSyncTokensJob' => __DIR__ . '/..' . '/../lib/BackgroundJob/PruneOutdatedSyncTokensJob.php', 'OCA\\DAV\\BackgroundJob\\RefreshWebcalJob' => __DIR__ . '/..' . '/../lib/BackgroundJob/RefreshWebcalJob.php', 'OCA\\DAV\\BackgroundJob\\RegisterRegenerateBirthdayCalendars' => __DIR__ . '/..' . '/../lib/BackgroundJob/RegisterRegenerateBirthdayCalendars.php', @@ -115,6 +116,7 @@ class ComposerStaticInitDAV 'OCA\\DAV\\CalDAV\\Search\\Xml\\Request\\CalendarSearchReport' => __DIR__ . '/..' . '/../lib/CalDAV/Search/Xml/Request/CalendarSearchReport.php', 'OCA\\DAV\\CalDAV\\Status\\Status' => __DIR__ . '/..' . '/../lib/CalDAV/Status/Status.php', 'OCA\\DAV\\CalDAV\\Status\\StatusService' => __DIR__ . '/..' . '/../lib/CalDAV/Status/StatusService.php', + 'OCA\\DAV\\CalDAV\\TimezoneService' => __DIR__ . '/..' . '/../lib/CalDAV/TimezoneService.php', 'OCA\\DAV\\CalDAV\\Trashbin\\DeletedCalendarObject' => __DIR__ . '/..' . '/../lib/CalDAV/Trashbin/DeletedCalendarObject.php', 'OCA\\DAV\\CalDAV\\Trashbin\\DeletedCalendarObjectsCollection' => __DIR__ . '/..' . '/../lib/CalDAV/Trashbin/DeletedCalendarObjectsCollection.php', 'OCA\\DAV\\CalDAV\\Trashbin\\Plugin' => __DIR__ . '/..' . '/../lib/CalDAV/Trashbin/Plugin.php', @@ -225,6 +227,8 @@ class ComposerStaticInitDAV 'OCA\\DAV\\Db\\AbsenceMapper' => __DIR__ . '/..' . '/../lib/Db/AbsenceMapper.php', 'OCA\\DAV\\Db\\Direct' => __DIR__ . '/..' . '/../lib/Db/Direct.php', 'OCA\\DAV\\Db\\DirectMapper' => __DIR__ . '/..' . '/../lib/Db/DirectMapper.php', + 'OCA\\DAV\\Db\\Property' => __DIR__ . '/..' . '/../lib/Db/Property.php', + 'OCA\\DAV\\Db\\PropertyMapper' => __DIR__ . '/..' . '/../lib/Db/PropertyMapper.php', 'OCA\\DAV\\Direct\\DirectFile' => __DIR__ . '/..' . '/../lib/Direct/DirectFile.php', 'OCA\\DAV\\Direct\\DirectHome' => __DIR__ . '/..' . '/../lib/Direct/DirectHome.php', 'OCA\\DAV\\Direct\\Server' => __DIR__ . '/..' . '/../lib/Direct/Server.php', diff --git a/apps/dav/lib/BackgroundJob/OutOfOfficeEventDispatcherJob.php b/apps/dav/lib/BackgroundJob/OutOfOfficeEventDispatcherJob.php new file mode 100644 index 00000000000..9b219cf30da --- /dev/null +++ b/apps/dav/lib/BackgroundJob/OutOfOfficeEventDispatcherJob.php @@ -0,0 +1,92 @@ +<?php + +declare(strict_types=1); + +/** + * @copyright Copyright (c) 2023 Richard Steinmetz <richard@steinmetz.cloud> + * + * @author Richard Steinmetz <richard@steinmetz.cloud> + * + * @license AGPL-3.0-or-later + * + * 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 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + * + */ + +namespace OCA\DAV\BackgroundJob; + +use OCA\DAV\CalDAV\TimezoneService; +use OCA\DAV\Db\AbsenceMapper; +use OCP\AppFramework\Db\DoesNotExistException; +use OCP\AppFramework\Utility\ITimeFactory; +use OCP\BackgroundJob\QueuedJob; +use OCP\EventDispatcher\IEventDispatcher; +use OCP\IUserManager; +use OCP\User\Events\OutOfOfficeEndedEvent; +use OCP\User\Events\OutOfOfficeStartedEvent; +use Psr\Log\LoggerInterface; + +class OutOfOfficeEventDispatcherJob extends QueuedJob { + public const EVENT_START = 'start'; + public const EVENT_END = 'end'; + + public function __construct( + ITimeFactory $time, + private AbsenceMapper $absenceMapper, + private LoggerInterface $logger, + private IEventDispatcher $eventDispatcher, + private IUserManager $userManager, + private TimezoneService $timezoneService, + ) { + parent::__construct($time); + } + + public function run($argument): void { + $id = $argument['id']; + $event = $argument['event']; + + try { + $absence = $this->absenceMapper->findById($id); + } catch (DoesNotExistException | \OCP\DB\Exception $e) { + $this->logger->error('Failed to dispatch out-of-office event: ' . $e->getMessage(), [ + 'exception' => $e, + 'argument' => $argument, + ]); + return; + } + + $userId = $absence->getUserId(); + $user = $this->userManager->get($userId); + if ($user === null) { + $this->logger->error("Failed to dispatch out-of-office event: User $userId does not exist", [ + 'argument' => $argument, + ]); + return; + } + + $data = $absence->toOutOufOfficeData( + $user, + $this->timezoneService->getUserTimezone($userId) ?? $this->timezoneService->getDefaultTimezone(), + ); + if ($event === self::EVENT_START) { + $this->eventDispatcher->dispatchTyped(new OutOfOfficeStartedEvent($data)); + } elseif ($event === self::EVENT_END) { + $this->eventDispatcher->dispatchTyped(new OutOfOfficeEndedEvent($data)); + } else { + $this->logger->error("Invalid out-of-office event: $event", [ + 'argument' => $argument, + ]); + } + } +} diff --git a/apps/dav/lib/CalDAV/TimezoneService.php b/apps/dav/lib/CalDAV/TimezoneService.php new file mode 100644 index 00000000000..bdbd0b9fe2c --- /dev/null +++ b/apps/dav/lib/CalDAV/TimezoneService.php @@ -0,0 +1,98 @@ +<?php + +declare(strict_types=1); + +/* + * @copyright 2023 Christoph Wurst <christoph@winzerhof-wurst.at> + * + * @author 2023 Christoph Wurst <christoph@winzerhof-wurst.at> + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + */ + +namespace OCA\DAV\CalDAV; + +use OCA\DAV\Db\PropertyMapper; +use OCP\Calendar\ICalendar; +use OCP\Calendar\IManager; +use OCP\IConfig; +use Sabre\VObject\Component\VCalendar; +use Sabre\VObject\Component\VTimeZone; +use Sabre\VObject\Reader; +use function array_reduce; + +class TimezoneService { + + public function __construct(private IConfig $config, + private PropertyMapper $propertyMapper, + private IManager $calendarManager) { + } + + public function getUserTimezone(string $userId): ?string { + $availabilityPropPath = 'calendars/' . $userId . '/inbox'; + $availabilityProp = '{' . Plugin::NS_CALDAV . '}calendar-availability'; + $availabilities = $this->propertyMapper->findPropertyByPathAndName($userId, $availabilityPropPath, $availabilityProp); + if (!empty($availabilities)) { + $availability = $availabilities[0]->getPropertyvalue(); + /** @var VCalendar $vCalendar */ + $vCalendar = Reader::read($availability); + /** @var VTimeZone $vTimezone */ + $vTimezone = $vCalendar->VTIMEZONE; + // Sabre has a fallback to date_default_timezone_get + return $vTimezone->getTimeZone()->getName(); + } + + $principal = 'principals/users/' . $userId; + $uri = $this->config->getUserValue($userId, 'dav', 'defaultCalendar', CalDavBackend::PERSONAL_CALENDAR_URI); + $calendars = $this->calendarManager->getCalendarsForPrincipal($principal); + + /** @var ?VTimeZone $personalCalendarTimezone */ + $personalCalendarTimezone = array_reduce($calendars, function (?VTimeZone $acc, ICalendar $calendar) use ($uri) { + if ($acc !== null) { + return $acc; + } + if ($calendar->getUri() === $uri && !$calendar->isDeleted() && $calendar instanceof CalendarImpl) { + return $calendar->getSchedulingTimezone(); + } + return null; + }); + if ($personalCalendarTimezone !== null) { + return $personalCalendarTimezone->getTimeZone()->getName(); + } + + // No timezone in the personalCalendarTimezone calendar or no personalCalendarTimezone calendar + // Loop through all calendars until we find a timezone. + /** @var ?VTimeZone $firstTimezone */ + $firstTimezone = array_reduce($calendars, function (?VTimeZone $acc, ICalendar $calendar) { + if ($acc !== null) { + return $acc; + } + if (!$calendar->isDeleted() && $calendar instanceof CalendarImpl) { + return $calendar->getSchedulingTimezone(); + } + return null; + }); + if ($firstTimezone !== null) { + return $firstTimezone->getTimeZone()->getName(); + } + return null; + } + + public function getDefaultTimezone(): string { + return $this->config->getSystemValueString('default_timezone', 'UTC'); + } + +} diff --git a/apps/dav/lib/Controller/AvailabilitySettingsController.php b/apps/dav/lib/Controller/AvailabilitySettingsController.php index 3ff89fe87eb..3e10162dd84 100644 --- a/apps/dav/lib/Controller/AvailabilitySettingsController.php +++ b/apps/dav/lib/Controller/AvailabilitySettingsController.php @@ -35,11 +35,12 @@ use OCP\AppFramework\Http\Attribute\NoAdminRequired; use OCP\AppFramework\Http\JSONResponse; use OCP\AppFramework\Http\Response; use OCP\IRequest; +use OCP\IUserSession; class AvailabilitySettingsController extends Controller { public function __construct( IRequest $request, - private ?string $userId, + private ?IUserSession $userSession, private AbsenceService $absenceService, ) { parent::__construct(Application::APP_ID, $request); @@ -56,8 +57,8 @@ class AvailabilitySettingsController extends Controller { string $status, string $message, ): Response { - $userId = $this->userId; - if ($userId === null) { + $user = $this->userSession?->getUser(); + if ($user === null) { return new JSONResponse([], Http::STATUS_FORBIDDEN); } @@ -68,7 +69,7 @@ class AvailabilitySettingsController extends Controller { } $absence = $this->absenceService->createOrUpdateAbsence( - $userId, + $user, $firstDay, $lastDay, $status, @@ -82,12 +83,12 @@ class AvailabilitySettingsController extends Controller { */ #[NoAdminRequired] public function clearAbsence(): Response { - $userId = $this->userId; - if ($userId === null) { + $user = $this->userSession?->getUser(); + if ($user === null) { return new JSONResponse([], Http::STATUS_FORBIDDEN); } - $this->absenceService->clearAbsence($userId); + $this->absenceService->clearAbsence($user); return new JSONResponse([]); } diff --git a/apps/dav/lib/Db/Absence.php b/apps/dav/lib/Db/Absence.php index 8de8ecc9aa0..3cd8037d57e 100644 --- a/apps/dav/lib/Db/Absence.php +++ b/apps/dav/lib/Db/Absence.php @@ -26,7 +26,8 @@ declare(strict_types=1); namespace OCA\DAV\Db; -use DateTimeImmutable; +use DateTime; +use DateTimeZone; use Exception; use InvalidArgumentException; use JsonSerializable; @@ -67,7 +68,7 @@ class Absence extends Entity implements JsonSerializable { $this->addType('message', 'string'); } - public function toOutOufOfficeData(IUser $user): IOutOfOfficeData { + public function toOutOufOfficeData(IUser $user, string $timezone): IOutOfOfficeData { if ($user->getUID() !== $this->getUserId()) { throw new InvalidArgumentException("The user doesn't match the user id of this absence! Expected " . $this->getUserId() . ", got " . $user->getUID()); } @@ -75,8 +76,10 @@ class Absence extends Entity implements JsonSerializable { throw new Exception('Creating out-of-office data without ID'); } - $startDate = new DateTimeImmutable($this->getFirstDay()); - $endDate = new DateTimeImmutable($this->getLastDay()); + $tz = new DateTimeZone($timezone); + $startDate = new DateTime($this->getFirstDay(), $tz); + $endDate = new DateTime($this->getLastDay(), $tz); + $endDate->setTime(23, 59); return new OutOfOfficeData( (string)$this->getId(), $user, diff --git a/apps/dav/lib/Db/AbsenceMapper.php b/apps/dav/lib/Db/AbsenceMapper.php index 6e1133f779c..7529d04cf10 100644 --- a/apps/dav/lib/Db/AbsenceMapper.php +++ b/apps/dav/lib/Db/AbsenceMapper.php @@ -44,6 +44,31 @@ class AbsenceMapper extends QBMapper { * @throws DoesNotExistException * @throws \OCP\DB\Exception */ + public function findById(int $id): Absence { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->getTableName()) + ->where($qb->expr()->eq( + 'id', + $qb->createNamedParameter($id, IQueryBuilder::PARAM_INT), + IQueryBuilder::PARAM_INT), + ); + try { + return $this->findEntity($qb); + } catch (MultipleObjectsReturnedException $e) { + // Won't happen as id is the primary key + throw new \RuntimeException( + 'The impossible has happened! The query returned multiple absence settings for one user.', + 0, + $e, + ); + } + } + + /** + * @throws DoesNotExistException + * @throws \OCP\DB\Exception + */ public function findByUserId(string $userId): Absence { $qb = $this->db->getQueryBuilder(); $qb->select('*') diff --git a/apps/dav/lib/Db/Property.php b/apps/dav/lib/Db/Property.php new file mode 100644 index 00000000000..5234ad852b1 --- /dev/null +++ b/apps/dav/lib/Db/Property.php @@ -0,0 +1,53 @@ +<?php + +declare(strict_types=1); + +/* + * @copyright 2023 Christoph Wurst <christoph@winzerhof-wurst.at> + * + * @author 2023 Christoph Wurst <christoph@winzerhof-wurst.at> + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + */ + +namespace OCA\DAV\Db; + +use OCP\AppFramework\Db\Entity; + +/** + * @method string getUserid() + * @method string getPropertypath() + * @method string getPropertyname() + * @method string getPropertyvalue() + */ +class Property extends Entity { + + /** @var string|null */ + protected $userid; + + /** @var string|null */ + protected $propertypath; + + /** @var string|null */ + protected $propertyname; + + /** @var string|null */ + protected $propertyvalue; + + /** @var int|null */ + protected $valuetype; + +} diff --git a/apps/dav/lib/Db/PropertyMapper.php b/apps/dav/lib/Db/PropertyMapper.php new file mode 100644 index 00000000000..6f39d03d1c1 --- /dev/null +++ b/apps/dav/lib/Db/PropertyMapper.php @@ -0,0 +1,57 @@ +<?php + +declare(strict_types=1); + +/* + * @copyright 2023 Christoph Wurst <christoph@winzerhof-wurst.at> + * + * @author 2023 Christoph Wurst <christoph@winzerhof-wurst.at> + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + */ + +namespace OCA\DAV\Db; + +use OCP\AppFramework\Db\QBMapper; +use OCP\IDBConnection; + +/** + * @template-extends QBMapper<Property> + */ +class PropertyMapper extends QBMapper { + + private const TABLE_NAME = 'properties'; + + public function __construct(IDBConnection $db) { + parent::__construct($db, self::TABLE_NAME, Property::class); + } + + /** + * @return Property[] + */ + public function findPropertyByPathAndName(string $userId, string $path, string $name): array { + $selectQb = $this->db->getQueryBuilder(); + $selectQb->select('*') + ->from(self::TABLE_NAME) + ->where( + $selectQb->expr()->eq('userid', $selectQb->createNamedParameter($userId)), + $selectQb->expr()->eq('propertypath', $selectQb->createNamedParameter($path)), + $selectQb->expr()->eq('propertyname', $selectQb->createNamedParameter($name)), + ); + return $this->findEntities($selectQb); + } + +} diff --git a/apps/dav/lib/Service/AbsenceService.php b/apps/dav/lib/Service/AbsenceService.php index b50dd32e925..3f5168e386d 100644 --- a/apps/dav/lib/Service/AbsenceService.php +++ b/apps/dav/lib/Service/AbsenceService.php @@ -27,11 +27,14 @@ declare(strict_types=1); namespace OCA\DAV\Service; use InvalidArgumentException; +use OCA\DAV\BackgroundJob\OutOfOfficeEventDispatcherJob; +use OCA\DAV\CalDAV\TimezoneService; use OCA\DAV\Db\Absence; use OCA\DAV\Db\AbsenceMapper; use OCP\AppFramework\Db\DoesNotExistException; +use OCP\BackgroundJob\IJobList; use OCP\EventDispatcher\IEventDispatcher; -use OCP\IUserManager; +use OCP\IUser; use OCP\User\Events\OutOfOfficeChangedEvent; use OCP\User\Events\OutOfOfficeClearedEvent; use OCP\User\Events\OutOfOfficeScheduledEvent; @@ -40,7 +43,8 @@ class AbsenceService { public function __construct( private AbsenceMapper $absenceMapper, private IEventDispatcher $eventDispatcher, - private IUserManager $userManager, + private IJobList $jobList, + private TimezoneService $timezoneService, ) { } @@ -52,61 +56,76 @@ class AbsenceService { * @throws InvalidArgumentException If no user with the given user id exists. */ public function createOrUpdateAbsence( - string $userId, + IUser $user, string $firstDay, string $lastDay, string $status, string $message, ): Absence { try { - $absence = $this->absenceMapper->findByUserId($userId); + $absence = $this->absenceMapper->findByUserId($user->getUID()); } catch (DoesNotExistException) { $absence = new Absence(); } - $absence->setUserId($userId); + $absence->setUserId($user->getUID()); $absence->setFirstDay($firstDay); $absence->setLastDay($lastDay); $absence->setStatus($status); $absence->setMessage($message); - // TODO: this method should probably just take a IUser instance - $user = $this->userManager->get($userId); - if ($user === null) { - throw new InvalidArgumentException("User $userId does not exist"); - } - if ($absence->getId() === null) { - $persistedAbsence = $this->absenceMapper->insert($absence); - $this->eventDispatcher->dispatchTyped(new OutOfOfficeScheduledEvent( - $persistedAbsence->toOutOufOfficeData($user) - )); - return $persistedAbsence; + $absence = $this->absenceMapper->insert($absence); + $eventData = $absence->toOutOufOfficeData( + $user, + $this->timezoneService->getUserTimezone($user->getUID()) ?? $this->timezoneService->getDefaultTimezone(), + ); + $this->eventDispatcher->dispatchTyped(new OutOfOfficeScheduledEvent($eventData)); + } else { + $absence = $this->absenceMapper->update($absence); + $eventData = $absence->toOutOufOfficeData( + $user, + $this->timezoneService->getUserTimezone($user->getUID()) ?? $this->timezoneService->getDefaultTimezone(), + ); + $this->eventDispatcher->dispatchTyped(new OutOfOfficeChangedEvent($eventData)); } - $this->eventDispatcher->dispatchTyped(new OutOfOfficeChangedEvent( - $absence->toOutOufOfficeData($user) - )); - return $this->absenceMapper->update($absence); + $this->jobList->scheduleAfter( + OutOfOfficeEventDispatcherJob::class, + $eventData->getStartDate(), + [ + 'id' => $absence->getId(), + 'event' => OutOfOfficeEventDispatcherJob::EVENT_START, + ], + ); + $this->jobList->scheduleAfter( + OutOfOfficeEventDispatcherJob::class, + $eventData->getEndDate(), + [ + 'id' => $absence->getId(), + 'event' => OutOfOfficeEventDispatcherJob::EVENT_END, + ], + ); + + return $absence; } /** * @throws \OCP\DB\Exception */ - public function clearAbsence(string $userId): void { + public function clearAbsence(IUser $user): void { try { - $absence = $this->absenceMapper->findByUserId($userId); + $absence = $this->absenceMapper->findByUserId($user->getUID()); } catch (DoesNotExistException $e) { // Nothing to clear return; } $this->absenceMapper->delete($absence); - // TODO: this method should probably just take a IUser instance - $user = $this->userManager->get($userId); - if ($user === null) { - throw new InvalidArgumentException("User $userId does not exist"); - } - $eventData = $absence->toOutOufOfficeData($user); + $this->jobList->remove(OutOfOfficeEventDispatcherJob::class); + $eventData = $absence->toOutOufOfficeData( + $user, + $this->timezoneService->getUserTimezone($user->getUID()) ?? $this->timezoneService->getDefaultTimezone(), + ); $this->eventDispatcher->dispatchTyped(new OutOfOfficeClearedEvent($eventData)); } } diff --git a/apps/dav/tests/integration/Db/PropertyMapperTest.php b/apps/dav/tests/integration/Db/PropertyMapperTest.php new file mode 100644 index 00000000000..e14b2265440 --- /dev/null +++ b/apps/dav/tests/integration/Db/PropertyMapperTest.php @@ -0,0 +1,55 @@ +<?php + +declare(strict_types=1); + +/* + * @copyright 2023 Christoph Wurst <christoph@winzerhof-wurst.at> + * + * @author 2023 Christoph Wurst <christoph@winzerhof-wurst.at> + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + */ + +namespace OCA\DAV\Tests\integration\Db; + +use OCA\DAV\Db\PropertyMapper; +use Test\TestCase; + +/** + * @group DB + */ +class PropertyMapperTest extends TestCase { + + /** @var PropertyMapper */ + private PropertyMapper $mapper; + + protected function setUp(): void { + parent::setUp(); + + $this->mapper = \OC::$server->get(PropertyMapper::class); + } + + public function testFindNonExistent(): void { + $props = $this->mapper->findPropertyByPathAndName( + 'userthatdoesnotexist', + 'path/that/does/not/exist/either', + 'nope', + ); + + self::assertEmpty($props); + } + +} diff --git a/apps/dav/tests/unit/BackgroundJob/OutOfOfficeEventDispatcherJobTest.php b/apps/dav/tests/unit/BackgroundJob/OutOfOfficeEventDispatcherJobTest.php new file mode 100644 index 00000000000..ee2b69168bf --- /dev/null +++ b/apps/dav/tests/unit/BackgroundJob/OutOfOfficeEventDispatcherJobTest.php @@ -0,0 +1,175 @@ +<?php + +declare(strict_types=1); + +/** + * @copyright Copyright (c) 2023 Richard Steinmetz <richard@steinmetz.cloud> + * + * @author Richard Steinmetz <richard@steinmetz.cloud> + * + * @license AGPL-3.0-or-later + * + * 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 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + * + */ + +namespace OCA\DAV\Tests\unit\BackgroundJob; + +use OCA\DAV\BackgroundJob\OutOfOfficeEventDispatcherJob; +use OCA\DAV\CalDAV\TimezoneService; +use OCA\DAV\Db\Absence; +use OCA\DAV\Db\AbsenceMapper; +use OCP\AppFramework\Utility\ITimeFactory; +use OCP\EventDispatcher\IEventDispatcher; +use OCP\IUser; +use OCP\IUserManager; +use OCP\User\Events\OutOfOfficeEndedEvent; +use OCP\User\Events\OutOfOfficeStartedEvent; +use PHPUnit\Framework\MockObject\MockObject; +use Psr\Log\LoggerInterface; +use Test\TestCase; + +class OutOfOfficeEventDispatcherJobTest extends TestCase { + private OutOfOfficeEventDispatcherJob $job; + + /** @var MockObject|ITimeFactory */ + private $timeFactory; + + /** @var MockObject|AbsenceMapper */ + private $absenceMapper; + + /** @var MockObject|LoggerInterface */ + private $logger; + + /** @var MockObject|IEventDispatcher */ + private $eventDispatcher; + + /** @var MockObject|IUserManager */ + private $userManager; + private MockObject|TimezoneService $timezoneService; + + protected function setUp(): void { + parent::setUp(); + + $this->timeFactory = $this->createMock(ITimeFactory::class); + $this->absenceMapper = $this->createMock(AbsenceMapper::class); + $this->logger = $this->createMock(LoggerInterface::class); + $this->eventDispatcher = $this->createMock(IEventDispatcher::class); + $this->userManager = $this->createMock(IUserManager::class); + $this->timezoneService = $this->createMock(TimezoneService::class); + + $this->job = new OutOfOfficeEventDispatcherJob( + $this->timeFactory, + $this->absenceMapper, + $this->logger, + $this->eventDispatcher, + $this->userManager, + $this->timezoneService, + ); + } + + public function testDispatchStartEvent() { + $this->timezoneService->method('getUserTimezone')->with('user')->willReturn('Europe/Berlin'); + + $absence = new Absence(); + $absence->setId(200); + $absence->setUserId('user'); + + $user = $this->createMock(IUser::class); + $user->method('getUID') + ->willReturn('user'); + + $this->absenceMapper->expects(self::once()) + ->method('findById') + ->with(1) + ->willReturn($absence); + $this->userManager->expects(self::once()) + ->method('get') + ->with('user') + ->willReturn($user); + $this->eventDispatcher->expects(self::once()) + ->method('dispatchTyped') + ->with(self::callback(static function ($event): bool { + self::assertInstanceOf(OutOfOfficeStartedEvent::class, $event); + return true; + })); + + $this->job->run([ + 'id' => 1, + 'event' => OutOfOfficeEventDispatcherJob::EVENT_START, + ]); + } + + public function testDispatchStopEvent() { + $this->timezoneService->method('getUserTimezone')->with('user')->willReturn('Europe/Berlin'); + + $absence = new Absence(); + $absence->setId(200); + $absence->setUserId('user'); + + $user = $this->createMock(IUser::class); + $user->method('getUID') + ->willReturn('user'); + + $this->absenceMapper->expects(self::once()) + ->method('findById') + ->with(1) + ->willReturn($absence); + $this->userManager->expects(self::once()) + ->method('get') + ->with('user') + ->willReturn($user); + $this->eventDispatcher->expects(self::once()) + ->method('dispatchTyped') + ->with(self::callback(static function ($event): bool { + self::assertInstanceOf(OutOfOfficeEndedEvent::class, $event); + return true; + })); + + $this->job->run([ + 'id' => 1, + 'event' => OutOfOfficeEventDispatcherJob::EVENT_END, + ]); + } + + public function testDoesntDispatchUnknownEvent() { + $this->timezoneService->method('getUserTimezone')->with('user')->willReturn('Europe/Berlin'); + + $absence = new Absence(); + $absence->setId(100); + $absence->setUserId('user'); + + $user = $this->createMock(IUser::class); + $user->method('getUID') + ->willReturn('user'); + + $this->absenceMapper->expects(self::once()) + ->method('findById') + ->with(1) + ->willReturn($absence); + $this->userManager->expects(self::once()) + ->method('get') + ->with('user') + ->willReturn($user); + $this->eventDispatcher->expects(self::never()) + ->method('dispatchTyped'); + $this->logger->expects(self::once()) + ->method('error'); + + $this->job->run([ + 'id' => 1, + 'event' => 'foobar', + ]); + } +} diff --git a/apps/dav/tests/unit/CalDAV/TimezoneServiceTest.php b/apps/dav/tests/unit/CalDAV/TimezoneServiceTest.php new file mode 100644 index 00000000000..3646461dc42 --- /dev/null +++ b/apps/dav/tests/unit/CalDAV/TimezoneServiceTest.php @@ -0,0 +1,161 @@ +<?php +/* + * @copyright 2023 Christoph Wurst <christoph@winzerhof-wurst.at> + * + * @author 2023 Christoph Wurst <christoph@winzerhof-wurst.at> + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + */ + +declare(strict_types=1); + +/* + * @copyright 2023 Christoph Wurst <christoph@winzerhof-wurst.at> + * + * @author 2023 Christoph Wurst <christoph@winzerhof-wurst.at> + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + */ + +namespace OCA\DAV\Tests\unit\CalDAV; + +use DateTimeZone; +use OCA\DAV\CalDAV\CalendarImpl; +use OCA\DAV\CalDAV\TimezoneService; +use OCA\DAV\Db\Property; +use OCA\DAV\Db\PropertyMapper; +use OCP\Calendar\ICalendar; +use OCP\Calendar\IManager; +use OCP\IConfig; +use PHPUnit\Framework\MockObject\MockObject; +use Sabre\VObject\Component\VTimeZone; +use Test\TestCase; + +class TimezoneServiceTest extends TestCase { + + private IConfig|MockObject $config; + private PropertyMapper|MockObject $propertyMapper; + private IManager|MockObject $calendarManager; + private TimezoneService $service; + + protected function setUp(): void { + parent::setUp(); + + $this->config = $this->createMock(IConfig::class); + $this->propertyMapper = $this->createMock(PropertyMapper::class); + $this->calendarManager = $this->createMock(IManager::class); + + $this->service = new TimezoneService( + $this->config, + $this->propertyMapper, + $this->calendarManager, + ); + } + + public function testGetUserTimezoneFromAvailability(): void { + $property = new Property(); + $property->setPropertyvalue('BEGIN:VCALENDAR +PRODID:Nextcloud DAV app +BEGIN:VTIMEZONE +TZID:Europe/Vienna +END:VTIMEZONE +END:VCALENDAR'); + $this->propertyMapper->expects(self::once()) + ->method('findPropertyByPathAndName') + ->willReturn([ + $property, + ]); + + $timezone = $this->service->getUserTimezone('test123'); + + self::assertNotNull($timezone); + self::assertEquals('Europe/Vienna', $timezone); + } + + public function testGetUserTimezoneFromPersonalCalendar(): void { + $this->config->expects(self::once()) + ->method('getUserValue') + ->with('test123', 'dav', 'defaultCalendar') + ->willReturn('personal-1'); + $other = $this->createMock(ICalendar::class); + $other->method('getUri')->willReturn('other'); + $personal = $this->createMock(CalendarImpl::class); + $personal->method('getUri')->willReturn('personal-1'); + $tz = new DateTimeZone('Europe/Berlin'); + $vtz = $this->createMock(VTimeZone::class); + $vtz->method('getTimeZone')->willReturn($tz); + $personal->method('getSchedulingTimezone')->willReturn($vtz); + $this->calendarManager->expects(self::once()) + ->method('getCalendarsForPrincipal') + ->with('principals/users/test123') + ->willReturn([ + $other, + $personal, + ]); + + $timezone = $this->service->getUserTimezone('test123'); + + self::assertNotNull($timezone); + self::assertEquals('Europe/Berlin', $timezone); + } + + public function testGetUserTimezoneFromAny(): void { + $this->config->expects(self::once()) + ->method('getUserValue') + ->with('test123', 'dav', 'defaultCalendar') + ->willReturn('personal-1'); + $other = $this->createMock(ICalendar::class); + $other->method('getUri')->willReturn('other'); + $personal = $this->createMock(CalendarImpl::class); + $personal->method('getUri')->willReturn('personal-2'); + $tz = new DateTimeZone('Europe/Prague'); + $vtz = $this->createMock(VTimeZone::class); + $vtz->method('getTimeZone')->willReturn($tz); + $personal->method('getSchedulingTimezone')->willReturn($vtz); + $this->calendarManager->expects(self::once()) + ->method('getCalendarsForPrincipal') + ->with('principals/users/test123') + ->willReturn([ + $other, + $personal, + ]); + + $timezone = $this->service->getUserTimezone('test123'); + + self::assertNotNull($timezone); + self::assertEquals('Europe/Prague', $timezone); + } + + public function testGetUserTimezoneNoneFound(): void { + $timezone = $this->service->getUserTimezone('test123'); + + self::assertNull($timezone); + } + +} |