From f7762523f3b68a4c09efbd2d4e76fb2d0e067068 Mon Sep 17 00:00:00 2001 From: Ivymaster Date: Mon, 20 Jul 2026 15:18:49 +0200 Subject: [PATCH] WIP: add first version of csv fetching endpoint for lvplans --- application/config/routes.php | 1 + application/controllers/LvPlanCSVDownload.php | 177 +++++++++ application/libraries/LvPlanCSVLib.php | 338 ++++++++++++++++++ public/js/components/Cis/LvPlan/MyLvPlan.js | 8 + 4 files changed, 524 insertions(+) create mode 100644 application/controllers/LvPlanCSVDownload.php create mode 100644 application/libraries/LvPlanCSVLib.php diff --git a/application/config/routes.php b/application/config/routes.php index 724bdccce..1654d6d9b 100644 --- a/application/config/routes.php +++ b/application/config/routes.php @@ -127,6 +127,7 @@ $route['lvplan/caldav'] = 'LvPlanCalDAV'; $route['lvplan/caldav/.*'] = 'LvPlanCalDAV'; $route['lvplan/ical/subscription'] = 'LvPlanICALSubscription'; $route['lvplan/ical/download'] = 'LvPlanICALDownload'; +$route['lvplan/csv/download'] = 'LvPlanCSVDownload'; // load routes from extensions, also look for environment-specific configs $subdirs = ['application/config/extensions', 'application/config/' . ENVIRONMENT . '/extensions']; diff --git a/application/controllers/LvPlanCSVDownload.php b/application/controllers/LvPlanCSVDownload.php new file mode 100644 index 000000000..bffaa8240 --- /dev/null +++ b/application/controllers/LvPlanCSVDownload.php @@ -0,0 +1,177 @@ + ['basis/cis:r'] + ]); + + $this->load->model('person/Benutzer_model', 'BenutzerModel'); + } + + /** + * Serves an authenticated CSV file download. + * + * @return void + */ + public function index() + { + $type = $this->getType(); + $uid = $this->getUid(); + $target = $this->getTarget(); + list($begin, $ende) = $this->getDateRange(); + + $this->authorizeUid($uid); + $this->validateUid($uid); + + $this->load->library('LvPlanCSVLib'); + $content = $this->lvplancsvlib->getContent( + $uid, + $begin, + $ende, + $type, + $target + ); + + $filename = 'FH-Kalender_'.date('m_Y', $begin); + if ($target === 'outlook') + $filename .= '_outlook'; + $filename .= '.csv'; + + $this->output + ->set_content_type('text/csv', 'UTF-8') + ->set_header('Content-Disposition: attachment; filename="'.$filename.'"') + ->set_header('Pragma: public') + ->set_header('Expires: 0') + ->set_output($content); + } + + /** + * @return string Requested timetable type. + */ + private function getType() + { + $type = $this->input->get('type', true); + + if (!in_array($type, array('student', 'lektor'), true)) + show_error('Ungueltiger Parameter: type', 400); + + return $type; + } + + /** + * @return string Requested user UID. + */ + private function getUid() + { + $uid = $this->input->get('pers_uid', true); + + if (!is_string($uid) || $uid === '' || !check_utf8($uid)) + show_error('Ungueltiger Parameter: pers_uid', 400); + + return $uid; + } + + /** + * @return string|null Requested CSV target. + */ + private function getTarget() + { + $target = $this->input->get('target', true); + + if ($target !== null && $target !== 'outlook') + show_error('Ungueltiger Parameter: target', 400); + + return $target; + } + + /** + * Validates and expands the requested range to complete weeks. + * + * @return array{0: int, 1: int} Begin and end timestamps. + */ + private function getDateRange() + { + $begin = $this->getTimestamp('begin'); + $ende = $this->getTimestamp('ende'); + + if ($ende < $begin) + show_error('Ungueltiger Datumsbereich', 400); + + if ($ende - $begin > self::MAX_DATE_RANGE) + show_error('Datumsbereich zu gross', 400); + + $begin = strtotime('monday this week', $begin); + $ende = strtotime('sunday this week', $ende); + + return array($begin, $ende); + } + + /** + * @param string $name GET parameter name. + * @return int Unix timestamp. + */ + private function getTimestamp($name) + { + $value = $this->input->get($name, true); + + if (!is_string($value) || !ctype_digit($value)) + show_error('Ungueltiger Parameter: '.$name, 400); + + $timestamp = filter_var( + $value, + FILTER_VALIDATE_INT, + array('options' => array('min_range' => 0)) + ); + + if ($timestamp === false) + show_error('Ungueltiger Parameter: '.$name, 400); + + return $timestamp; + } + + /** + * Ensures access to another user's timetable is explicitly permitted. + * + * @param string $uid Requested user UID. + * @return void + */ + private function authorizeUid($uid) + { + if ($uid !== getAuthUID() + && !$this->permissionlib->isBerechtigt('basis/other_lv_plan')) + { + show_error('Forbidden', 403); + } + } + + /** + * Ensures the requested user exists. + * + * @param string $uid Requested user UID. + * @return void + */ + private function validateUid($uid) + { + $this->BenutzerModel->addSelect('uid'); + $user = $this->BenutzerModel->load(array($uid)); + + if (isError($user)) + show_error(getError($user), 500); + + if (!hasData($user)) + show_error('Ungueltiger Parameter: pers_uid', 400); + } +} diff --git a/application/libraries/LvPlanCSVLib.php b/application/libraries/LvPlanCSVLib.php new file mode 100644 index 000000000..451dfadac --- /dev/null +++ b/application/libraries/LvPlanCSVLib.php @@ -0,0 +1,338 @@ +ci =& get_instance(); + } + + /** + * Builds CSV content for a user's timetable. + * + * @param string $uid User UID. + * @param int $begin First included date as Unix timestamp. + * @param int $ende Last included date as Unix timestamp. + * @param string $type Timetable type (student or lektor). + * @param string|null $target Optional CSV target (outlook). + * @return string + */ + public function getContent($uid, $begin, $ende, $type, $target = null) + { + if (!in_array($type, array('student', 'lektor'), true)) + throw new InvalidArgumentException('Unsupported timetable type'); + if ($target !== null && $target !== self::TARGET_OUTLOOK) + throw new InvalidArgumentException('Unsupported CSV target'); + + $isLecturer = $type === 'lektor'; + $this->ci->load->library('KalenderLib', array('uid' => $uid)); + + $startDate = date('Y-m-d', $begin); + $endDate = date('Y-m-d', $ende); + + if ($isLecturer) + { + $events = $this->ci->kalenderlib + ->getPlanForLecturerByLecturer($startDate, $endDate, $uid); + } + else + { + $events = $this->ci->kalenderlib + ->getPlanForStudentByStudent($startDate, $endDate, $uid); + } + + if (!is_array($events)) + $events = array(); + + $content = $this->buildCSVLine( + $target === self::TARGET_OUTLOOK ? self::OUTLOOK_HEADER : self::GENERIC_HEADER + ); + + foreach ($events as $event) + { + $eventData = $this->getEventData($event); + if ($eventData === null) + continue; + + $content .= $this->buildCSVLine( + $target === self::TARGET_OUTLOOK + ? $this->buildOutlookRow($eventData) + : $this->buildGenericRow($eventData) + ); + } + + return $content; + } + + /** + * @param object $event Calendar event. + * @return array|null Normalized CSV values or null for invalid dates. + */ + private function getEventData($event) + { + $start = $this->createDateTime(isset($event->isostart) ? $event->isostart : null); + $end = $this->createDateTime(isset($event->isoend) ? $event->isoend : null); + + if ($start === null || $end === null) + return null; + + $timezone = new DateTimeZone('Europe/Vienna'); + $start->setTimezone($timezone); + $end->setTimezone($timezone); + + $isReservation = isset($event->type) && $event->type === 'reservierung'; + $title = $isReservation + ? (isset($event->titel) ? $event->titel : '') + : $this->joinValues(isset($event->topic) ? $event->topic : array()); + $location = $this->joinValues( + isset($event->ort_kurzbz) ? $event->ort_kurzbz : array(), + ' / ' + ); + $groups = $this->getGroups($event); + $lecturers = $this->getLecturers($event); + + if ($isReservation) + { + $description = isset($event->beschreibung) ? $event->beschreibung : ''; + } + else + { + $descriptionParts = array( + 'Stundenplan', + isset($event->lehrfach_bez) ? $event->lehrfach_bez : '', + $lecturers, + $groups, + $location, + isset($event->beschreibung) ? $event->beschreibung : '' + ); + $description = implode("\r\n", array_values(array_filter( + $descriptionParts, + function ($value) { + return $value !== ''; + } + ))); + } + + return array( + 'title' => $title, + 'location' => $location, + 'description' => $description, + 'groups' => $groups, + 'start_date' => $start->format('d.m.Y'), + 'start_time' => $start->format('H:i:s'), + 'end_date' => $end->format('d.m.Y'), + 'end_time' => $end->format('H:i:s') + ); + } + + /** + * @param array $eventData Normalized event values. + * @return array Generic CSV row. + */ + private function buildGenericRow(array $eventData) + { + $category = defined('LVPLAN_KATEGORIE') ? LVPLAN_KATEGORIE : 'StundenplanTW'; + + return array( + $eventData['title'], + $category, + $eventData['location'], + $eventData['description'], + 'Stundenplan', + $eventData['start_date'], + $eventData['start_time'], + $eventData['end_date'], + $eventData['end_time'], + '', + '', + '', + '', + '' + ); + } + + /** + * @param array $eventData Normalized event values. + * @return array Outlook CSV row. + */ + private function buildOutlookRow(array $eventData) + { + $subject = $eventData['title']; + if ($eventData['groups'] !== '') + $subject .= ' - '.$eventData['groups']; + + return array( + $subject, + $eventData['start_date'], + $eventData['start_time'], + $eventData['end_date'], + $eventData['end_time'], + 'Aus', + 'Aus', + '', + '', + '', + '', + '', + '', + '', + $eventData['description'], + 'StundenplanFH', + $eventData['location'], + 'Normal', + 'Aus', + '', + 'Normal', + '2' + ); + } + + /** + * @param object $event Calendar event. + * @return string Lecturer abbreviations. + */ + private function getLecturers($event) + { + $lecturers = array(); + + if (isset($event->lektor) && is_array($event->lektor)) + { + foreach ($event->lektor as $lecturer) + { + if (isset($lecturer['kurzbz']) && $lecturer['kurzbz'] !== '') + $lecturers[] = $lecturer['kurzbz']; + } + } + + return implode(' / ', array_unique($lecturers)); + } + + /** + * @param object $event Calendar event. + * @return string Participant group labels. + */ + private function getGroups($event) + { + $groups = array(); + + if (isset($event->gruppe) && is_array($event->gruppe)) + { + foreach ($event->gruppe as $group) + { + if (isset($group['bezeichnung']) && $group['bezeichnung'] !== '') + $groups[] = $group['bezeichnung']; + } + } + + if (isset($event->teilnehmer_gruppe) && is_array($event->teilnehmer_gruppe)) + { + foreach ($event->teilnehmer_gruppe as $group) + { + if (isset($group['gruppe_kurzbz']) && $group['gruppe_kurzbz'] !== '') + $groups[] = $group['gruppe_kurzbz']; + } + } + + return implode(' / ', array_unique($groups)); + } + + /** + * @param mixed $value String or list of strings. + * @param string $separator Join separator. + * @return string + */ + private function joinValues($value, $separator = ' / ') + { + return is_array($value) ? implode($separator, $value) : (string)$value; + } + + /** + * Builds one fully quoted RFC-style CSV row. + * + * @param array $values Row values. + * @return string + */ + private function buildCSVLine(array $values) + { + $escapedValues = array_map(function ($value) { + $value = preg_replace('/\r\n|\r|\n/', "\r\n", (string)$value); + return '"'.str_replace('"', '""', $value).'"'; + }, $values); + + return implode(',', $escapedValues)."\r\n"; + } + + /** + * @param mixed $dateTime Date/time input. + * @return DateTime|null + */ + private function createDateTime($dateTime) + { + if ($dateTime instanceof DateTime) + return clone $dateTime; + + if ($dateTime === null || $dateTime === '') + return null; + + try + { + if (is_numeric($dateTime)) + return new DateTime('@'.(int)$dateTime); + + return new DateTime($dateTime); + } + catch (Exception $e) + { + return null; + } + } +} diff --git a/public/js/components/Cis/LvPlan/MyLvPlan.js b/public/js/components/Cis/LvPlan/MyLvPlan.js index 5c0b77ea9..92d3f0c1f 100644 --- a/public/js/components/Cis/LvPlan/MyLvPlan.js +++ b/public/js/components/Cis/LvPlan/MyLvPlan.js @@ -80,10 +80,18 @@ export default { + '&pers_uid=' + encodeURIComponent(this.uid) + '&begin=' + encodeURIComponent(start) + '&ende=' + encodeURIComponent(ende); + const new_csv_download_link = FHC_JS_DATA_STORAGE_OBJECT.app_root + + FHC_JS_DATA_STORAGE_OBJECT.ci_router + + '/lvplan/csv/download' + + '?type=' + encodeURIComponent(type) + + '&pers_uid=' + encodeURIComponent(this.uid) + + '&begin=' + encodeURIComponent(start) + + '&ende=' + encodeURIComponent(ende); return [ { title: "excel", icon: 'fa-solid fa-file-excel', link: download_link + '&format=excel' }, { title: "csv", icon: 'fa-solid fa-file-csv', link: download_link + '&format=csv' }, + { title: "csv-new", icon: 'fa-solid fa-file-csv', link: new_csv_download_link }, { title: "ical1", icon: 'fa-regular fa-calendar', link: download_link + '&format=ical&version=1&target=ical' }, { title: "ical2", icon: 'fa-regular fa-calendar', link: download_link + '&format=ical&version=2&target=ical' }, { title: "ical1-new", icon: 'fa-regular fa-calendar', link: new_download_link + '&version=1' },