From 4c10ca495c20c3f2a07841c54fbd1888b2dbac28 Mon Sep 17 00:00:00 2001 From: Murtaza Sultani Date: Thu, 20 Nov 2025 15:15:16 +0100 Subject: Resolve "Zielgruppenspezifische Startseitenwidgets" Closes #4756 Merge request studip/studip!3696 --- app/controllers/start.php | 311 +++++++++++++++++++++ app/views/start/masswidget_index.php | 90 ++++++ db/migrations/6.1.17_step_4756.php | 53 ++++ .../MassMail/MassMailPermissionFilter.php | 2 +- lib/models/MassWidget/MassWidget.php | 187 +++++++++++++ lib/models/MassWidget/MassWidgetFilter.php | 32 +++ lib/models/WidgetUser.php | 8 +- resources/vue/components/StudipDialog.vue | 2 +- resources/vue/components/StudipUserFilter.vue | 13 +- 9 files changed, 689 insertions(+), 9 deletions(-) create mode 100644 app/views/start/masswidget_index.php create mode 100644 db/migrations/6.1.17_step_4756.php create mode 100644 lib/models/MassWidget/MassWidget.php create mode 100644 lib/models/MassWidget/MassWidgetFilter.php diff --git a/app/controllers/start.php b/app/controllers/start.php index 0bd3ca9..9500004 100644 --- a/app/controllers/start.php +++ b/app/controllers/start.php @@ -1,4 +1,8 @@ syncMassWidgets(); + $plugin_manager = PluginManager::getInstance(); $widgets = WidgetUser::getWidgets($GLOBALS['user']->id); $this->columns = [[], []]; @@ -87,6 +93,22 @@ class StartController extends AuthenticatedController // Root may set initial positions if ($GLOBALS['perm']->have_perm('root')) { + $massWidgetActions = $sidebar->addWidget(new ActionsWidget()); + $massWidgetActions->setTitle(_('Widgets für Zielgruppen')); + + $massWidgetActions->addLink( + _('Regel hinzufügen'), + $this->url_for('start/masswidget_edit'), + Icon::create('add') + )->asDialog(); + + $massWidgetActions->addLink( + _('Regel-Übersicht'), + $this->url_for('start/masswidget_index'), + Icon::create('view-wall') + )->asDialog(); + + $settings = $sidebar->addWidget(new ActionsWidget()); $settings->setTitle(_('Einstellungen')); $settings->addElement(new WidgetElement(_('Standard-Startseite bearbeiten:'))); @@ -385,4 +407,293 @@ class StartController extends AuthenticatedController $this->relocate('start'); } + + public function masswidget_index_action() + { + $GLOBALS['perm']->check('root'); + + $this->massWidgets = MassWidget::findBySQL('1'); + } + + public function masswidget_edit_action(?MassWidget $massWidget = null) + { + $GLOBALS['perm']->check('root'); + + PageLayout::setTitle(_('Widget hinzufügen')); + + if (Request::isPost() && !$massWidget->isNew()) { + CSRFProtection::verifyUnsafeRequest(); + + if ( + Request::get('target') !== $massWidget->target + || Request::int('plugin_id') !== $massWidget->plugin_id + ) { + $massWidget->deleteUserWidgets(); + } + } + + // SearchType needed for course selection + $courseSearch = new StandardSearch('Seminar_id'); + + $widgets = PluginEngine::getPlugins(PortalPlugin::class); + + $availableWidgets = []; + foreach ($widgets as $widget) { + $availableWidgets[$widget->getPluginId()] = htmlReady($widget->getPluginName()); + } + + $semesters = []; + foreach (array_reverse(Semester::getAll()) as $one) { + $semesters[$one->id] = $one->name; + } + + $form = Form::fromSORM( + $massWidget, + [ + 'legend' => _('Zielgruppe'), + 'collapsed' => false, + 'collapsable' => false, + 'fields' => [ + 'target' => [ + 'type' => 'select', + 'required' => true, + 'label' => _('Zielgruppe'), + 'value' => $massWidget->target ?? 'all', + 'options' => MassWidget::getTargets() + ], + 'student_filters' => [ + 'type' => 'userFilter', + 'label' => _('Filterauswahl'), + 'if' => 'target === "students"', + 'context' => '', + 'target' => 'students', + 'store' => function($value, $input) { + if ($input->getContextObject()->target === 'students') { + $input->getContextObject()->filters = $this->buildMassWidgetFilters($value); + } + } + ], + 'employee_filters' => [ + 'type' => 'userFilter', + 'label' => _('Filterauswahl'), + 'if' => 'target === "employees"', + 'context' => '', + 'target' => 'employees', + 'store' => function($value, $input) { + if ($input->getContextObject()->target === 'employees') { + $input->getContextObject()->filters = $this->buildMassWidgetFilters($value); + } + } + ], + 'semester' => [ + 'type' => 'select', + 'label' => _('Semester'), + 'value' => $massWidget->settings['semester'] ?? \Semester::findDefault()->id, + 'if' => 'target === "lecturers"', + 'options' => $semesters, + 'store' => function($value, $input) { + if ($input->getContextObject()->target === 'lecturers') { + $input->getContextObject()->settings = ['semester' => $value]; + } + } + ], + 'courses' => [ + 'type' => 'quicksearchList', + 'label' => _('Veranstaltungen'), + 'value' => json_encode($massWidget->settings?->getArrayCopy()['courses'] ?? []), + 'if' => 'target === "courses"', + 'searchtype' => $courseSearch, + 'store' => function($value, $input) { + if ($input->getContextObject()->target === 'courses') { + $input->getContextObject()->settings = []; + $input->getContextObject()->settings['courses'] = \Course::findAndMapMany( + function ($course) { + return ['id' => $course->id, 'name' => $course->getFullname()]; + }, + json_decode($value, true) + ); + } + } + ], + 'course_perm' => [ + 'type' => 'select', + 'label' => _('Rechtestufe'), + 'value' => $massWidget->settings['perm'] ?? 'autor', + 'if' => 'target === "courses"', + 'options' => [ + 'dozent' => get_title_for_status('dozent', 2, 1), + 'tutor' => get_title_for_status('tutor', 2, 1), + 'autor' => get_title_for_status('autor', 2, 1), + 'user' => get_title_for_status('user', 2, 1), + ], + 'store' => function($value, $input) { + if ($input->getContextObject()->target === 'courses') { + $input->getContextObject()->settings['perm'] = $value; + } + } + ], + 'manual_usernames' => [ + 'type' => 'textarea', + 'label' => _('Liste von Benutzernamen (durch Zeilenumbruch getrennt)'), + 'if' => 'target === "usernames"', + 'value' => $massWidget->settings['usernames'] ?? '', + 'store' => function($value, $input) { + if ($input->getContextObject()->target === 'usernames') { + $input->getContextObject()->settings = []; + $input->getContextObject()->settings['usernames'] = $value; + } + } + ], + ] + ], + $this->url_for('start') + )->addSORM($massWidget, [ + 'legend' => _('Widget'), + 'collapsable' => false, + 'collapsed' => false, + 'fields' => [ + 'author_id' => [ + 'type' => 'hidden', + 'value' => User::findCurrent()->id + ], + 'name' => [ + 'type' => 'text', + 'required' => true, + 'label' => _('Name'), + 'value' => $massWidget->name + ], + 'plugin_id' => [ + 'type' => 'select', + 'required' => true, + 'label' => _('Widget'), + 'value' => $massWidget->plugin_id ?? '0', + 'options' => $availableWidgets + ], + 'col' => [ + 'if' => 'plugin_id > 0', + 'value' => $massWidget->col ?? '0', + 'label' => _('Spalte'), + 'type' => 'radio', + 'orientation' => 'vertical', + 'options' => [ + '0' => _('Links'), + '1' => _('Rechts'), + ], + ], + 'row' => [ + 'if' => 'plugin_id > 0', + 'value' => $massWidget->row ?? '0', + 'type' => 'radio', + 'label' => _('Zeile'), + 'orientation' => 'vertical', + 'default' => '0', + 'options' => [ + '0' => _('Oben'), + '1' => _('Unten'), + ], + ] + ] + ]) + ->setSuccessMessage(_('Die Regel für das Widget wurde gespeichert.')) + ->autoStore(); + + $this->render_form($form); + } + + public function masswidget_delete_action(MassWidget $massWidget) + { + CSRFProtection::verifyUnsafeRequest(); + + $GLOBALS['perm']->check('root'); + + $massWidget->deleteUserWidgets()->delete(); + + PageLayout::postSuccess(_('Die Regel für das Widget wurde gelöscht.')); + + $this->redirect($this->url_for('start')); + } + + public function syncMassWidgets(): void + { + $massWidgets = MassWidget::findBySQL('1'); + + if (count($massWidgets) > 0) { + WidgetUser::setInitialWidgets(User::findCurrent()->id); + } + + foreach ($massWidgets as $massWidget) { + $recipientIds = $massWidget->getTargetUserIds(); + + if (in_array(User::findCurrent()->id, $recipientIds)) { + $userWidget = WidgetUser::findOneBySQL( + 'pluginid = :plugin_id AND range_id = :user_id', + ['plugin_id' => $massWidget->plugin_id, 'user_id' => User::findCurrent()->id] + ); + + if (!$userWidget) { + $userWidget = new WidgetUser(); + $userWidget->range_id = User::findCurrent()->id; + $userWidget->pluginid = $massWidget->plugin_id; + } + + if ($userWidget->isNew() || $massWidget->chdate > $userWidget->chdate) { + if ((int) $massWidget->row === 0) { + $minRow = DBManager::get()->fetchColumn( + "SELECT MIN(`position`) - 1 FROM `widget_user` WHERE `range_id` = ?", + [User::findCurrent()->id] + ); + $userWidget->position = $minRow; + } else { + $maxRow = DBManager::get()->fetchColumn( + "SELECT MAX(`position`) + 1 FROM `widget_user` WHERE `range_id` = ?", + [User::findCurrent()->id] + ); + $userWidget->position = $maxRow; + } + + $userWidget->is_active = 1; + $userWidget->col = $massWidget->col ?? 0; + $userWidget->store(); + } + } + } + } + + private function buildMassWidgetFilters(array $value): array + { + $filters = []; + + foreach ($value as $one) { + $filter = new UserFilter($one['id'] ?? ''); + $filter->fields = []; + + foreach ($one['attributes']['fields'] as $field) { + $className = $field['attributes']['type']; + + if (!is_a($className, UserFilterField::class, true)) { + throw new InvalidArgumentException('Only user filters allowed'); + } + + $f = new $className(); + + if (!empty($field['id'])) { + $f->setId($field['id']); + } + + $f->setCompareOperator($field['attributes']['compare-operator']); + $f->setValue($field['attributes']['value']); + + $filter->addField($f); + } + + $filter->store(); + + $connection = new MassWidgetFilter(); + $connection->filter_id = $filter->getId(); + + $filters[] = $connection; + } + + return $filters; + } } diff --git a/app/views/start/masswidget_index.php b/app/views/start/masswidget_index.php new file mode 100644 index 0000000..8387ebb --- /dev/null +++ b/app/views/start/masswidget_index.php @@ -0,0 +1,90 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + +
+ name) ?> + + plugin->pluginclassname) ?> + + author, true) ?> + + target) . ' ('. count($massWidget->getTargetUserIds()) . ')' ?> + + addLink( + $controller->masswidget_editURL($massWidget), + _('Bearbeiten'), + Icon::create('edit'), + ['data-dialog' => 'default'] + ) + ->addButton( + 'delete', + _('Löschen'), + Icon::create('trash'), + [ + 'data-confirm' => sprintf( + _('Wollen Sie die Regel "%s" löschen?'), + $massWidget->name + ), + 'form' => 'delete-mass-widget', + 'formaction' => $controller->masswidget_deleteURL($massWidget) + ] + ) + ?> +
+ +
+ +
+ +
diff --git a/db/migrations/6.1.17_step_4756.php b/db/migrations/6.1.17_step_4756.php new file mode 100644 index 0000000..fa9abcb --- /dev/null +++ b/db/migrations/6.1.17_step_4756.php @@ -0,0 +1,53 @@ +exec("CREATE TABLE IF NOT EXISTS `masswidget` ( + `id` INT NOT NULL AUTO_INCREMENT, + `name` VARCHAR(255) NOT NULL, + `author_id` CHAR(32) COLLATE latin1_bin NOT NULL, + `target` ENUM('all', 'students', 'employees', 'lecturers', 'courses', 'usernames') COLLATE latin1_bin, + `settings` LONGTEXT, + `exclude_users` LONGTEXT, + `plugin_id` INT UNSIGNED NOT NULL, + `row` TINYINT UNSIGNED DEFAULT 0, + `col` TINYINT UNSIGNED DEFAULT 0, + `mkdate` INT UNSIGNED NOT NULL, + `chdate` INT UNSIGNED NOT NULL, + PRIMARY KEY (`id`), + INDEX author_id (`author_id`) + )"); + + DBManager::get()->exec("CREATE TABLE IF NOT EXISTS `masswidget_filter` ( + `masswidget_id` INT NOT NULL, + `filter_id` CHAR(32) COLLATE latin1_bin NOT NULL, + `mkdate` INT UNSIGNED NOT NULL, + PRIMARY KEY (`masswidget_id`, `filter_id`), + INDEX filter_id (`filter_id`) + )"); + + DBManager::get()->exec("ALTER TABLE `widget_user` + ADD COLUMN `is_active` TINYINT(1) UNSIGNED DEFAULT 1, + ADD COLUMN `chdate` INT UNSIGNED NOT NULL"); + } + + protected function down() + { + DBManager::get()->exec(" + DROP TABLE IF EXISTS + `masswidget_filter`, + `masswidget` + "); + + DBManager::get()->exec("ALTER TABLE `widget_user` + DROP COLUMN `is_active`, + DROP COLUMN `chdate`"); + } +}; diff --git a/lib/classes/UserFilterFields/MassMail/MassMailPermissionFilter.php b/lib/classes/UserFilterFields/MassMail/MassMailPermissionFilter.php index eba3307..d462748 100644 --- a/lib/classes/UserFilterFields/MassMail/MassMailPermissionFilter.php +++ b/lib/classes/UserFilterFields/MassMail/MassMailPermissionFilter.php @@ -33,7 +33,7 @@ class MassMailPermissionFilter extends PermissionCondition */ public static function getTargets() { - return ['employees']; + return ['students', 'employees']; } /** diff --git a/lib/models/MassWidget/MassWidget.php b/lib/models/MassWidget/MassWidget.php new file mode 100644 index 0000000..f76a951 --- /dev/null +++ b/lib/models/MassWidget/MassWidget.php @@ -0,0 +1,187 @@ + User::class, + 'foreign_key' => 'author_id', + 'assoc_foreign_key' => 'user_id' + ]; + $config['has_many']['filters'] = [ + 'class_name' => MassWidgetFilter::class, + 'assoc_foreign_key' => 'masswidget_id', + 'on_store' => 'store', + 'on_delete' => 'delete' + ]; + $config['has_one']['plugin'] = [ + 'class_name' => Plugin::class, + 'foreign_key' => 'plugin_id', + 'assoc_foreign_key' => 'pluginid' + ]; + + parent::configure($config); + } + + public static function getTargets(): array + { + return [ + 'all' => _('alle'), + 'students' => _('Studierende'), + 'employees' => _('Mitarbeitende'), + 'lecturers' => _('Aktive Lehrende'), + 'courses' => _('Veranstaltungen'), + 'usernames' => _('Liste von Nutzernamen'), + ]; + } + + /** + * Gets the real recipient list for this widget. + * @return string[] The list of user IDs that will receive this widget. + */ + public function getTargetUserIds(): array + { + $ids = []; + + switch ($this->target) { + // Everyone studying something or working at an institute. + case 'all': + $students = DBManager::get()->fetchFirst("SELECT DISTINCT `user_id` FROM `user_studiengang`"); + + $employees = DBManager::get()->fetchFirst( + "SELECT DISTINCT `user_id` FROM `user_inst` WHERE `inst_perms` IN (:perms)", + ['perms' => ['autor', 'tutor', 'dozent']] + ); + + $ids = array_unique(array_merge($students, $employees)); + + break; + + // Students are users with at least one studycourse assignment in user_studiengang. + case 'students': + $ids = DBManager::get()->fetchFirst("SELECT DISTINCT `user_id` FROM `user_studiengang`"); + + if (count($this->filters) > 0) { + $filtered = []; + foreach ($this->filters as $filter) { + $f = new UserFilter($filter->filter_id); + $filtered = array_merge($filtered, $f->getUsers()); + } + + $ids = array_unique(array_intersect($ids, $filtered)); + } + + break; + + // Employees are users with at least one institute assignment at 'autor" level or more. + case 'employees': + $ids = DBManager::get()->fetchFirst( + "SELECT DISTINCT `user_id` FROM `user_inst` WHERE `inst_perms` IN (:perms)", + ['perms' => ['autor', 'tutor', 'dozent']] + ); + + if (count($this->filters) > 0) { + $filtered = []; + foreach ($this->filters as $filter) { + $f = new UserFilter($filter->filter_id); + $filtered = array_merge($filtered, $f->getUsers()); + } + + $ids = array_unique(array_intersect($ids, $filtered)); + } + + break; + + // Course members having the specified permission level. + case 'courses': + $courses = array_map( + fn ($course) => $course['id'], + $this->settings['courses']->getArrayCopy() + ); + $permission = $this->settings['perm']; + + $ids = DBManager::get()->fetchFirst( + "SELECT DISTINCT `user_id` FROM `seminar_user` WHERE `Seminar_id` IN (:courses) AND `status` = :perm", + ['courses' => $courses, 'perm' => $permission] + ); + + break; + + // Lecturers of at least one course in the given semester + case 'lecturers': + + $ids = DBManager::get()->fetchFirst( + "SELECT DISTINCT u.`user_id` FROM `seminar_user` u + LEFT JOIN `semester_courses` sc ON (sc.`course_id` = u.`Seminar_id`) + JOIN `seminare` s ON (s.`Seminar_id` = u.`Seminar_id`) + JOIN `sem_types` t ON (t.`id` = s.`status`) + WHERE (sc.`semester_id` = :semester OR sc.`semester_id` IS NULL) + AND t.`class` IN (:categories) + AND u.`status` = 'dozent'", + [ + 'semester' => $this->settings['semester'], + 'categories' => \Config::get()->MASSMAIL_LECTURER_SEM_CATEGORIES + ] + ); + + break; + + case 'usernames': + + $ids = DBManager::get()->fetchFirst( + "SELECT DISTINCT `user_id` FROM `auth_user_md5` WHERE `Username` IN (:usernames)", + ['usernames' => explode("\n", $this->settings['usernames'])] + ); + } + + return DBManager::get()->fetchFirst( + "SELECT DISTINCT `user_id` + FROM `auth_user_md5` + WHERE `visible` != :visible + AND `locked` = :locked + AND `user_id` IN (:ids) + AND `username` NOT IN (:exclude) + ORDER BY `username` + ", + [ + 'visible' => 'never', + 'locked' => 0, + 'ids' => $ids, + 'exclude' => $this->exclude_users ? explode("\n", $this->exclude_users) : [''] + ] + ); + } + + public function deleteUserWidgets(): self + { + $recipientIds = $this->getTargetUserIds(); + + WidgetUser::deleteBySQL( + 'pluginid = :plugin_id AND range_id IN (:user_ids)', + ['plugin_id' => $this->plugin_id, 'user_ids' => $recipientIds] + ); + + return $this; + } + + public function canEditFilter(User $user, UserFilter $filter): bool + { + return $GLOBALS['perm']->have_perm('root'); + } +} diff --git a/lib/models/MassWidget/MassWidgetFilter.php b/lib/models/MassWidget/MassWidgetFilter.php new file mode 100644 index 0000000..32c5dad --- /dev/null +++ b/lib/models/MassWidget/MassWidgetFilter.php @@ -0,0 +1,32 @@ +filter_id); + }; + $config['registered_callbacks']['before_delete'][] = 'deleteUserFilter'; + $config['registered_callbacks']['after_store'][] = 'updateUserFilterRange'; + + parent::configure($config); + } + + public function deleteUserFilter(): void + { + $filter = new UserFilter($this->filter_id); + $filter->delete(); + } + + public function updateUserFilterRange(): void + { + $filter = new UserFilter($this->filter_id); + $filter->setRange(MassWidget::class, $this->masswidget_id); + $filter->store(); + } +} diff --git a/lib/models/WidgetUser.php b/lib/models/WidgetUser.php index 84bb0c3..1b2b889 100644 --- a/lib/models/WidgetUser.php +++ b/lib/models/WidgetUser.php @@ -42,8 +42,8 @@ class WidgetUser extends SimpleORMap { if (self::countBySQL('range_id = ?', [$user_id]) === 0) { $stmt = DBManager::get()->prepare( - 'INSERT INTO widget_user (pluginid, position, range_id, col) - SELECT pluginid, position, :user_id, col FROM widget_default WHERE perm = :perm' + 'INSERT INTO widget_user (pluginid, position, range_id, col, chdate) + SELECT pluginid, position, :user_id, col, UNIX_TIMESTAMP() FROM widget_default WHERE perm = :perm' ); $stmt->execute([ 'user_id' => $user_id, @@ -61,7 +61,7 @@ class WidgetUser extends SimpleORMap */ public static function getWidgets($user_id): array { - $widgets = self::findBySQL('range_id = ? ORDER BY position', [$user_id]); + $widgets = self::findBySQL('is_active = 1 AND range_id = ? ORDER BY position', [$user_id]); $result = []; foreach ($widgets as $widget) { @@ -125,6 +125,6 @@ class WidgetUser extends SimpleORMap { self::setInitialWidgets($user_id); - return self::deleteBySQL('pluginid = ? AND range_id = ?', [$plugin_id, $user_id]); + return DBManager::get()->execute("UPDATE widget_user SET is_active = 0 WHERE range_id = ? AND pluginid = ?", [$user_id, $plugin_id]); } } diff --git a/resources/vue/components/StudipDialog.vue b/resources/vue/components/StudipDialog.vue index 8d56668..a52a93a 100644 --- a/resources/vue/components/StudipDialog.vue +++ b/resources/vue/components/StudipDialog.vue @@ -1,7 +1,7 @@ @@ -177,3 +178,9 @@ export default { } } + + -- cgit v1.0