Merge pull request #32576 from nextcloud/feature/spreed-7321/automatic-DND-outside-of-availability

🤖 Automatically set user status to  DND outside of  📅 availability
This commit is contained in:
Joas Schilling 2022-07-01 15:29:03 +02:00 committed by GitHub
commit 6222b702db
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 390 additions and 4 deletions

View file

@ -22,6 +22,7 @@ return array(
'OCA\\DAV\\BackgroundJob\\RegisterRegenerateBirthdayCalendars' => $baseDir . '/../lib/BackgroundJob/RegisterRegenerateBirthdayCalendars.php',
'OCA\\DAV\\BackgroundJob\\UpdateCalendarResourcesRoomsBackgroundJob' => $baseDir . '/../lib/BackgroundJob/UpdateCalendarResourcesRoomsBackgroundJob.php',
'OCA\\DAV\\BackgroundJob\\UploadCleanup' => $baseDir . '/../lib/BackgroundJob/UploadCleanup.php',
'OCA\\DAV\\BackgroundJob\\UserStatusAutomation' => $baseDir . '/../lib/BackgroundJob/UserStatusAutomation.php',
'OCA\\DAV\\BulkUpload\\BulkUploadPlugin' => $baseDir . '/../lib/BulkUpload/BulkUploadPlugin.php',
'OCA\\DAV\\BulkUpload\\MultipartRequestParser' => $baseDir . '/../lib/BulkUpload/MultipartRequestParser.php',
'OCA\\DAV\\CalDAV\\Activity\\Backend' => $baseDir . '/../lib/CalDAV/Activity/Backend.php',
@ -247,6 +248,7 @@ return array(
'OCA\\DAV\\Listener\\ClearPhotoCacheListener' => $baseDir . '/../lib/Listener/ClearPhotoCacheListener.php',
'OCA\\DAV\\Listener\\SubscriptionListener' => $baseDir . '/../lib/Listener/SubscriptionListener.php',
'OCA\\DAV\\Listener\\TrustedServerRemovedListener' => $baseDir . '/../lib/Listener/TrustedServerRemovedListener.php',
'OCA\\DAV\\Listener\\UserPreferenceListener' => $baseDir . '/../lib/Listener/UserPreferenceListener.php',
'OCA\\DAV\\Migration\\BuildCalendarSearchIndex' => $baseDir . '/../lib/Migration/BuildCalendarSearchIndex.php',
'OCA\\DAV\\Migration\\BuildCalendarSearchIndexBackgroundJob' => $baseDir . '/../lib/Migration/BuildCalendarSearchIndexBackgroundJob.php',
'OCA\\DAV\\Migration\\BuildSocialSearchIndex' => $baseDir . '/../lib/Migration/BuildSocialSearchIndex.php',

View file

@ -37,6 +37,7 @@ class ComposerStaticInitDAV
'OCA\\DAV\\BackgroundJob\\RegisterRegenerateBirthdayCalendars' => __DIR__ . '/..' . '/../lib/BackgroundJob/RegisterRegenerateBirthdayCalendars.php',
'OCA\\DAV\\BackgroundJob\\UpdateCalendarResourcesRoomsBackgroundJob' => __DIR__ . '/..' . '/../lib/BackgroundJob/UpdateCalendarResourcesRoomsBackgroundJob.php',
'OCA\\DAV\\BackgroundJob\\UploadCleanup' => __DIR__ . '/..' . '/../lib/BackgroundJob/UploadCleanup.php',
'OCA\\DAV\\BackgroundJob\\UserStatusAutomation' => __DIR__ . '/..' . '/../lib/BackgroundJob/UserStatusAutomation.php',
'OCA\\DAV\\BulkUpload\\BulkUploadPlugin' => __DIR__ . '/..' . '/../lib/BulkUpload/BulkUploadPlugin.php',
'OCA\\DAV\\BulkUpload\\MultipartRequestParser' => __DIR__ . '/..' . '/../lib/BulkUpload/MultipartRequestParser.php',
'OCA\\DAV\\CalDAV\\Activity\\Backend' => __DIR__ . '/..' . '/../lib/CalDAV/Activity/Backend.php',
@ -262,6 +263,7 @@ class ComposerStaticInitDAV
'OCA\\DAV\\Listener\\ClearPhotoCacheListener' => __DIR__ . '/..' . '/../lib/Listener/ClearPhotoCacheListener.php',
'OCA\\DAV\\Listener\\SubscriptionListener' => __DIR__ . '/..' . '/../lib/Listener/SubscriptionListener.php',
'OCA\\DAV\\Listener\\TrustedServerRemovedListener' => __DIR__ . '/..' . '/../lib/Listener/TrustedServerRemovedListener.php',
'OCA\\DAV\\Listener\\UserPreferenceListener' => __DIR__ . '/..' . '/../lib/Listener/UserPreferenceListener.php',
'OCA\\DAV\\Migration\\BuildCalendarSearchIndex' => __DIR__ . '/..' . '/../lib/Migration/BuildCalendarSearchIndex.php',
'OCA\\DAV\\Migration\\BuildCalendarSearchIndexBackgroundJob' => __DIR__ . '/..' . '/../lib/Migration/BuildCalendarSearchIndexBackgroundJob.php',
'OCA\\DAV\\Migration\\BuildSocialSearchIndex' => __DIR__ . '/..' . '/../lib/Migration/BuildSocialSearchIndex.php',

View file

@ -85,6 +85,7 @@ use OCA\DAV\Listener\CardListener;
use OCA\DAV\Listener\ClearPhotoCacheListener;
use OCA\DAV\Listener\SubscriptionListener;
use OCA\DAV\Listener\TrustedServerRemovedListener;
use OCA\DAV\Listener\UserPreferenceListener;
use OCA\DAV\Search\ContactsSearchProvider;
use OCA\DAV\Search\EventsSearchProvider;
use OCA\DAV\Search\TasksSearchProvider;
@ -96,6 +97,8 @@ use OCP\AppFramework\Bootstrap\IBootstrap;
use OCP\AppFramework\Bootstrap\IRegistrationContext;
use OCP\AppFramework\IAppContainer;
use OCP\Calendar\IManager as ICalendarManager;
use OCP\Config\BeforePreferenceDeletedEvent;
use OCP\Config\BeforePreferenceSetEvent;
use OCP\Contacts\IManager as IContactsManager;
use OCP\IServerContainer;
use OCP\IUser;
@ -186,6 +189,9 @@ class Application extends App implements IBootstrap {
$context->registerEventListener(CardUpdatedEvent::class, ClearPhotoCacheListener::class);
$context->registerEventListener(TrustedServerRemovedEvent::class, TrustedServerRemovedListener::class);
$context->registerEventListener(BeforePreferenceDeletedEvent::class, UserPreferenceListener::class);
$context->registerEventListener(BeforePreferenceSetEvent::class, UserPreferenceListener::class);
$context->registerNotifierService(Notifier::class);
$context->registerCalendarProvider(CalendarProvider::class);

View file

@ -0,0 +1,188 @@
<?php
declare(strict_types=1);
/**
* @copyright Copyright (c) 2022 Joas Schilling <coding@schilljs.com>
*
* @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\BackgroundJob;
use OCA\DAV\CalDAV\Schedule\Plugin;
use OCP\AppFramework\Utility\ITimeFactory;
use OCP\BackgroundJob\IJobList;
use OCP\BackgroundJob\TimedJob;
use OCP\DB\QueryBuilder\IQueryBuilder;
use OCP\IConfig;
use OCP\IDBConnection;
use OCP\UserStatus\IManager;
use OCP\UserStatus\IUserStatus;
use Psr\Log\LoggerInterface;
use Sabre\VObject\Component\Available;
use Sabre\VObject\Component\VAvailability;
use Sabre\VObject\Reader;
use Sabre\VObject\Recur\RRuleIterator;
class UserStatusAutomation extends TimedJob {
protected IDBConnection $connection;
protected IJobList $jobList;
protected LoggerInterface $logger;
protected IManager $manager;
protected IConfig $config;
public function __construct(ITimeFactory $timeFactory,
IDBConnection $connection,
IJobList $jobList,
LoggerInterface $logger,
IManager $manager,
IConfig $config) {
parent::__construct($timeFactory);
$this->connection = $connection;
$this->jobList = $jobList;
$this->logger = $logger;
$this->manager = $manager;
$this->config = $config;
// Interval 0 might look weird, but the last_checked is always moved
// to the next time we need this and then it's 0 seconds ago.
$this->setInterval(0);
}
/**
* @inheritDoc
*/
protected function run($argument) {
if (!isset($argument['userId'])) {
$this->jobList->remove(self::class, $argument);
$this->logger->info('Removing invalid ' . self::class . ' background job');
return;
}
$userId = $argument['userId'];
$automationEnabled = $this->config->getUserValue($userId, 'dav', 'user_status_automation', 'no') === 'yes';
if (!$automationEnabled) {
$this->logger->info('Removing ' . self::class . ' background job for user "' . $userId . '" because the setting is disabled');
$this->jobList->remove(self::class, $argument);
return;
}
$property = $this->getAvailabilityFromPropertiesTable($userId);
if (!$property) {
$this->logger->info('Removing ' . self::class . ' background job for user "' . $userId . '" because the user has no availability settings');
$this->jobList->remove(self::class, $argument);
return;
}
$isCurrentlyAvailable = false;
$nextPotentialToggles = [];
$now = new \DateTime('now');
$lastMidnight = (clone $now)->setTime(0, 0);
$vObject = Reader::read($property);
foreach ($vObject->getComponents() as $component) {
if ($component->name !== 'VAVAILABILITY') {
continue;
}
/** @var VAvailability $component */
$availables = $component->getComponents();
foreach ($availables as $available) {
/** @var Available $available */
if ($available->name === 'AVAILABLE') {
/** @var \DateTimeInterface $effectiveStart */
/** @var \DateTimeInterface $effectiveEnd */
[$effectiveStart, $effectiveEnd] = $available->getEffectiveStartEnd();
try {
$it = new RRuleIterator((string) $available->RRULE, $effectiveStart);
$it->fastForward($lastMidnight);
$startToday = $it->current();
if ($startToday && $startToday <= $now) {
$duration = $effectiveStart->diff($effectiveEnd);
$endToday = $startToday->add($duration);
if ($endToday > $now) {
// User is currently available
// Also queuing the end time as next status toggle
$isCurrentlyAvailable = true;
$nextPotentialToggles[] = $endToday->getTimestamp();
}
// Availability enabling already done for today,
// so jump to the next recurrence to find the next status toggle
$it->next();
}
if ($it->current()) {
$nextPotentialToggles[] = $it->current()->getTimestamp();
}
} catch (\Exception $e) {
$this->logger->error($e->getMessage(), ['exception' => $e]);
}
}
}
}
$nextAutomaticToggle = min($nextPotentialToggles);
$this->setLastRunToNextToggleTime($userId, $nextAutomaticToggle - 1);
if ($isCurrentlyAvailable) {
$this->manager->revertUserStatus($userId, IUserStatus::MESSAGE_AVAILABILITY, IUserStatus::DND);
} else {
// The DND status automation is more important than the "Away - In call" so we also restore that one if it exists.
$this->manager->revertUserStatus($userId, IUserStatus::MESSAGE_CALL, IUserStatus::AWAY);
$this->manager->setUserStatus($userId, IUserStatus::MESSAGE_AVAILABILITY, IUserStatus::DND, true);
}
$this->logger->debug('User status automation ran');
}
protected function setLastRunToNextToggleTime(string $userId, int $timestamp): void {
$query = $this->connection->getQueryBuilder();
$query->update('jobs')
->set('last_run', $query->createNamedParameter($timestamp, IQueryBuilder::PARAM_INT))
->where($query->expr()->eq('id', $query->createNamedParameter($this->getId(), IQueryBuilder::PARAM_INT)));
$query->executeStatement();
$this->logger->debug('Updated user status automation last_run to ' . $timestamp . ' for user ' . $userId);
}
/**
* @param string $userId
* @return false|string
*/
protected function getAvailabilityFromPropertiesTable(string $userId) {
$propertyPath = 'calendars/' . $userId . '/inbox';
$propertyName = '{' . Plugin::NS_CALDAV . '}calendar-availability';
$query = $this->connection->getQueryBuilder();
$query->select('propertyvalue')
->from('properties')
->where($query->expr()->eq('userid', $query->createNamedParameter($userId)))
->andWhere($query->expr()->eq('propertypath', $query->createNamedParameter($propertyPath)))
->where($query->expr()->eq('propertyname', $query->createNamedParameter($propertyName)))
->setMaxResults(1);
$result = $query->executeQuery();
$property = $result->fetchOne();
$result->closeCursor();
return $property;
}
}

View file

@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
/**
* @copyright Copyright (c) 2022 Joas Schilling <coding@schilljs.com>
*
* @author Joas Schilling <coding@schilljs.com>
*
* @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\Listener;
use OCA\DAV\BackgroundJob\UserStatusAutomation;
use OCP\BackgroundJob\IJobList;
use OCP\Config\BeforePreferenceDeletedEvent;
use OCP\Config\BeforePreferenceSetEvent;
use OCP\EventDispatcher\Event;
use OCP\EventDispatcher\IEventListener;
class UserPreferenceListener implements IEventListener {
protected IJobList $jobList;
public function __construct(IJobList $jobList) {
$this->jobList = $jobList;
}
public function handle(Event $event): void {
if ($event instanceof BeforePreferenceSetEvent) {
if ($event->getAppId() === 'dav' && $event->getConfigKey() === 'user_status_automation' && $event->getConfigValue() === 'yes') {
$event->setValid(true);
// Not the cleanest way, but we just add the job in the before event.
// If something ever turns wrong the first execution will remove the job again.
// We also first delete the current job, so the next run time is reset.
$this->jobList->remove(UserStatusAutomation::class, ['userId' => $event->getUserId()]);
$this->jobList->add(UserStatusAutomation::class, ['userId' => $event->getUserId()]);
}
} elseif ($event instanceof BeforePreferenceDeletedEvent) {
if ($event->getAppId() === 'dav' && $event->getConfigKey() === 'user_status_automation') {
$event->setValid(true);
}
}
}
}

View file

@ -27,10 +27,34 @@ namespace OCA\DAV\Settings;
use OCA\DAV\AppInfo\Application;
use OCP\AppFramework\Http\TemplateResponse;
use OCP\AppFramework\Services\IInitialState;
use OCP\IConfig;
use OCP\Settings\ISettings;
class AvailabilitySettings implements ISettings {
protected IConfig $config;
protected IInitialState $initialState;
protected ?string $userId;
public function __construct(IConfig $config,
IInitialState $initialState,
?string $userId) {
$this->config = $config;
$this->initialState = $initialState;
$this->userId = $userId;
}
public function getForm(): TemplateResponse {
$this->initialState->provideInitialState(
'user_status_automation',
$this->config->getUserValue(
$this->userId,
'dav',
'user_status_automation',
'no'
)
);
return new TemplateResponse(Application::APP_ID, 'settings-personal-availability');
}

View file

@ -0,0 +1,50 @@
/**
* @copyright 2022 Joas Schilling <coding@schilljs.com>
*
* @author Joas Schilling <coding@schilljs.com>
*
* @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 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/>.
*/
import axios from '@nextcloud/axios'
import { generateOcsUrl } from '@nextcloud/router'
/**
* Enable user status automation based on availability
*/
export async function enableUserStatusAutomation() {
return await axios.post(
generateOcsUrl('/apps/provisioning_api/api/v1/config/users/{appId}/{configKey}', {
appId: 'dav',
configKey: 'user_status_automation',
}),
{
configValue: 'yes',
}
)
}
/**
* Disable user status automation based on availability
*/
export async function disableUserStatusAutomation() {
return await axios.delete(
generateOcsUrl('/apps/provisioning_api/api/v1/config/users/{appId}/{configKey}', {
appId: 'dav',
configKey: 'user_status_automation',
})
)
}

View file

@ -24,6 +24,10 @@
:l10n-saturday="$t('dav', 'Saturday')"
:l10n-sunday="$t('dav', 'Sunday')" />
<CheckboxRadioSwitch :checked.sync="automated">
{{ $t('dav', 'Automatically set user status to "Do not distrub" outside of availability to mute all notifications.') }}
</CheckboxRadioSwitch>
<Button :disabled="loading || saving"
type="primary"
@click="save">
@ -34,6 +38,7 @@
<script>
import { CalendarAvailability } from '@nextcloud/calendar-availability-vue'
import { loadState } from '@nextcloud/initial-state'
import {
showError,
showSuccess,
@ -43,8 +48,13 @@ import {
getEmptySlots,
saveScheduleInboxAvailability,
} from '../service/CalendarService'
import {
enableUserStatusAutomation,
disableUserStatusAutomation,
} from '../service/PreferenceService'
import jstz from 'jstimezonedetect'
import Button from '@nextcloud/vue/dist/Components/Button'
import CheckboxRadioSwitch from '@nextcloud/vue/dist/Components/CheckboxRadioSwitch'
import SettingsSection from '@nextcloud/vue/dist/Components/SettingsSection'
import TimezonePicker from '@nextcloud/vue/dist/Components/TimezonePicker'
@ -52,6 +62,7 @@ export default {
name: 'Availability',
components: {
Button,
CheckboxRadioSwitch,
CalendarAvailability,
SettingsSection,
TimezonePicker,
@ -66,6 +77,7 @@ export default {
saving: false,
timezone: defaultTimezoneId,
slots: getEmptySlots(),
automated: loadState('dav', 'user_status_automation') === 'yes',
}
},
async mounted() {
@ -96,6 +108,11 @@ export default {
this.saving = true
await saveScheduleInboxAvailability(this.slots, this.timezone)
if (this.automated) {
await enableUserStatusAutomation()
} else {
await disableUserStatusAutomation()
}
showSuccess(t('dav', 'Saved availability'))
} catch (e) {

View file

@ -26,6 +26,7 @@ declare(strict_types=1);
namespace OCA\UserStatus\Service;
use OCP\IL10N;
use OCP\UserStatus\IUserStatus;
/**
* Class DefaultStatusService
@ -41,6 +42,9 @@ class PredefinedStatusService {
private const SICK_LEAVE = 'sick-leave';
private const VACATIONING = 'vacationing';
private const REMOTE_WORK = 'remote-work';
/**
* @depreacted See \OCP\UserStatus\IUserStatus::MESSAGE_CALL
*/
public const CALL = 'call';
/** @var IL10N */
@ -196,7 +200,8 @@ class PredefinedStatusService {
self::SICK_LEAVE,
self::VACATIONING,
self::REMOTE_WORK,
self::CALL,
IUserStatus::MESSAGE_CALL,
IUserStatus::MESSAGE_AVAILABILITY,
], true);
}
}

File diff suppressed because one or more lines are too long

View file

@ -18,3 +18,24 @@
* 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/>.
*/
/**
* @copyright 2022 Joas Schilling <coding@schilljs.com>
*
* @author Joas Schilling <coding@schilljs.com>
*
* @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 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/>.
*/

File diff suppressed because one or more lines are too long

View file

@ -64,6 +64,18 @@ interface IUserStatus {
*/
public const INVISIBLE = 'invisible';
/**
* @var string
* @since 25.0.0
*/
public const MESSAGE_CALL = 'call';
/**
* @var string
* @since 25.0.0
*/
public const MESSAGE_AVAILABILITY = 'availability';
/**
* Get the user this status is connected to
*