Skip to content

Commit 7e69659

Browse files
authored
Merge pull request #561 from nextcloud/fix/assignment-timezones
fix(assignments): Add timezone to assignments
2 parents 02feb52 + 9e57cfd commit 7e69659

8 files changed

Lines changed: 149 additions & 11 deletions

File tree

appinfo/info.xml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ Known providers:
6262
6363
More details on how to set this up in the [admin docs](https://docs.nextcloud.com/server/latest/admin_manual/ai/index.html)
6464
]]> </description>
65-
<version>3.5.0-dev.1</version>
65+
<version>3.5.0-dev.2</version>
6666
<licence>agpl</licence>
6767
<author>Julien Veyssier</author>
6868
<namespace>Assistant</namespace>

lib/Controller/AssignmentsApiController.php

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ public function __construct(
4949
* @param string $prompt The prompt to be sent to the assistant when the assignment is executed
5050
* @param int $startsAt The timestamp when the assignment should start being executed
5151
* @param string $recurrence The recurrence rule for the assignment, in RRULE format (e.g. "FREQ=DAILY;INTERVAL=1" for a daily assignment)
52+
* @param string|null $timezone The timezone for this assignment (either the timezone name or a timezone offset, e.g. "Europe/Berlin" or "+0100" for UTC+1). Defaults to the user's current timezone
5253
* @return DataResponse<Http::STATUS_OK, array{assignment: AssistantAssignment}, array{}>|DataResponse<Http::STATUS_FORBIDDEN|Http::STATUS_INTERNAL_SERVER_ERROR|Http::STATUS_BAD_REQUEST, '', array{}>
5354
*
5455
* 200: User assignments returned
@@ -58,9 +59,9 @@ public function __construct(
5859
#[NoAdminRequired]
5960
#[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT, tags: ['assignments'])]
6061
#[Http\Attribute\ApiRoute(verb: 'POST', url: '/assignments')]
61-
public function createUserAssignment(string $title, string $prompt, int $startsAt, string $recurrence): DataResponse {
62+
public function createUserAssignment(string $title, string $prompt, int $startsAt, string $recurrence, ?string $timezone): DataResponse {
6263
try {
63-
$assignment = $this->assignmentsService->createAssignment($this->userId, $title, $prompt, $startsAt, $recurrence);
64+
$assignment = $this->assignmentsService->createAssignment($this->userId, $title, $prompt, $startsAt, $recurrence, $timezone);
6465
$serializedAssignment = $assignment->jsonSerialize();
6566
return new DataResponse(['assignment' => $serializedAssignment]);
6667
} catch (InternalException $e) {
@@ -141,18 +142,19 @@ public function getUserAssignment(int $id): DataResponse {
141142
* @param string|null $prompt The prompt to be sent to the assistant when the assignment is executed
142143
* @param int|null $startsAt The timestamp when the assignment should start being executed
143144
* @param string|null $recurrence The recurrence rule for the assignment, in RRULE format
145+
* @param string|null $timezone The timezone for this assignment, omit to leave the current value in place. the value should be either the timezone name or a timezone offset, e.g. "Europe/Berlin" or "+0100" for UTC+1
144146
*
145147
* @return DataResponse<Http::STATUS_OK, array{assignment: AssistantAssignment}, array{}>|DataResponse<Http::STATUS_FORBIDDEN|Http::STATUS_BAD_REQUEST|Http::STATUS_NOT_FOUND|Http::STATUS_INTERNAL_SERVER_ERROR, '', array{}>
146148
*
147149
* 200: User tasks returned
148150
* 403: User not logged in
149-
* 400: Malformed recurrence rule
151+
* 400: Malformed input
150152
* 404: Assignment not found
151153
*/
152154
#[NoAdminRequired]
153155
#[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT, tags: ['assignments'])]
154156
#[Http\Attribute\ApiRoute(verb: 'PATCH', url: '/assignments/{id}')]
155-
public function updateUserAssignment(int $id, ?string $prompt, ?string $recurrence, ?int $startsAt): DataResponse {
157+
public function updateUserAssignment(int $id, ?string $prompt, ?string $recurrence, ?int $startsAt, ?string $timezone): DataResponse {
156158
if ($this->userId !== null) {
157159
try {
158160
$assignment = $this->assignmentMapper->find($this->userId, $id);
@@ -169,6 +171,13 @@ public function updateUserAssignment(int $id, ?string $prompt, ?string $recurren
169171
if ($startsAt !== null) {
170172
$assignment->setStartsAt($startsAt);
171173
}
174+
if ($timezone !== null) {
175+
try {
176+
$assignment->setTimezone($timezone);
177+
} catch (\InvalidArgumentException $e) {
178+
return new DataResponse('', Http::STATUS_BAD_REQUEST);
179+
}
180+
}
172181
$assignment->setUpdatedAt($this->timeFactory->now()->getTimestamp());
173182
$this->assignmentMapper->update($assignment);
174183
/** @var AssistantAssignment $serializedAssignment */

lib/Db/Assignment.php

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
* @method \void setUpdatedAt(int $updatedAt)
3434
* @method \void setLastRunAt(int $lastRunAt)
3535
* @method \int getLastRunAt()
36+
* @method \string getTimezone()
3637
*/
3738
class Assignment extends Entity implements \JsonSerializable {
3839
/** @var string */
@@ -50,6 +51,9 @@ class Assignment extends Entity implements \JsonSerializable {
5051
/** @var int */
5152
protected $lastRunAt;
5253

54+
/** @var string */
55+
protected $timezone;
56+
5357
public static $columns = [
5458
'id',
5559
'user_id',
@@ -59,6 +63,7 @@ class Assignment extends Entity implements \JsonSerializable {
5963
'created_at',
6064
'updated_at',
6165
'last_run_at',
66+
'timezone'
6267
];
6368
public static $fields = [
6469
'id',
@@ -69,6 +74,7 @@ class Assignment extends Entity implements \JsonSerializable {
6974
'createdAt',
7075
'updatedAt',
7176
'lastRunAt',
77+
'timezone'
7278
];
7379

7480
public function __construct() {
@@ -79,6 +85,7 @@ public function __construct() {
7985
$this->addType('createdAt', Types::BIGINT);
8086
$this->addType('updatedAt', Types::BIGINT);
8187
$this->addType('lastRunAt', Types::BIGINT);
88+
$this->addType('timezone', Types::STRING);
8289
}
8390

8491
#[\ReturnTypeWillChange]
@@ -92,6 +99,7 @@ public function jsonSerialize() {
9299
'created_at' => $this->getCreatedAt(),
93100
'updated_at' => $this->getUpdatedAt(),
94101
'last_run_at' => $this->getLastRunAt(),
102+
'timezone' => $this->getTimezone(),
95103
];
96104
}
97105

@@ -107,6 +115,18 @@ public function setRecurrence(string $recurrence): void {
107115
$this->setter('recurrence', [$recurrence]);
108116
}
109117

118+
/**
119+
* @throws \InvalidArgumentException
120+
*/
121+
public function setTimezone(string $timezone): void {
122+
try {
123+
$tz = new \DateTimeZone($timezone);
124+
} catch (\Throwable $e) {
125+
throw new \InvalidArgumentException('Invalid timezone: ' . $timezone, previous: $e);
126+
}
127+
$this->setter('timezone', [$tz->getName()]);
128+
}
129+
110130
/**
111131
* Evaluates the recurrence rule and checks if a run is due
112132
*/
@@ -115,9 +135,9 @@ public function isDueToRun(\DateTimeImmutable $now): bool {
115135
$startsAt = new \DateTime('@' . $this->getStartsAt());
116136
$lastRunAt = new \DateTime('@' . $this->getLastRunAt());
117137
// Find recurrences after the last run or after the current time if this assignment has never run
118-
$rule = new Rule($this->getRecurrence(), $startsAt);
138+
$rule = new Rule($this->getRecurrence(), $startsAt, timezone: $this->getTimezone());
119139
$transformer = new \Recurr\Transformer\ArrayTransformer();
120-
$constraint = new AfterConstraint($this->getLastRunAt() !== 0 ? $lastRunAt : $startsAt, false);
140+
$constraint = $this->getLastRunAt() !== 0 ? new AfterConstraint($lastRunAt, false) : new AfterConstraint($startsAt, true);
121141
/** @var RecurrenceCollection $collection */
122142
$collection = $transformer->transform($rule, $constraint);
123143
if ($collection->isEmpty()) {
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
7+
* SPDX-License-Identifier: AGPL-3.0-or-later
8+
*/
9+
10+
namespace OCA\Assistant\Migration;
11+
12+
use Closure;
13+
use OCP\DB\ISchemaWrapper;
14+
use OCP\DB\Types;
15+
use OCP\Migration\IOutput;
16+
use OCP\Migration\SimpleMigrationStep;
17+
18+
class Version030500Date20260528083738 extends SimpleMigrationStep {
19+
20+
/**
21+
* @param IOutput $output
22+
* @param Closure(): ISchemaWrapper $schemaClosure
23+
* @param array $options
24+
* @return null|ISchemaWrapper
25+
*/
26+
public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper {
27+
/** @var ISchemaWrapper $schema */
28+
$schema = $schemaClosure();
29+
$schemaChanged = false;
30+
31+
if ($schema->hasTable('assistant_assignments')) {
32+
$table = $schema->getTable('assistant_assignments');
33+
if (!$table->hasColumn('timezone')) {
34+
$table->addColumn('timezone', Types::STRING, [
35+
'notnull' => true,
36+
'length' => 256,
37+
'default' => 'UTC',
38+
]);
39+
$schemaChanged = true;
40+
}
41+
}
42+
43+
return $schemaChanged ? $schema : null;
44+
}
45+
}

lib/ResponseDefinitions.php

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,8 @@
9494
* created_at: int,
9595
* updated_at: int,
9696
* starts_at: int,
97-
* last_run_at: int
97+
* last_run_at: int,
98+
* timezone: string,
9899
* }
99100
*/
100101
class ResponseDefinitions {

lib/Service/AssignmentsService.php

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
use OCP\AppFramework\Utility\ITimeFactory;
2020
use OCP\BackgroundJob\IJobList;
2121
use OCP\DB\Exception;
22+
use OCP\IDateTimeZone;
2223
use OCP\IL10N;
2324
use Psr\Log\LoggerInterface;
2425

@@ -31,6 +32,7 @@ public function __construct(
3132
private LoggerInterface $logger,
3233
private IJobList $jobList,
3334
private IL10N $l10n,
35+
private IDateTimeZone $dateTimeZone,
3436
) {
3537
}
3638

@@ -39,7 +41,7 @@ public function __construct(
3941
* @throws UnauthorizedException
4042
* @throws BadRequestException
4143
*/
42-
public function createAssignment(?string $userId, string $title, string $prompt, int $startsAt, string $recurrence): Assignment {
44+
public function createAssignment(?string $userId, string $title, string $prompt, int $startsAt, string $recurrence, ?string $timezone): Assignment {
4345
if ($userId === null) {
4446
throw new UnauthorizedException();
4547
}
@@ -56,6 +58,14 @@ public function createAssignment(?string $userId, string $title, string $prompt,
5658
} catch (\InvalidArgumentException $e) {
5759
throw new BadRequestException('Invalid recurrence rule', previous: $e);
5860
}
61+
if ($timezone === null) {
62+
$timezone = $this->dateTimeZone->getTimeZone(userId: $userId);
63+
}
64+
try {
65+
$assignment->setTimezone($timezone);
66+
} catch (\InvalidArgumentException $e) {
67+
throw new BadRequestException('Invalid recurrence rule', previous: $e);
68+
}
5969
try {
6070
$this->assignmentMapper->insert($assignment);
6171
} catch (Exception $e) {

openapi.json

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,8 @@
3030
"created_at",
3131
"updated_at",
3232
"starts_at",
33-
"last_run_at"
33+
"last_run_at",
34+
"timezone"
3435
],
3536
"properties": {
3637
"id": {
@@ -61,6 +62,9 @@
6162
"last_run_at": {
6263
"type": "integer",
6364
"format": "int64"
65+
},
66+
"timezone": {
67+
"type": "string"
6468
}
6569
}
6670
},
@@ -5356,6 +5360,11 @@
53565360
"recurrence": {
53575361
"type": "string",
53585362
"description": "The recurrence rule for the assignment, in RRULE format (e.g. \"FREQ=DAILY;INTERVAL=1\" for a daily assignment)"
5363+
},
5364+
"timezone": {
5365+
"type": "string",
5366+
"nullable": true,
5367+
"description": "The timezone for this assignment (either the timezone name or a timezone offset, e.g. \"Europe/Berlin\" or \"+0100\" for UTC+1). Defaults to the user's current timezone"
53595368
}
53605369
}
53615370
}
@@ -5925,6 +5934,11 @@
59255934
"format": "int64",
59265935
"nullable": true,
59275936
"description": "The timestamp when the assignment should start being executed"
5937+
},
5938+
"timezone": {
5939+
"type": "string",
5940+
"nullable": true,
5941+
"description": "The timezone for this assignment, omit to leave the current value in place. the value should be either the timezone name or a timezone offset, e.g. \"Europe/Berlin\" or \"+0100\" for UTC+1"
59285942
}
59295943
}
59305944
}
@@ -6023,7 +6037,7 @@
60236037
}
60246038
},
60256039
"400": {
6026-
"description": "Malformed recurrence rule",
6040+
"description": "Malformed input",
60276041
"content": {
60286042
"application/json": {
60296043
"schema": {

tests/unit/Db/AssignmentTest.php

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
<?php
2+
3+
/**
4+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
5+
* SPDX-License-Identifier: AGPL-3.0-or-later
6+
*/
7+
8+
namespace OCA\Assistant\Tests;
9+
10+
use OCA\Assistant\Db\Assignment;
11+
12+
class AssignmentTest extends \PHPUnit\Framework\TestCase {
13+
14+
/**
15+
* @dataProvider isDueDataProvider
16+
*/
17+
public function testIsDue(string $rrule, string $startsAt, string $lastRunAt, string $timezone, string $now, bool $expected) {
18+
$assignment = new Assignment();
19+
$assignment->setRecurrence($rrule);
20+
$assignment->setStartsAt((new \DateTimeImmutable($startsAt))->getTimestamp());
21+
$assignment->setLastRunAt((new \DateTimeImmutable($lastRunAt))->getTimestamp());
22+
$assignment->setTimezone($timezone);
23+
self::assertEquals($expected, $assignment->isDueToRun(new \DateTimeImmutable($now)));
24+
}
25+
26+
public function isDueDataProvider(): array {
27+
return [
28+
['FREQ=DAILY', '1970-01-02T00:00:00Z', '@0', 'UTC', '1970-01-02T00:00:00Z', true],
29+
['FREQ=DAILY', '@0', '@0', 'UTC', '1970-01-02T00:00:00Z', false],
30+
['FREQ=DAILY;BYHOUR=8', '@0', '@0', 'UTC', '1970-01-01T08:00:00Z', true],
31+
['FREQ=DAILY;BYHOUR=8', '@0', '1970-01-01T08:00:00Z', 'UTC', '1970-01-01T08:00:01Z', false],
32+
['FREQ=DAILY;BYHOUR=8', '@0', '@0', '+0200', '1970-01-01T10:00:00Z', true],
33+
['FREQ=DAILY;BYHOUR=8', '@0', '1970-01-01T10:00:00Z', '+0200', '1970-01-01T10:00:00Z', false],
34+
['FREQ=DAILY;BYHOUR=8', '@0', '1970-01-01T10:00:00Z', '+0200', '1970-01-02T10:00:00Z', true],
35+
['FREQ=DAILY;BYHOUR=8', '@0', '1970-01-01T10:00:00Z', '+0200', '1970-01-02T10:00:00Z', true],
36+
['FREQ=DAILY;BYHOUR=8', '@0', '1970-01-01T10:00:00Z', '+0200', '2027-01-02T10:00:00Z', true],
37+
];
38+
}
39+
}

0 commit comments

Comments
 (0)