aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--app/controllers/running_processes.php46
-rw-r--r--db/migrations/6.2.4_step5675_running_processes_widget.php16
-rw-r--r--lib/classes/Processes/ProcessProvider.php13
-rw-r--r--lib/classes/Processes/Questionnaires.php86
-rw-r--r--lib/classes/Processes/TimedFolders.php42
-rw-r--r--lib/classes/RunningProcess.php37
-rw-r--r--lib/modules/RunningProcessesWidget.php26
-rw-r--r--lib/plugins/core/RunningProcessPlugin.php11
-rw-r--r--resources/assets/stylesheets/scss/runningprocesses.scss64
-rw-r--r--resources/assets/stylesheets/studip.scss1
-rw-r--r--resources/vue/apps/RunningProcesses.vue131
-rw-r--r--resources/vue/utils/getRemainingTime.js65
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) }
+ );
+}