diff options
| -rw-r--r-- | app/controllers/running_processes.php | 46 | ||||
| -rw-r--r-- | db/migrations/6.2.4_step5675_running_processes_widget.php | 16 | ||||
| -rw-r--r-- | lib/classes/Processes/ProcessProvider.php | 13 | ||||
| -rw-r--r-- | lib/classes/Processes/Questionnaires.php | 86 | ||||
| -rw-r--r-- | lib/classes/Processes/TimedFolders.php | 42 | ||||
| -rw-r--r-- | lib/classes/RunningProcess.php | 37 | ||||
| -rw-r--r-- | lib/modules/RunningProcessesWidget.php | 26 | ||||
| -rw-r--r-- | lib/plugins/core/RunningProcessPlugin.php | 11 | ||||
| -rw-r--r-- | resources/assets/stylesheets/scss/runningprocesses.scss | 64 | ||||
| -rw-r--r-- | resources/assets/stylesheets/studip.scss | 1 | ||||
| -rw-r--r-- | resources/vue/apps/RunningProcesses.vue | 131 | ||||
| -rw-r--r-- | resources/vue/utils/getRemainingTime.js | 65 |
12 files changed, 538 insertions, 0 deletions
diff --git a/app/controllers/running_processes.php b/app/controllers/running_processes.php new file mode 100644 index 0000000..988062e --- /dev/null +++ b/app/controllers/running_processes.php @@ -0,0 +1,46 @@ +<?php + +class RunningProcessesController extends AuthenticatedController +{ + + public function widget_action() + { + $this->processes = array_merge( + \Studip\Processes\Questionnaires::getProcesses(User::findCurrent()), + \Studip\Processes\TimedFolders::getProcesses(User::findCurrent()) + ); + $plugins = PluginManager::getInstance()->getPlugins('RunningProcessPlugin'); + foreach ($plugins as $plugin) { + $this->processes = array_merge($this->processes, $plugin->getRunningProcesses()); + } + + $this->contexts = []; + foreach ($this->processes as $process) { + if (!isset($this->contexts[$process->context_id])) { + $context = get_object_by_range_id($process->context_id); + + if ($context) { + if ($context instanceof Course) { + $avatar = CourseAvatar::getAvatar($process->context_id); + } else { + $avatar = InstituteAvatar::getAvatar($process->context_id); + } + $this->contexts[$process->context_id] = [ + 'id' => $process->context_id, + 'name' => (string) $context->name, + 'url' => URLHelper::getURL('dispatch.php/course/go', ['to' => $process->context_id]), + 'avatar' => $avatar->getURL(Avatar::SMALL), + ]; + } + } + } + + $this->render_vue_app( + Studip\VueApp::create('RunningProcesses') + ->withProps([ + 'contexts' => $this->contexts, + 'processes' => array_map(function ($process) { return $process->toArray(); }, $this->processes) + ]) + ); + } +} diff --git a/db/migrations/6.2.4_step5675_running_processes_widget.php b/db/migrations/6.2.4_step5675_running_processes_widget.php new file mode 100644 index 0000000..a68dcb8 --- /dev/null +++ b/db/migrations/6.2.4_step5675_running_processes_widget.php @@ -0,0 +1,16 @@ +<?php + +class Step5675RunningProcessesWidget extends Migration +{ + protected function up() + { + $pos = DBManager::get()->fetchColumn("SELECT MAX(navigationpos) + 1 FROM plugins WHERE plugintype = 'PortalPlugin'"); + $sql = "INSERT INTO plugins (pluginclassname, pluginname, plugintype, enabled, navigationpos) VALUES (?)"; + DBManager::get()->execute($sql, [['RunningProcessesWidget', 'RunningProcessesWidget', 'PortalPlugin', 'yes', $pos]]); + + $sql = "INSERT INTO roles_plugins (roleid, pluginid) + SELECT roleid, ? FROM roles WHERE `system` = 'y' AND rolename != 'Nobody'"; + DBManager::get()->execute($sql, [DBManager::get()->lastInsertId()]); + } + +} diff --git a/lib/classes/Processes/ProcessProvider.php b/lib/classes/Processes/ProcessProvider.php new file mode 100644 index 0000000..fcce4bd --- /dev/null +++ b/lib/classes/Processes/ProcessProvider.php @@ -0,0 +1,13 @@ +<?php +namespace Studip\Processes; + +interface ProcessProvider +{ + + /** + * Returns an array of RunningProcess objects for the given user. + * + * @return array : RunningProcess[] + */ + public static function getProcesses(\User $user): array; +} diff --git a/lib/classes/Processes/Questionnaires.php b/lib/classes/Processes/Questionnaires.php new file mode 100644 index 0000000..2354c95 --- /dev/null +++ b/lib/classes/Processes/Questionnaires.php @@ -0,0 +1,86 @@ +<?php + +namespace Studip\Processes; + +class Questionnaires implements ProcessProvider +{ + /** + * Retrieves all active questionnaires accessible to the given user. + * + * This method fetches questionnaires that: + * - Are currently active (within start and stop dates) + * - Are visible + * - Are assigned to courses, institutes, or status groups where the user is a member + * - Are either unanswered or still editable by the user + * + * For each qualifying questionnaire, it creates a RunningProcess object containing: + * - Context information (course/institute/status group) + * - Questionnaire details (title, URL) + * - Time constraints (start and end dates) + * - Response statistics (number of answers / total possible respondents) + * + * @return array : RunningProcess[] + */ + public static function getProcesses(\User $user) : array + { + $statement = \DBManager::get()->prepare(" + SELECT `questionnaires`.* + FROM `questionnaires` + INNER JOIN `questionnaire_assignments` USING (`questionnaire_id`) + LEFT JOIN `seminar_user` ON (`seminar_user`.`Seminar_id` = `questionnaire_assignments`.`range_id` AND `questionnaire_assignments`.`range_type` = 'course') + LEFT JOIN `user_inst` ON (`user_inst`.`Institut_id` = `questionnaire_assignments`.`range_id` AND `questionnaire_assignments`.`range_type` = 'institute') + LEFT JOIN `statusgruppe_user` ON (`statusgruppe_user`.`statusgruppe_id` = `questionnaire_assignments`.`range_id` AND `questionnaire_assignments`.`range_type` = 'statusgruppe') + LEFT JOIN `statusgruppen` ON (`statusgruppen`.`statusgruppe_id` = `questionnaire_assignments`.`range_id` AND `questionnaire_assignments`.`range_type` = 'statusgruppe') + LEFT JOIN `seminar_user` AS `teacher` ON (`teacher`.`Seminar_id` = `statusgruppen`.`range_id` AND `questionnaire_assignments`.`range_type` = 'statusgruppe' AND `teacher`.`status` IN ('tutor', 'dozent')) + WHERE `questionnaires`.`startdate` <= UNIX_TIMESTAMP() + AND `questionnaires`.`stopdate` >= UNIX_TIMESTAMP() + AND `questionnaires`.`visible` = 1 + AND (`seminar_user`.`user_id` = :user_id OR `teacher`.`user_id` = :user_id OR `user_inst`.`user_id` = :user_id OR `statusgruppe_user`.`user_id` = :user_id) + GROUP BY `questionnaires`.`questionnaire_id` + "); + $statement->execute(['user_id' => $user->id]); + $questionnaires = $statement->fetchAll(\PDO::FETCH_ASSOC); + $result = []; + + foreach ($questionnaires as $questionnaire_data) { + $questionnaire = \Questionnaire::buildExisting($questionnaire_data); + if ($questionnaire->isViewable() && (!$questionnaire->isAnswered() || $questionnaire->isEditable())) { + foreach ($questionnaire->assignments as $assignment) { + if ($questionnaire->isAnswerable() || $questionnaire->isEditable()) { + + $answers = $questionnaire->countAnswers(); + if ($assignment->range_type === 'course') { + $allPersons = \CourseMember::countBySQL("`Seminar_id` = ?", [$assignment->range_id]); + $range_id = $assignment->range_id; + } elseif($assignment->range_type === 'institute') { + $allPersons = \InstituteMember::countBySQL("`Institut_id` = ?", [$assignment->range_id]); + $range_id = $assignment->range_id; + } elseif($assignment->range_type === 'statusgruppe') { + $allPersons = \StatusgruppeUser::countBySQL("`statusgruppe_id` = ?", [$assignment->range_id]); + $statusgroup = \Statusgruppen::find($assignment->range_id); + if ($statusgroup) { + $range_id = $statusgroup->range_id; + } + } + + $result[] = new \RunningProcess( + $range_id, + \Icon::create("vote"), + _('Fragebogen'), + $questionnaire->isEditable() + ? \URLHelper::getURL('dispatch.php/questionnaire/evaluate/'.$questionnaire->id) + : \URLHelper::getURL('dispatch.php/questionnaire/answer/'.$questionnaire->id), + $questionnaire->startdate, + $questionnaire->stopdate, + true, + $questionnaire->title, + $questionnaire->isEditable() ? $answers.'/'.$allPersons : '', + $questionnaire->isEditable() ? sprintf(_('Rücklaufquote: %s'), $answers.'/'.$allPersons) : '' + ); + } + } + } + } + return $result; + } +} diff --git a/lib/classes/Processes/TimedFolders.php b/lib/classes/Processes/TimedFolders.php new file mode 100644 index 0000000..1460183 --- /dev/null +++ b/lib/classes/Processes/TimedFolders.php @@ -0,0 +1,42 @@ +<?php + +namespace Studip\Processes; + +class TimedFolders implements ProcessProvider +{ + /** + * Retrieves an array of timed folder processes that are visible, active, and linked to the given user. + * The method fetches folders of type "TimedFolder" for which the user has access, + * processes each folder to check its visibility and end time, and compiles them into a list + * of ongoing processes with relevant details such as folder name, start time, end time, and file information. + * + * @return array : RunningProcess[] + */ + public static function getProcesses(\User $user) : array + { + $folders = \Folder::findBySQL("LEFT JOIN `seminar_user` ON (`seminar_user`.`Seminar_id` = `folders`.`range_id` AND `folders`.`range_type` = 'course') + LEFT JOIN `user_inst` ON (`user_inst`.`Institut_id` = `folders`.`range_id` AND `folders`.`range_type` = 'institute') + WHERE `folders`.`folder_type` = 'TimedFolder' + AND (`seminar_user`.`user_id` = :user_id OR `user_inst`.`user_id` = :user_id) + ", ['user_id' => $user->id]); + $result = []; + foreach ($folders as $folder) { + $folderType = $folder->getTypedFolder(); + if ($folderType->isVisible($user->id) && ($folderType->end_time > 0)) { + $files = $folderType->getFiles(); + $result[] = new \RunningProcess( + $folder->range_id, + $folderType->getIcon(), + _('Zeitgesteuerter Ordner'), + \URLHelper::getURL('dispatch.php/course/files/index/'.$folder->id, ['cid' => $folder->range_id]), + $folderType->start_time, + $folderType->end_time, + false, + $folder->name, + $folderType->isReadable($user->id) && (count($files) > 0) ? sprintf(ngettext('%d Datei', '%d Dateien', count($files)), count($files)) : '' + ); + } + } + return $result; + } +} diff --git a/lib/classes/RunningProcess.php b/lib/classes/RunningProcess.php new file mode 100644 index 0000000..ca71068 --- /dev/null +++ b/lib/classes/RunningProcess.php @@ -0,0 +1,37 @@ +<?php + +class RunningProcess +{ + public readonly string $id; + public function __construct( + public readonly string $context_id, + public readonly Icon $icon, + public readonly string $type, + public readonly string $url, + public readonly int $begin, + public readonly int $end, + public readonly bool $dialog = false, + public readonly string $title = '', + public readonly string $additionalShortInfo = '', + public readonly string $additionalInfoTitleTag = '' + ) { + $this->id = uniqid(); + } + + public function toArray() + { + return [ + 'id' => $this->id, + 'context_id' => $this->context_id, + 'icon' => $this->icon->asImagePath(), + 'type' => $this->type, + 'url' => $this->url, + 'begin' => $this->begin, + 'end' => $this->end, + 'dialog' => $this->dialog, + 'title' => $this->title, + 'additionalShortInfo' => $this->additionalShortInfo, + 'additionalInfoTitleTag' => $this->additionalInfoTitleTag + ]; + } +} diff --git a/lib/modules/RunningProcessesWidget.php b/lib/modules/RunningProcessesWidget.php new file mode 100644 index 0000000..54a4bd4 --- /dev/null +++ b/lib/modules/RunningProcessesWidget.php @@ -0,0 +1,26 @@ +<?php + +class RunningProcessesWidget extends CorePlugin implements PortalPlugin +{ + public function getPluginName() + { + return _('Meine Prozesse'); + } + + public function getMetadata() + { + return [ + 'description' => _('Dieses Widget zeigt offene Prozesse, wie unbearbeitete Fragebögen oder zeitgesteuerte Dateiordner, aus Ihren Veranstaltungen oder Einrichtungen an.') + ]; + } + + function getPortalTemplate() + { + $controller = app(\Trails\Dispatcher::class)->load_controller('running_processes'); + $response = $controller->relayWithRedirect('running_processes/widget'); + $template = $GLOBALS['template_factory']->open('shared/string'); + $template->content = $response->body; + + return $template; + } +} diff --git a/lib/plugins/core/RunningProcessPlugin.php b/lib/plugins/core/RunningProcessPlugin.php new file mode 100644 index 0000000..e64e75c --- /dev/null +++ b/lib/plugins/core/RunningProcessPlugin.php @@ -0,0 +1,11 @@ +<?php + +interface RunningProcessPlugin +{ + /** + * Returns an array of RunningProcess objects, that should be displayed in the running processes widget.. + * + * @return array : [RunningProcess, RunningProcess, ...] + */ + public function getRunningProcesses() : array; +} diff --git a/resources/assets/stylesheets/scss/runningprocesses.scss b/resources/assets/stylesheets/scss/runningprocesses.scss new file mode 100644 index 0000000..23dcbf4 --- /dev/null +++ b/resources/assets/stylesheets/scss/runningprocesses.scss @@ -0,0 +1,64 @@ +.running_processes { + .context { + display: flex; + align-items: center; + .course-avatar-small { + margin-right: 10px; + } + margin-bottom: 10px; + font-size: $font-size-h3; + } + ul.processes { + list-style: none; + padding-left: 35px; + li.running_process { + + display: flex; + align-items: center; + width: 100%; + margin-bottom: 10px; + + img { + width: 35px; + height: 35px; + margin-right: 10px; + } + + .process_right_side { + width: 100%; + .process_text_info { + display: flex; + justify-content: space-between; + + .additionalShortInfo { + padding-left: 10px; + color: $light-gray-color; + } + } + + .progressbar_container { + height: 5px; + margin-top: 5px; + .progress_bar { + width: 100%; + border-radius: 0px; + height: 5px; + border: none; + background-color: $color--gray-6; + .progress { + background-color: $origin-base-color; + &.alerted { + background-color: $red; + } + height: 5px; + } + } + + } + + + + } + } + } +} diff --git a/resources/assets/stylesheets/studip.scss b/resources/assets/stylesheets/studip.scss index e57bb35..1449808 100644 --- a/resources/assets/stylesheets/studip.scss +++ b/resources/assets/stylesheets/studip.scss @@ -83,6 +83,7 @@ @import "scss/raumzeit"; @import "scss/responsive"; @import "scss/resources"; +@import "scss/runningprocesses"; @import "scss/sidebar"; @import "scss/wizard"; @import "scss/schedule"; diff --git a/resources/vue/apps/RunningProcesses.vue b/resources/vue/apps/RunningProcesses.vue new file mode 100644 index 0000000..852aacd --- /dev/null +++ b/resources/vue/apps/RunningProcesses.vue @@ -0,0 +1,131 @@ +<template> + <div class="running_processes"> + <article class="studip"> + <section> + <template v-if="sortedContexts.length === 0"> + {{ $gettext('Es sind derzeit keine laufenden Prozesse vorhanden. Sobald für Sie relevante Aufgaben – zum Beispiel Fragebögen aus Ihren Veranstaltungen oder Einrichtungen – verfügbar sind, erscheinen diese hier.') }} + </template> + <ul class="clean" v-if="sortedContexts.length > 0"> + <li v-for="context in sortedContexts" :key="context.id"> + <a class="context" :href="context.url"> + <span class="my-courses-avatar course-avatar-small" + :style="'background-image: url(' + context.avatar + ')'"></span> + {{ context.name }} + </a> + <ul class="processes"> + <li v-for="process in getProcessesForContext(context)" :key="process.id" class="running_process"> + + <a :href="process.url" + :data-dialog="process.dialog ? 'size=auto' : null"> + <img :src="process.icon" aria-hidden="true"> + </a> + <div class="process_right_side"> + <div class="process_text_info"> + <div> + <a :href="process.url" + :data-dialog="process.dialog ? 'size=auto' : null"> + {{ process.type }} + {{ process.title }} + </a> + <span v-if="process.additionalShortInfo" + :title="process.additionalInfoTitleTag" + class="additionalShortInfo"> + {{ process.additionalShortInfo }} + </span> + </div> + <div :title="getDatetimeInfo(process)" aria-live="off"> + {{ getRemainingTime(process) }} + </div> + </div> + <div class="progressbar_container"> + <div class="progress_bar"> + <div :class="process.end - (currentTime / 1000) <= 86400 ? 'progress alerted' : 'progress'" + :style="'width: ' + getProcessPercentage(process) + '%;'"></div> + </div> + </div> + </div> + + </li> + </ul> + </li> + </ul> + </section> + </article> + </div> +</template> + +<script setup> +import { ref, computed, onMounted, onBeforeUnmount } from 'vue'; +import { $ngettext } from "../../assets/javascripts/lib/gettext"; +import { getRemainingTime as calculateRemainingTime } from "../utils/getRemainingTime"; + +const props = defineProps({ + contexts: { + type: Object, + required: true + }, + processes: { + type: Array, + required: true + } +}); + +const currentTime = ref(Date.now()); +const intervalId = ref(null); + +const sortedContexts = computed(() => { + const contexts = Object.values(props.contexts); + return contexts.sort((a, b) => { + const a_short_end = getProcessesForContext(a)[0].end; + const b_short_end = getProcessesForContext(b)[0].end; + return a_short_end > b_short_end ? 1 : -1; + }); +}); + +function getProcessesForContext(context) { + const processes = props.processes.filter(process => process.context_id === context.id); + return processes.sort((a, b) => { + return a.end > b.end ? 1 : -1; + }); +} + +function getProcessPercentage(process) { + const now = currentTime.value / 1000; + if (now > process.end) { + return 100; + } + if (now < process.begin) { + return 0; + } + + return Math.round((now - process.begin) / (process.end - process.begin) * 100); +} + +function getRemainingTime(process) { + const now = Math.floor(currentTime.value / 1000); + + if (now > process.end) { + return this.$gettext('Beendet'); + } + if (now < process.begin) { + return this.$gettext('Noch nicht gestartet'); + } + return calculateRemainingTime(process.end - now, $ngettext); +} + +function getDatetimeInfo(process) { + return STUDIP.DateTime.getStudipDate(new Date(process.end * 1000)); +} + +onMounted(() => { + intervalId.value = window.setInterval(() => { + currentTime.value = Date.now(); + }, 1000); +}); + +onBeforeUnmount(() => { + if (intervalId.value) { + clearInterval(intervalId.value); + } +}); +</script> diff --git a/resources/vue/utils/getRemainingTime.js b/resources/vue/utils/getRemainingTime.js new file mode 100644 index 0000000..2d66606 --- /dev/null +++ b/resources/vue/utils/getRemainingTime.js @@ -0,0 +1,65 @@ +/** + * Berechnet die verbleibende Zeit eines Prozesses als formatierten String + * @param {Object} process - Der Prozess mit begin und end Timestamp (in Sekunden) + * @param {number} currentTime - Die aktuelle Zeit in Millisekunden (Date.now()) + * @param {Function} gettext - Die Gettext-Funktion für Übersetzungen + * @returns {string} - Formatierte verbleibende Zeit + */ +export function getRemainingTime(remainingSeconds, ngettext) { + if (remainingSeconds < 61) { + return ngettext( + '%{seconds} Sekunde', + '%{seconds} Sekunden', + remainingSeconds, + { minutes: remainingSeconds } + ); + } + if (remainingSeconds < 3601) { + //return gettext('%{minutes} Minuten', {minutes: Math.round(remainingSeconds / 60)}); + return ngettext( + '%{minutes} Sekunde', + '%{minutes} Sekunden', + Math.round(remainingSeconds / 60), + { minutes: Math.round(remainingSeconds / 60) } + ); + } + if (remainingSeconds < 86401) { + return ngettext( + '%{hours} Stunde', + '%{hours} Stunden', + Math.round(remainingSeconds / 3600), + { hours: Math.round(remainingSeconds / 3600) } + ); + } + if (remainingSeconds < 604801) { + return ngettext( + '%{days} Tag', + '%{days} Tage', + Math.round(remainingSeconds / 86400), + { days: Math.round(remainingSeconds / 86400) } + ); + } + if (remainingSeconds < 31536001) { + return ngettext( + '%{weeks} Woche', + '%{weeks} Wochen', + Math.round(remainingSeconds / 604800), + { weeks: Math.round(remainingSeconds / 604800) } + ); + } + if (remainingSeconds < 315360001) { + return ngettext( + '%{months} Monat', + '%{months} Monate', + Math.round(remainingSeconds / 2628000), + { months: Math.round(remainingSeconds / 2628000) } + ); + } + + return ngettext( + '%{years} Jahr', + '%{years} Jahre', + Math.round(remainingSeconds / 31536000), + { years: Math.round(remainingSeconds / 31536000) } + ); +} |
