Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions appinfo/routes.php
Original file line number Diff line number Diff line change
Expand Up @@ -53,4 +53,6 @@
['name' => 'timetable#upsert', 'url' => '/api/timetable/sessions/upsert', 'verb' => 'POST'],
// Admins publish a source's draft lessons in a window (timetable-draft-review).
['name' => 'timetable#publish', 'url' => '/api/timetable/sessions/publish', 'verb' => 'POST'],
// Admins upload the activities and rooms sheets for the timetable generator (timetabling-generator 2.2).
['name' => 'timetableInput#upload', 'url' => '/api/timetable/input/upload', 'verb' => 'POST'],
]);
109 changes: 109 additions & 0 deletions lib/Controller/TimetableInputController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
<?php

/**
* Planninq Timetable Input Controller
*
* Admins upload the rooms and activities sheets the timetable generator uses
* when no app supplies the hour plan.
*
* @category Controller
* @package OCA\Planninq\Controller
*
* @author Conduction Development Team <dev@conduction.nl>
* @copyright 2026 Conduction B.V.
* @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
*
* @version GIT: <git-id>
*
* @link https://conduction.nl
*
* SPDX-FileCopyrightText: 2026 Conduction B.V. <info@conduction.nl>
* SPDX-License-Identifier: EUPL-1.2
*/

declare(strict_types=1);

namespace OCA\Planninq\Controller;

use OCA\Planninq\AppInfo\Application;
use OCA\Planninq\Service\SettingsService;
use OCA\Planninq\Service\TimetableCsvParser;
use OCA\Planninq\Service\TimetableInputBuilder;
use OCA\Planninq\Settings\AdminSettings;
use OCP\AppFramework\Controller;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\Attribute\AuthorizedAdminSetting;
use OCP\AppFramework\Http\JSONResponse;
use OCP\IRequest;

/**
* Takes the uploaded rooms and activities sheets.
*
* @spec openspec/changes/timetabling-generator/tasks.md#task-2.2
*/
class TimetableInputController extends Controller {

/**
* Constructor.
*
* @param IRequest $request The request.
* @param TimetableCsvParser $parser Reads the sheets.
* @param TimetableInputBuilder $inputBuilder Keeps the parsed sheets.
* @param SettingsService $settingsService Answers whether the caller is an admin.
*
* @return void
*/
public function __construct(
IRequest $request,
private readonly TimetableCsvParser $parser,
private readonly TimetableInputBuilder $inputBuilder,
private readonly SettingsService $settingsService,
) {
parent::__construct(appName: Application::APP_ID, request: $request);
}//end __construct()

/**
* Parse and keep the rooms and activities sheets. Admins only.
*
* Nextcloud's admin middleware refuses everyone else before this runs; the
* explicit check is defence in depth, as in TimetableController::upsert().
* Nothing is kept when either sheet has a refused line.
*
* @return JSONResponse 200 with the counts; 400 with the refused lines; 403 for a non-admin.
*
* @spec openspec/changes/timetabling-generator/tasks.md#task-2.2
*/
#[AuthorizedAdminSetting(settings: AdminSettings::class)]
public function upload(): JSONResponse {
if ($this->settingsService->isCurrentUserAdmin() === false) {
return new JSONResponse(['error' => 'Only an admin can upload timetable activities.'], Http::STATUS_FORBIDDEN);
}

$roomsCsv = $this->request->getParam('rooms');
$activitiesCsv = $this->request->getParam('activities');
if (is_string($roomsCsv) === false || is_string($activitiesCsv) === false) {
return new JSONResponse(['error' => 'Send the rooms and the activities sheet as text.'], Http::STATUS_BAD_REQUEST);
}

$rooms = $this->parser->parseRooms(csv: $roomsCsv);
$types = array_values(array_unique(array_column($rooms['rows'], 'type')));
$activities = $this->parser->parseActivities(csv: $activitiesCsv, roomTypes: $types);
$errors = [
'rooms' => $rooms['errors'],
'activities' => $activities['errors'],
];
if ($rooms['errors'] !== [] || $activities['errors'] !== []) {
return new JSONResponse(['error' => 'Some lines were refused. Nothing was kept.', 'errors' => $errors], Http::STATUS_BAD_REQUEST);
}

$this->inputBuilder->storeUpload(rooms: $rooms['rows'], activities: $activities['rows']);

return new JSONResponse(
[
'rooms' => count($rooms['rows']),
'activities' => count($activities['rows']),
'lessons' => array_sum(array_column($activities['rows'], 'lessonsPerWeek')),
]
);
}//end upload()
}//end class
148 changes: 148 additions & 0 deletions lib/Event/TimetableActivitiesQueryEvent.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
<?php

/**
* Planninq Timetable Activities Query Event
*
* The typed door (ADR-041) through which planninq asks the app that owns the
* hour plan (learniq) for the activities and rooms of an academic year, so the
* timetable generator can place them. Planninq dispatches it; a listener in
* the owning app answers it in this event's result slot before dispatch
* returns. An event nobody answers means "no activities from another app": the
* generator then uses an uploaded CSV, or reports that it has no input.
*
* The owning app looks this class up by name, guards it with
* `class_exists()`, and never needs planninq installed to load.
*
* @category Event
* @package OCA\Planninq\Event
*
* @author Conduction Development Team <dev@conduction.nl>
* @copyright 2026 Conduction B.V.
* @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
*
* @version GIT: <git-id>
*
* @link https://conduction.nl
*
* SPDX-FileCopyrightText: 2026 Conduction B.V. <info@conduction.nl>
* SPDX-License-Identifier: EUPL-1.2
*/

declare(strict_types=1);

namespace OCA\Planninq\Event;

use OCP\EventDispatcher\Event;

/**
* A request for the activities and rooms of one academic year.
*
* An activity row: group, subject, teacher (a Nextcloud user id), lessonsPerWeek,
* lessonLength (in periods) and roomType. A room row: reference, capacity, type.
*
* @spec openspec/changes/timetabling-generator/tasks.md#task-2.1
*/
class TimetableActivitiesQueryEvent extends Event {

/**
* Contract version of this event and its answer.
*
* @var integer
*/
public const CONTRACT_VERSION = 1;

/**
* The activities, once an app answered.
*
* @var array<int,array<string,mixed>>|null
*/
private ?array $activities = null;

/**
* The rooms, once an app answered.
*
* @var array<int,array<string,mixed>>
*/
private array $rooms = [];

/**
* The app that answered.
*
* @var string|null
*/
private ?string $answeredBy = null;

/**
* Constructor.
*
* @param string $academicYear The academic year asked for, such as `2026-2027`.
*
* @return void
*/
public function __construct(
private readonly string $academicYear,
) {
parent::__construct();
}//end __construct()

/**
* The academic year asked for.
*
* @return string
*
* @spec openspec/changes/timetabling-generator/tasks.md#task-2.1
*/
public function getAcademicYear(): string {
return $this->academicYear;
}//end getAcademicYear()

/**
* Answer the event with the year's activities and rooms.
*
* @param string $app The answering app, such as `learniq`.
* @param array<int,array<string,mixed>> $activities Activity rows.
* @param array<int,array<string,mixed>> $rooms Room rows.
*
* @return void
*
* @spec openspec/changes/timetabling-generator/tasks.md#task-2.1
*/
public function answer(string $app, array $activities, array $rooms): void {
$this->answeredBy = $app;
$this->activities = array_values($activities);
$this->rooms = array_values($rooms);
}//end answer()

/**
* The activities, or null when no app answered.
*
* @return array<int,array<string,mixed>>|null
*
* @spec openspec/changes/timetabling-generator/tasks.md#task-2.1
*/
public function getActivities(): ?array {
return $this->activities;
}//end getActivities()

/**
* The rooms the answering app sent.
*
* @return array<int,array<string,mixed>>
*
* @spec openspec/changes/timetabling-generator/tasks.md#task-2.1
*/
public function getRooms(): array {
return $this->rooms;
}//end getRooms()

/**
* The app that answered, or null.
*
* @return string|null
*
* @spec openspec/changes/timetabling-generator/tasks.md#task-2.1
*/
public function getAnsweredBy(): ?string {
return $this->answeredBy;
}//end getAnsweredBy()
}//end class
Loading
Loading