From d1375e5f7b5d7543ec694df7c2f47b0a967f8951 Mon Sep 17 00:00:00 2001 From: Thomas Hackl <hackl@data-quest.de> Date: Mon, 25 Nov 2024 08:41:07 +0000 Subject: [PATCH] =?UTF-8?q?Resolve=20"Garuda=20in=20den=20Kern=20=C3=BCber?= =?UTF-8?q?nehmen"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #3326 Merge request studip/studip!3035 --- app/controllers/admin/courses.php | 23 + app/controllers/massmail/message.php | 394 ++++++++++++++++++ app/controllers/massmail/overview.php | 32 ++ app/controllers/massmail/permissions.php | 174 ++++++++ app/controllers/massmail/quick.php | 90 ++++ app/controllers/massmail/settings.php | 109 +++++ app/views/admin/courses/massmail.php | 10 + .../6.0.32_integrate_garuda_plugin.php | 285 +++++++++++++ .../ConditionalAdmission.php | 2 + .../PreferentialAdmission.php | 1 + lib/classes/JsonApi/RouteMap.php | 9 + .../JsonApi/Routes/MassMail/Authority.php | 30 ++ .../Routes/MassMail/MassMailMessagesIndex.php | 71 ++++ .../MassMail/MassMailPermissionsIndex.php | 31 ++ .../MassMail/MassMailPermissionsShow.php | 32 ++ .../JsonApi/Routes/UserFilters/Authority.php | 11 +- .../UserFilters/UserFilterFieldsIndex.php | 38 +- .../Routes/UserFilters/UserFiltersCreate.php | 8 +- .../Routes/UserFilters/UserFiltersDelete.php | 12 +- .../Routes/UserFilters/UserFiltersUpdate.php | 14 +- lib/classes/JsonApi/SchemaMap.php | 6 +- lib/classes/JsonApi/Schemas/Degree.php | 78 ++++ .../JsonApi/Schemas/MassMailMessage.php | 84 ++++ .../JsonApi/Schemas/MassMailPermission.php | 121 ++++++ lib/classes/{admission => }/UserFilter.php | 110 +++-- .../{admission => }/UserFilterField.php | 105 +++-- .../DatafieldCondition.php | 32 +- .../DegreeCondition.php | 7 +- .../UserFilterFields/DomainCondition.php | 45 ++ .../MassMail/MassMailDegreeFilter.php | 126 ++++++ .../MassMail/MassMailDomainFilter.php | 75 ++++ .../MassMail/MassMailGenderFilter.php | 74 ++++ .../MassMail/MassMailInstituteFilter.php | 140 +++++++ .../MassMail/MassMailPermissionFilter.php | 111 +++++ .../MassMailSelfAssignedInstituteFilter.php | 137 ++++++ .../MassMailSemesterOfStudyFilter.php | 75 ++++ .../MassMail/MassMailStatusgroupFilter.php | 88 ++++ .../MassMail/MassMailSubjectFilter.php | 125 ++++++ .../PermissionCondition.php | 7 +- .../SemesterOfStudyCondition.php | 11 +- .../StgteilVersionCondition.php | 17 +- .../SubjectCondition.php | 7 +- .../SubjectConditionAny.php | 6 +- lib/classes/UserFilterRange.php | 29 ++ lib/classes/admission/CourseSet.php | 46 +- lib/classes/forms/CheckboxCollectionInput.php | 25 ++ lib/classes/forms/Fieldset.php | 17 + lib/classes/forms/FileInput.php | 23 + lib/classes/forms/Form.php | 20 +- lib/classes/forms/QuicksearchListInput.php | 19 + lib/classes/forms/SerialWysiwygInput.php | 34 ++ lib/classes/forms/UserFilterInput.php | 60 +++ lib/cronjobs/send_massmails.php | 107 +++++ lib/models/MassMail/MassMailFilter.php | 34 ++ lib/models/MassMail/MassMailMarker.php | 181 ++++++++ lib/models/MassMail/MassMailMessage.php | 373 +++++++++++++++++ lib/models/MassMail/MassMailPermission.php | 139 ++++++ lib/models/MassMail/MassMailToken.php | 25 ++ lib/navigation/MessagingNavigation.php | 31 +- resources/vue/base-components.js | 4 + resources/vue/components/StudipUserFilter.vue | 20 +- resources/vue/components/StudipWysiwyg.vue | 2 + .../vue/components/form_inputs/FileUpload.vue | 198 +++++++++ .../form_inputs/QuicksearchListInput.vue | 97 +++++ .../form_inputs/SerialTextMarkers.vue | 80 ++++ .../form_inputs/UserFilterInput.vue | 146 +++++++ .../massmail/MassMailMessagesList.vue | 153 +++++++ .../massmail/MassMailPermissions.vue | 108 +++++ templates/forms/checkbox_collection_input.php | 33 ++ templates/forms/fieldset.php | 10 +- templates/forms/file_input.php | 11 + templates/forms/form.php | 1 + templates/forms/quicksearchlist_input.php | 18 + templates/forms/radio_input.php | 16 +- templates/forms/serial_wysiwyg_input.php | 17 + templates/forms/textarea_input.php | 28 +- templates/forms/user_filter_input.php | 19 + 77 files changed, 4928 insertions(+), 159 deletions(-) create mode 100644 app/controllers/massmail/message.php create mode 100644 app/controllers/massmail/overview.php create mode 100644 app/controllers/massmail/permissions.php create mode 100644 app/controllers/massmail/quick.php create mode 100644 app/controllers/massmail/settings.php create mode 100644 app/views/admin/courses/massmail.php create mode 100644 db/migrations/6.0.32_integrate_garuda_plugin.php create mode 100644 lib/classes/JsonApi/Routes/MassMail/Authority.php create mode 100644 lib/classes/JsonApi/Routes/MassMail/MassMailMessagesIndex.php create mode 100644 lib/classes/JsonApi/Routes/MassMail/MassMailPermissionsIndex.php create mode 100644 lib/classes/JsonApi/Routes/MassMail/MassMailPermissionsShow.php create mode 100644 lib/classes/JsonApi/Schemas/Degree.php create mode 100644 lib/classes/JsonApi/Schemas/MassMailMessage.php create mode 100644 lib/classes/JsonApi/Schemas/MassMailPermission.php rename lib/classes/{admission => }/UserFilter.php (71%) rename lib/classes/{admission => }/UserFilterField.php (79%) rename lib/classes/{admission/userfilter => UserFilterFields}/DatafieldCondition.php (81%) rename lib/classes/{admission/userfilter => UserFilterFields}/DegreeCondition.php (92%) create mode 100644 lib/classes/UserFilterFields/DomainCondition.php create mode 100644 lib/classes/UserFilterFields/MassMail/MassMailDegreeFilter.php create mode 100644 lib/classes/UserFilterFields/MassMail/MassMailDomainFilter.php create mode 100644 lib/classes/UserFilterFields/MassMail/MassMailGenderFilter.php create mode 100644 lib/classes/UserFilterFields/MassMail/MassMailInstituteFilter.php create mode 100644 lib/classes/UserFilterFields/MassMail/MassMailPermissionFilter.php create mode 100644 lib/classes/UserFilterFields/MassMail/MassMailSelfAssignedInstituteFilter.php create mode 100644 lib/classes/UserFilterFields/MassMail/MassMailSemesterOfStudyFilter.php create mode 100644 lib/classes/UserFilterFields/MassMail/MassMailStatusgroupFilter.php create mode 100644 lib/classes/UserFilterFields/MassMail/MassMailSubjectFilter.php rename lib/classes/{admission/userfilter => UserFilterFields}/PermissionCondition.php (90%) rename lib/classes/{admission/userfilter => UserFilterFields}/SemesterOfStudyCondition.php (89%) rename lib/classes/{admission/userfilter => UserFilterFields}/StgteilVersionCondition.php (84%) rename lib/classes/{admission/userfilter => UserFilterFields}/SubjectCondition.php (92%) rename lib/classes/{admission/userfilter => UserFilterFields}/SubjectConditionAny.php (88%) create mode 100644 lib/classes/UserFilterRange.php create mode 100644 lib/classes/forms/CheckboxCollectionInput.php create mode 100644 lib/classes/forms/FileInput.php create mode 100644 lib/classes/forms/QuicksearchListInput.php create mode 100644 lib/classes/forms/SerialWysiwygInput.php create mode 100644 lib/classes/forms/UserFilterInput.php create mode 100644 lib/cronjobs/send_massmails.php create mode 100644 lib/models/MassMail/MassMailFilter.php create mode 100644 lib/models/MassMail/MassMailMarker.php create mode 100644 lib/models/MassMail/MassMailMessage.php create mode 100644 lib/models/MassMail/MassMailPermission.php create mode 100644 lib/models/MassMail/MassMailToken.php create mode 100644 resources/vue/components/form_inputs/FileUpload.vue create mode 100644 resources/vue/components/form_inputs/QuicksearchListInput.vue create mode 100644 resources/vue/components/form_inputs/SerialTextMarkers.vue create mode 100644 resources/vue/components/form_inputs/UserFilterInput.vue create mode 100644 resources/vue/components/massmail/MassMailMessagesList.vue create mode 100644 resources/vue/components/massmail/MassMailPermissions.vue create mode 100644 templates/forms/checkbox_collection_input.php create mode 100644 templates/forms/file_input.php create mode 100644 templates/forms/quicksearchlist_input.php create mode 100644 templates/forms/serial_wysiwyg_input.php create mode 100644 templates/forms/user_filter_input.php diff --git a/app/controllers/admin/courses.php b/app/controllers/admin/courses.php index 3d63cd9f3e0..890a9ab0b90 100644 --- a/app/controllers/admin/courses.php +++ b/app/controllers/admin/courses.php @@ -487,6 +487,16 @@ class Admin_CoursesController extends AuthenticatedController 'data-dialog' => 'size=big' ]); break; + case 23: // Mass mail to selected courses + $data['buttons_top'] = '<label>' . _('Alle auswählen') . + '<input type="checkbox" data-proxyfor=".course-admin td:last-child :checkbox"></label>'; + $data['buttons_bottom'] = (string) \Studip\Button::createAccept( + _('Nachricht an ausgewählte Veranstaltungen'), 'massmail', + [ + 'formaction' => URLHelper::getURL('dispatch.php/massmail/quick/courses'), + 'data-dialog' => 'size=big' + ]); + break; default: foreach (PluginManager::getInstance()->getPlugins(AdminCourseAction::class) as $plugin) { if ($GLOBALS['user']->cfg->MY_COURSES_ACTION_AREA === get_class($plugin)) { @@ -837,6 +847,11 @@ class Admin_CoursesController extends AuthenticatedController $template->course = $course; $d['action'] = $template->render(); break; + case 23: //Masssenexport Teilnehmendendaten + $template = $tf->open('admin/courses/massmail'); + $template->course = $course; + $d['action'] = $template->render(); + break; default: foreach (PluginManager::getInstance()->getPlugins(AdminCourseAction::class) as $plugin) { if ($GLOBALS['user']->cfg->MY_COURSES_ACTION_AREA === get_class($plugin)) { @@ -1435,6 +1450,14 @@ class Admin_CoursesController extends AuthenticatedController 'partial' => 'batch_export_members.php' ], + 23 => [ + 'name' => _('Nachricht schreiben'), + 'title' => _('Nachricht schreiben'), + 'url' => 'dispatch.php/massmail/quick/courses', + 'dialogform' => true, + 'multimode' => true, + 'partial' => 'massmail.php' + ] ]; if (!$GLOBALS['perm']->have_perm('admin')) { diff --git a/app/controllers/massmail/message.php b/app/controllers/massmail/message.php new file mode 100644 index 00000000000..3f7a009d4c3 --- /dev/null +++ b/app/controllers/massmail/message.php @@ -0,0 +1,394 @@ +<?php + +class Massmail_MessageController extends \AuthenticatedController +{ + + public function before_filter(&$action, &$args) + { + parent::before_filter($action, $args); + + if (!\MassMail\MassMailPermission::has(User::findCurrent()->id)) { + throw new AccessDeniedException(); + } + } + + public function index_action($id = null) + { + Navigation::activateItem('/messaging/massmail/message'); + PageLayout::setTitle(_('Nachricht an Zielgruppe schreiben')); + + $message = new \MassMail\MassMailMessage($id); + + $temp_id = $id ?: uniqid(md5(time())); + $folder = $message->findFolder($temp_id); + + // SearchType needed for course selection + $courseSearch = new StandardSearch('Seminar_id'); + + // SearchType needed for user + $userSearch = new StandardSearch('user_id'); + + $form = \Studip\Forms\Form::fromSORM( + $message, + [ + 'legend' => _('Grunddaten'), + 'collapsed' => false, + 'collapsable' => false, + 'fields' => [ + 'target' => [ + 'type' => 'select', + 'required' => true, + 'label' => _('Zielgruppe'), + 'value' => $message->target ?? 'all', + 'options' => \MassMail\MassMailMessage::getTargets() + ], + 'student_filters' => [ + 'type' => 'userFilter', + 'label' => _('Auswahlfilter'), + 'if' => 'target === "students"', + 'context' => 'MassMail', + 'target' => 'students', + ':key' => 'NaN', + 'store' => function($value, $input) { + $filters = []; + foreach ($value as $one) { + $filter = new UserFilter($one['id'] ?? ''); + $filter->fields = []; + foreach ($one['attributes']['fields'] as $field) { + $classname = $field['attributes']['type']; + $f = new $classname(); + if (!empty($fiele['id'])) { + $f->setId($field['id']); + } + $f->setCompareOperator($field['attributes']['compare-operator']); + $f->setValue($field['attributes']['value']); + $filter->addField($f); + } + $filter->store(); + $connection = new \MassMail\MassMailFilter(); + $connection->filter_id = $filter->getId(); + $filters[] = $connection; + } + $input->getContextObject()->filters = $filters; + } + ], + 'employee_filters' => [ + 'type' => 'userFilter', + 'label' => _('Auswahlfilter'), + 'if' => 'target === "employees"', + 'context' => 'MassMail', + 'target' => 'employees', + ':key' => 'NaN', + 'store' => function($value, $input) { + $filters = []; + foreach ($value as $one) { + $filter = new UserFilter($one['id'] ?? ''); + $filter->fields = []; + foreach ($one['attributes']['fields'] as $field) { + $classname = $field['attributes']['type']; + $f = new $classname(); + if (!empty($fiele['id'])) { + $f->setId($field['id']); + } + $f->setCompareOperator($field['attributes']['compare-operator']); + $f->setValue($field['attributes']['value']); + $filter->addField($f); + } + $filter->store(); + $connection = new \MassMail\MassMailFilter(); + $connection->filter_id = $filter->getId(); + $filters[] = $connection; + } + $input->getContextObject()->filters = $filters; + } + ], + 'semester' => [ + 'type' => 'select', + 'label' => _('Semester wählen'), + 'value' => $message->config['semester'] ?? \Semester::findDefault()->id, + 'if' => 'target === "lecturers"', + 'options' => \MassMail\MassMailMessage::getSemesters(), + 'store' => function($value, $input) { + if ($input->getContextObject()->target === 'lecturers') { + $input->getContextObject()->config = ['semester' => $value]; + } + } + ], + 'courses' => [ + 'type' => 'quicksearchList', + 'label' => _('Veranstaltungen wählen'), + 'value' => json_encode($message->config?->getArrayCopy()['courses'] ?? []), + 'if' => 'target === "courses"', + 'searchtype' => $courseSearch, + 'store' => function($value, $input) { + if ($input->getContextObject()->target === 'courses') { + $input->getContextObject()->config = []; + $input->getContextObject()->config['courses'] = \Course::findAndMapMany( + function ($course) { + return ['id' => $course->id, 'name' => $course->getFullname()]; + }, + json_decode($value, true) + ); + } + } + ], + 'course_perm' => [ + 'type' => 'select', + 'label' => _('Berechtigungsebene wählen'), + 'value' => $message->config['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()->config['perm'] = $value; + } + } + ], + 'manual_usernames' => [ + 'type' => 'textarea', + 'label' => _('Liste von Benutzernamen, durch Zeilenumbruch getrennt'), + 'if' => 'target === "usernames"', + 'value' => $message->config['usernames'] ?? '', + 'store' => function($value, $input) { + if ($input->getContextObject()->target === 'usernames') { + $input->getContextObject()->config = []; + $input->getContextObject()->config['usernames'] = $value; + } + } + ], + 'subject' => [ + 'type' => 'text', + 'required' => true, + 'label' => _('Betreff'), + 'value' => $message->subject + ], + 'message' => [ + 'type' => 'serialWysiwyg', + 'required' => true, + 'label' => _('Nachricht'), + 'value' => $message->message, + 'markers' => json_encode( + array_map( + fn ($m) => $m->toArray(), + \MassMail\MassMailMarker::findAll( + \MassMail\MassMailPermission::has(User::findCurrent()->id, true) + ) + ) + ) + ] + ] + ], + $this->url_for('massmail/overview') + )->addSORM($message, [ + 'legend' => _('Weitere Einstellungen'), + 'collapsable' => true, + 'collapsed' => true, + 'fields' => [ + 'author_id' => [ + 'type' => 'hidden', + 'value' => User::findCurrent()->id + ], + 'attachments' => [ + 'type' => 'file', + 'label' => _('Dateianhänge auswählen'), + 'value' => $message->folder_id ?? $message->folder_id = $folder->id, + 'upload_url' => $this->url_for('massmail/message/attachments', $folder->id), + 'multiple' => true, + 'if' => $GLOBALS['ENABLE_EMAIL_ATTACHMENTS'] + ? 'true' : 'false', + 'store' => function($value, $input) { + $input->getContextObject()->folder_id = $value; + } + ], + 'tokens' => [ + 'type' => 'file', + 'label' => _('CSV mit Teilnahmecodes auswählen'), + 'value' => $message->folder_id ?? $message->folder_id = $folder->id, + 'upload_url' => $this->url_for('massmail/message/tokens', $message->folder_id), + 'accept' => '.csv,.txt', + 'if' => \MassMail\MassMailPermission::has(User::findCurrent()->id, true) + ? 'true' : 'false', + 'store' => function($value, $input) { + $input->getContextObject()->folder_id = $value; + } + ], + 'send_at_date' => [ + 'type' => 'datetimepicker', + 'label' => _('Zu einem späteren Zeitpunkt senden'), + 'value' => $message->send_at_date ?? time() + ], + 'send_as' => [ + 'type' => 'select', + 'label' => ('Nachricht senden als'), + 'value' => $message->sender_id ?? User::findCurrent()->id, + 'if' => \MassMail\MassMailPermission::has(User::findCurrent()->id, true) + ? 'true' : 'false', + 'options' => [ + User::findCurrent()->id => _('Von meiner Kennung verschicken'), + 'user_id' => _('Eine andere Person eintragen'), + '____%system%____' => _('Anonym, mit "Stud.IP" als Absender') + ], + 'store' => function($value, $input) { + if ($value === User::findCurrent()->id || $value === '____%system%____') { + $input->getContextObject()->sender_id = $value; + } + } + ], + 'sender_id' => [ + 'type' => 'quicksearch', + 'label' => _('Absender:in wählen'), + 'value' => $message->sender_id ?? '', + 'if' => 'send_as === "user_id"', + 'searchtype' => $userSearch, + 'store' => function($value, $input) { + $sender_id = $input->getContextObject()->sender_id; + if ($sender_id !== User::findCurrent()->id && $sender_id !== '____%system%____') { + $input->sender_id = $value; + } + } + ], + 'exclude_users' => [ + 'type' => 'textarea', + 'label' => _('Liste von Benutzernamen, die die Nachricht nicht erhalten sollen'), + 'value' => $message->exclude_users ?? '' + ], + 'cc' => [ + 'type' => 'textarea', + 'label' => _('Liste von Benutzernamen, die die Nachricht als Kopie erhalten sollen'), + 'value' => $message->cc ?? '' + ], + 'flags' => [ + 'type' => 'radio', + 'label' => _('Besondere Kennzeichnung'), + 'value' => $message->is_template + ? 'is_template' + : ($message->protected ? 'protected' : ''), + 'options' => [ + '' => _('Keine besondere Kennzeichnung'), + 'is_template' => _('Nicht verschicken, sondern als Vorlage speichern'), + 'protected' => _('Auch nach dem Versand dauerhaft speichern') + ], + 'store' => function($value, $input) { + switch ($value) { + case 'is_template': + $input->getContextObject()->is_template = 1; + $input->getContextObject()->protected = 0; + break; + case 'protected': + $input->getContextObject()->is_template = 0; + $input->getContextObject()->protected = 1; + break; + default: + $input->getContextObject()->is_template = 0; + $input->getContextObject()->protected = 0; + break; + } + } + ] + ] + ])->addStoreCallback(function ($form) { + $message = $form->getLastPart()->getContextObject(); + + // Adjust folder range_id to the actual message id. + $folder = Folder::find($message->folder_id); + $folder->range_id = $message->id; + $folder->store(); + + // Create message tokens if necessary. + if ($message->hasMarkers('token')) { + foreach ($folder->getTypedFolder()->getFiles() as $ref) { + if (isset($ref->file->metadata['is_token_file'])) { + $file = fopen($ref->file->getPath(), 'r'); + while (!feof($file)) { + $token = fgets($file); + $t = new \MassMail\MassMailToken(); + $t->message_id = $message->id; + $t->token = $token; + $t->store(); + } + } + } + } + })->autoStore(); + + $this->render_form($form); + } + + public function delete_action(int $id) + { + $message = \MassMail\MassMailMessage::find($id); + + if ( + !$message + || ( + $message->author_id !== User::findCurrent()->id + && !\MassMail\MassMailPermission::has(User::findCurrent()->id, true) + ) + ) { + throw new AccessDeniedException(); + } + + if ($message->delete() !== false) { + PageLayout::postSuccess(_('Die Nachricht wurde gelöscht.')); + } else { + PageLayout::postError(_('Die Nachricht konnte nicht gelöscht werden.')); + } + + $this->relocate('massmail/overview'); + } + + public function attachments_action(string $folder_id) + { + if (!$GLOBALS['ENABLE_EMAIL_ATTACHMENTS']) { + throw new AccessDeniedException(); + } + + $folder = Folder::find($folder_id)->getTypedFolder(); + $uploaded = FileManager::handleFileUpload($_FILES['attachments'], $folder); + + if (!empty($uploaded['error'])) { + $this->set_status(400); + $this->render_text(implode('<br>' . $uploaded['error'])); + } else { + $this->render_nothing(); + } + } + + public function tokens_action(string $folder_id) + { + if (!\MassMail\MassMailPermission::has(User::findCurrent()->id, true)) { + throw new AccessDeniedException(); + } + + $data = [ + 'name' => [$_FILES['tokens']['name']], + 'tmp_name' => [$_FILES['tokens']['tmp_name']], + 'type' => [$_FILES['tokens']['type']], + 'error' => [$_FILES['tokens']['error']], + 'size' => [$_FILES['tokens']['size']], + ]; + + $folder = Folder::find($folder_id)->getTypedFolder(); + $uploaded = FileManager::handleFileUpload($data, $folder); + + if (!empty($uploaded['error'])) { + $this->set_status(400); + $this->render_text(implode('<br>' . $uploaded['error'])); + } else { + + // Set metadata for created file, indicating that this is a file with message tokens. + foreach ($uploaded['files'] as $ref) { + $ref->file->metadata = ['is_token_file' => true]; + $ref->file->store(); + } + + $this->render_nothing(); + } + } + +} diff --git a/app/controllers/massmail/overview.php b/app/controllers/massmail/overview.php new file mode 100644 index 00000000000..db71d35f8df --- /dev/null +++ b/app/controllers/massmail/overview.php @@ -0,0 +1,32 @@ +<?php + +class Massmail_OverviewController extends \AuthenticatedController +{ + + public function before_filter(&$action, &$args) + { + parent::before_filter($action, $args); + + if (!\MassMail\MassMailPermission::has(User::findCurrent()->id)) { + throw new AccessDeniedException(); + } + + Navigation::activateItem('/messaging/massmail/overview'); + + Sidebar::Get()->addWidget(new VueWidget('message-views')); + + $this->render_vue_app( + Studip\VueApp::create('massmail/MassMailMessagesList') + ); + } + + public function index_action($id = null) + { + PageLayout::setTitle(_('Nachrichten')); + + $this->render_vue_app( + Studip\VueApp::create('massmail/MassMailMessagesList') + ); + } + +} diff --git a/app/controllers/massmail/permissions.php b/app/controllers/massmail/permissions.php new file mode 100644 index 00000000000..bd8200bf642 --- /dev/null +++ b/app/controllers/massmail/permissions.php @@ -0,0 +1,174 @@ +<?php + +class Massmail_PermissionsController extends \AuthenticatedController +{ + + public function before_filter(&$action, &$args) + { + parent::before_filter($action, $args); + + if (!\MassMail\MassMailPermission::has(User::findCurrent()->id, true)) { + throw new AccessDeniedException(); + } + + Navigation::activateItem('/messaging/massmail/permissions'); + } + + /** + * Lists all existing permissions. + * @return void + * @throws AccessDeniedException + */ + public function index_action() + { + PageLayout::setTitle(_('Berechtigungen für den Nachrichtenversand an Zielgruppen')); + + $this->permissions = \MassMail\MassMailPermission::findBySQL("1"); + usort( + $this->permissions, + fn ($a, $b) => strnatcasecmp($a->institute_name, $b->institute_name) + ); + + $sidebar = Sidebar::Get(); + $actions = new ActionsWidget(); + $actions->addLink( + _('Neue Berechtigung vergeben'), + $this->url_for('massmail/permissions/edit'), + Icon::create('add'), + )->asDialog('size=medium'); + $sidebar->addWidget($actions); + + $this->render_vue_app( + Studip\VueApp::create('massmail/MassMailPermissions') + ); + } + + /** + * Provides a form for entering or editing a massmail permission. + * @param int $id which permission to edit, create a new one if $id is 0 + * @return void + * @throws AccessDeniedException + */ + public function edit_action(int $id = 0) + { + $permission = new \MassMail\MassMailPermission($id); + + PageLayout::setTitle( + $permission->isNew() + ? _('Berechtigung erstellen') + : _('Berechtigung bearbeiten') + ); + + $institutes = []; + foreach (Institute::getInstitutes() as $one) { + $institutes[$one['Institut_id']] = $one['Name']; + } + + $degrees = []; + foreach (Degree::findBySQL("1 ORDER BY `name`") as $one) { + $degrees[$one->id] = $one->name; + } + + $subjects = []; + foreach (StudyCourse::findBySQL("1 ORDER BY `name`") as $one) { + $subjects[$one->id] = $one->name; + } + + $form = \Studip\Forms\Form::fromSORM( + $permission, + [ + 'fields' => [ + 'institute_id' => [ + 'type' => 'select', + 'required' => true, + 'label' => _('Einrichtung'), + 'value' => $permission->institute_id, + 'options' => $institutes + ], + 'min_perm' => [ + 'type' => 'select', + 'required' => true, + 'label' => _('Benötigte Rechte'), + 'value' => $permission->min_perm, + 'options' => [ + 'admin' => 'admin', + 'dozent' => 'dozent', + 'tutor' => 'tutor' + ] + ], + 'allowed_degrees' => [ + 'type' => 'checkboxCollection', + 'collapsable' => true, + 'label' => _('Erlaubte Abschlüsse'), + 'value' => $permission->allowed_degrees->pluck('id'), + 'options' => $degrees + ], + 'allowed_subjects' => [ + 'type' => 'checkboxCollection', + 'collapsable' => true, + 'label' => _('Erlaubte Fächer'), + 'value' => $permission->allowed_subjects->pluck('id'), + 'options' => $subjects + ], + 'allowed_institutes' => [ + 'type' => 'checkboxCollection', + 'collapsable' => true, + 'label' => _('Erlaubte Einrichtungen (außer den eigenen)'), + 'value' => $permission->allowed_institutes->pluck('id'), + 'options' => $institutes + ] + ] + ] + )->setURL($this->url_for('massmail/permissions/store', $id)); + + $this->render_form($form); + } + + /** + * Stores permission data by editing an existing or creating a new one. + * @param int $id the permission to store + * @return void + * @throws AccessDeniedException + */ + public function store_action(int $id = 0) + { + CSRFProtection::verifyUnsafeRequest(); + $permission = new \MassMail\MassMailPermission($id); + $permission->institute_id = Request::option('institute_id'); + $permission->min_perm = Request::get('min_perm'); + $permission->allowed_degrees = Degree::findMany(Request::optionArray('allowed_degrees')); + $permission->allowed_subjects = StudyCourse::findMany(Request::optionArray('allowed_subjects')); + $permission->allowed_institutes = Institute::findMany(Request::optionArray('allowed_institutes')); + + if ($permission->store() !== false) { + PageLayout::postSuccess('Die Daten wurden gespeichert.'); + } else { + PageLayout::postError('Die Daten konnten nicht gespeichert werden.'); + } + + $this->relocate('massmail/permissions'); + } + + /** + * Deletes the given permission entry. + * @param int $id the permission to delete + * @return void + * @throws AccessDeniedException + */ + public function delete_action(int $id) + { + $permission = \MassMail\MassMailPermission::find($id); + if ($permission) { + if ($permission->delete()) { + PageLayout::postSuccess(_('Die Berechtigung wurde gelöscht.')); + } else { + PageLayout::postError(_('Die Berechtigung konnte nicht gelöscht werden.')); + } + } else { + PageLayout::postError(_('Die Berechtigung wurde nicht gefunden.')); + } + + $this->relocate('massmail/permissions'); + } + +} diff --git a/app/controllers/massmail/quick.php b/app/controllers/massmail/quick.php new file mode 100644 index 00000000000..b7cdf94400b --- /dev/null +++ b/app/controllers/massmail/quick.php @@ -0,0 +1,90 @@ +<?php + +/** + * quick.php Controller for quick creation of massmails to selected courses. + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License as + * published by the Free Software Foundation; either version 2 of + * the License, or (at your option) any later version. + * + * @author Thomas Hackl + * @license GPL2 or any later version + * @since Stud.IP 6.0 + */ + +class Massmail_QuickController extends \AuthenticatedController +{ + + public function courses_action() + { + $GLOBALS['perm']->check('admin'); + + Navigation::activateItem('/messaging/massmail/message'); + PageLayout::setTitle(_('Nachricht an Zielgruppe schreiben')); + + $message = new \MassMail\MassMailMessage(); + $message->target = 'courses'; + $message->sender_id = $message->author_id = User::findCurrent()->id; + $message->config = ['perm' => 'autor', 'courses' => Request::optionArray('courses')]; + + $courses = Request::optionArray('courses'); + + $form = \Studip\Forms\Form::fromSORM( + $message, + [ + 'legend' => _('Grunddaten'), + 'collapsed' => false, + 'collapsable' => false, + 'fields' => [ + 'courses' => [ + 'type' => 'hidden', + 'value' => implode(',', $courses), + 'store' => function($value, $input) { + $input->getContextObject()->config = []; + $input->getContextObject()->config['courses'] = explode(',', $value); + } + ], + 'course_perm' => [ + 'type' => 'select', + 'label' => _('Berechtigungsebene wählen'), + 'value' => 'autor', + '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) { + $input->getContextObject()->config['perm'] = $value; + } + ], + 'subject' => [ + 'type' => 'text', + 'required' => true, + 'label' => _('Betreff'), + 'value' => $message->subject + ], + 'message' => [ + 'type' => 'serialWysiwyg', + 'required' => true, + 'label' => _('Nachricht'), + 'value' => $message->message, + 'markers' => json_encode( + array_map( + fn ($m) => $m->toArray(), + \MassMail\MassMailMarker::findAll( + \MassMail\MassMailPermission::has(User::findCurrent()->id, true) + ) + ) + ) + ] + ] + ], + $this->url_for('admin/courses') + )->autoStore(); + + $this->render_form($form); + } + +} diff --git a/app/controllers/massmail/settings.php b/app/controllers/massmail/settings.php new file mode 100644 index 00000000000..1b6c626454a --- /dev/null +++ b/app/controllers/massmail/settings.php @@ -0,0 +1,109 @@ +<?php + +class Massmail_SettingsController extends \AuthenticatedController +{ + + public function before_filter(&$action, &$args) + { + parent::before_filter($action, $args); + + if (!\MassMail\MassMailPermission::has(User::findCurrent()->id, true)) { + throw new AccessDeniedException(); + } + + Navigation::activateItem('/messaging/massmail/settings'); + } + + /** + * Lists all existing permissions. + * @return void + * @throws AccessDeniedException + */ + public function index_action() + { + PageLayout::setTitle(_('Einstellungen für den Nachrichtenversand an Zielgruppen')); + + $categories = []; + foreach (SemClass::getClasses() as $class) { + $categories[$class['id']] = $class['name']; + } + + $form = \Studip\Forms\Form::create(); + $form->setURL($this->url_for('massmail/settings/store')); + $config = new \Studip\Forms\Fieldset(_('Konfiguration')); + $config->addInput( + new \Studip\Forms\NumberInput( + 'cleanup', + _('Anzahl Tage, nach denen bereits verschickte Nachrichten gelöscht werden (0 bedeutet nie)'), + Config::get()->MASSMAIL_GC_DAYS, + ['min' => 0] + ) + ); + $form->addPart($config); + + $form->addInput( + new \Studip\Forms\CheckboxCollectionInput( + 'categories', + _('Veranstaltungskategorien, die für die Ermittlung aktiver Lehrender berücksichtigt werden'), + Config::get()->MASSMAIL_LECTURER_SEM_CATEGORIES, + ['options' => $categories] + ) + ); + + $task = CronjobTask::findOneByClass(SendMassmailsJob::class); + $job = CronjobSchedule::findOneByTask_id($task->id); + + $cron = new \Studip\Forms\Fieldset(_('Cronjob')); + + if (!$task->active || !$job->active) { + $cron->addInput( + new \Studip\Forms\InfoInput( + 'inactive', + _('Achtung: Kein Versand'), + _('Der automatische Versand ist nicht aktiviert!') + ) + ); + } + + $cron->addInput( + new \Studip\Forms\NumberInput( + 'minutes', + _('Abstand des Versands anstehender Nachrichten in Minuten'), + abs($job->minute), + ['min' => 1, 'max' => 59] + ) + ); + $form->addPart($cron); + + $this->render_form($form); + } + + /** + * Stores the global massmail settings.. + * @return void + * @throws AccessDeniedException + */ + public function store_action() + { + CSRFProtection::verifyUnsafeRequest(); + + Config::get()->store( + 'MASSMAIL_GC_DAYS', + Request::int('cleanup', 7) + ); + Config::get()->store( + 'MASSMAIL_LECTURER_SEM_CATEGORIES', + Request::intArray('categories') + ); + + $task = CronjobTask::findOneByClass(SendMassmailsJob::class); + $job = CronjobSchedule::findOneByTask_id($task->id); + $job->minute = -1 * abs(Request::int('minutes')); + $job->store(); + + PageLayout::postSuccess('Die Einstellungen wurden gespeichert.'); + + $this->relocate('massmail/settings'); + } + +} diff --git a/app/views/admin/courses/massmail.php b/app/views/admin/courses/massmail.php new file mode 100644 index 00000000000..e871040ee54 --- /dev/null +++ b/app/views/admin/courses/massmail.php @@ -0,0 +1,10 @@ +<?php +/** + * @var Course $course + */ +?> +<label> + <input name="courses[]" type="checkbox" value="<?= htmlReady($course->id) ?>" + aria-label="<?= htmlReady(sprintf(_('Nachricht an Teilnehmende der Veranstaltung %s senden'), + $course->getFullName())) ?>"> +</label> diff --git a/db/migrations/6.0.32_integrate_garuda_plugin.php b/db/migrations/6.0.32_integrate_garuda_plugin.php new file mode 100644 index 00000000000..2e0861476a6 --- /dev/null +++ b/db/migrations/6.0.32_integrate_garuda_plugin.php @@ -0,0 +1,285 @@ +<?php +require_once 'lib/models/MassMail/MassMailPermission.php'; +require_once 'lib/cronjobs/send_massmails.php'; + +return new class extends Migration +{ + use DatabaseMigrationTrait; + + public function description() + { + return 'Integrate the functionality from the Garuda plugin into the Stud.IP core.'; + } + + protected function up() + { + // Messages and templates + DBManager::get()->exec("CREATE TABLE IF NOT EXISTS `massmail_messages` ( + `message_id` INT NOT NULL AUTO_INCREMENT, + `sender_id` CHAR(32) COLLATE latin1_bin, + `author_id` CHAR(32) COLLATE latin1_bin NOT NULL, + `send_at_date` INT, + `target` ENUM('all', 'students', 'employees', 'lecturers', 'courses', 'usernames') COLLATE latin1_bin, + `config` LONGTEXT, + `exclude_users` LONGTEXT, + `cc` TEXT, + `subject` VARCHAR(255) NOT NULL, + `message` TEXT NOT NULL, + `folder_id` CHAR(32) COLLATE latin1_bin, + `is_template` TINYINT(1) NOT NULL DEFAULT 0, + `locked` TINYINT(1) NOT NULL DEFAULT 0, + `sent` TINYINT(1) NOT NULL DEFAULT 0, + `protected` TINYINT(1) NOT NULL DEFAULT 0, + `mkdate` INT UNSIGNED NOT NULL, + `chdate` INT UNSIGNED NOT NULL, + PRIMARY KEY (`message_id`), + INDEX author_id (`author_id`) + )"); + + // Permissions for using this functionality + DBManager::get()->exec("CREATE TABLE IF NOT EXISTS `massmail_permissions` ( + `permission_id` INT NOT NULL AUTO_INCREMENT, + `institute_id` CHAR(32) COLLATE latin1_bin NOT NULL, + `min_perm` ENUM ('admin', 'dozent', 'tutor', 'autor') COLLATE latin1_bin NOT NULL DEFAULT 'admin', + `mkdate` INT UNSIGNED NOT NULL, + `chdate` INT UNSIGNED NOT NULL, + PRIMARY KEY (`permission_id`), + UNIQUE INDEX institute_id (`institute_id`) + )"); + + // Allowed degrees + DBManager::get()->exec("CREATE TABLE IF NOT EXISTS `massmail_permission_degree` ( + `permission_id` INT NOT NULL, + `degree_id` CHAR(32) COLLATE latin1_bin NOT NULL, + `mkdate` INT UNSIGNED NOT NULL, + PRIMARY KEY (`permission_id`, `degree_id`), + INDEX degree_id (`degree_id`) + )"); + + // Allowed subjects of study + DBManager::get()->exec("CREATE TABLE IF NOT EXISTS `massmail_permission_subject` ( + `permission_id` INT NOT NULL, + `subject_id` CHAR(32) COLLATE latin1_bin NOT NULL, + `mkdate` INT UNSIGNED NOT NULL, + PRIMARY KEY (`permission_id`, `subject_id`), + INDEX subject_id (`subject_id`) + )"); + + // Allowed institutes + DBManager::get()->exec("CREATE TABLE IF NOT EXISTS `massmail_permission_institute` ( + `permission_id` INT NOT NULL, + `institute_id` CHAR(32) COLLATE latin1_bin NOT NULL, + `mkdate` INT UNSIGNED NOT NULL, + PRIMARY KEY (`permission_id`, `institute_id`), + INDEX institute_id (`institute_id`) + )"); + + // User filters + DBManager::get()->exec("CREATE TABLE IF NOT EXISTS `massmail_filter` ( + `message_id` INT NOT NULL, + `filter_id` CHAR(32) COLLATE latin1_bin NOT NULL, + `mkdate` INT UNSIGNED NOT NULL, + PRIMARY KEY (`message_id`, `filter_id`), + INDEX filter_id (`filter_id`) + )"); + + // User-specific tokens + DBManager::get()->exec("CREATE TABLE IF NOT EXISTS `massmail_tokens` ( + `token_id` INT NOT NULL AUTO_INCREMENT, + `message_id` INT NOT NULL, + `user_id` CHAR(32) COLLATE latin1_bin, + `token` VARCHAR(1024) NOT NULL, + `mkdate` INT UNSIGNED NOT NULL, + PRIMARY KEY (`token_id`), + INDEX message_id (`message_id`) + )"); + + // Serial mail markers + DBManager::get()->exec("CREATE TABLE IF NOT EXISTS `massmail_markers` ( + `marker_id` INT NOT NULL AUTO_INCREMENT, + `marker` VARCHAR(255) NOT NULL, + `name` VARCHAR(255) NOT NULL, + `type` ENUM('text', 'database', 'function', 'token') COLLATE latin1_bin, + `description` TEXT, + `root_only` TINYINT(1) UNSIGNED DEFAULT 0, + `replacement` TEXT, + `replacement_female` TEXT, + `replacement_unknown` TEXT, + `position` TINYINT(1) UNSIGNED, + `mkdate` INT UNSIGNED NOT NULL, + `chdate` INT UNSIGNED NOT NULL, + PRIMARY KEY (`marker_id`) + )"); + + $markers = [ + [ + 'marker' => 'FULLNAME', + 'name' => 'Voller Name', + 'type' => 'database', + 'description' => _('Hier wird der volle Name der jeweiligen Person eingesetzt, z.B. "Prof. Max Mustermann, PhD".'), + 'replacement' => 'user_info.title_front {{FIRSTNAME}} {{LASTNAME}} user_info.title_rear', + 'position' => 2 + ], + [ + 'marker' => 'FIRSTNAME', + 'name' => 'Vorname', + 'type' => 'database', + 'description' => _('Hier wird der Vorname der jeweiligen Person eingesetzt.'), + 'replacement' => 'auth_user_md5.Vorname', + 'position' => 3 + ], + [ + 'marker' => 'LASTNAME', + 'name' => 'Nachname', + 'type' => 'database', + 'description' => _('Hier wird der Nachname der jeweiligen Person eingesetzt.'), + 'replacement' => 'auth_user_md5.Nachname', + 'position' => 4 + ], + [ + 'marker' => 'USERNAME', + 'name' => 'Benutzername', + 'type' => 'database', + 'description' => _('Hier wird der Benutzername der jeweiligen Person eingesetzt.'), + 'replacement' => 'auth_user_md5.username', + 'position' => 5 + ], + [ + 'marker' => 'SEHRGEEHRTE', + 'name' => 'Anrede mit vollem Namen', + 'type' => 'text', + 'description' => _('Hier wird eine Anrede erzeugt: "Sehr geehrte Michaela Musterfrau" bzw. "Sehr geehrter Max Mustermann".'), + 'replacement' => 'Sehr geehrter {{FULLNAME}}', + 'replacement_female' => 'Sehr geehrte {{FULLNAME}}', + 'replacement_unknown' => 'Sehr geehrte/r {{FULLNAME}}', + 'position' => 1 + ], + [ + 'marker' => 'DEARSIRMADAM', + 'name' => 'Anrede (englisch) mit vollem Namen', + 'type' => 'text', + 'description' => _('Creates a Salutation: "Dear Jane Doe" or "Dear John Doe".'), + 'replacement' => 'Dear {{FULLNAME}}', + 'position' => 6 + ], + [ + 'marker' => 'TOKEN', + 'name' => 'Personalisierter Code o.ä.', + 'type' => 'token', + 'description' => _('Hier wird ein persönlicher Teilnahmecode o.ä. aus einer hochgeladenen Datei eingesetzt.'), + 'replacement' => 'massmail_tokens.token', + 'root_only' => 1, + 'position' => 7 + ] + ]; + + foreach ($markers as $data) { + \MassMail\MassMailMarker::create($data); + } + + if (empty(RolePersistence::getRoleIdByName(\MassMail\MassMailPermission::MASSMAIL_ROOT_ROLE))) { + RolePersistence::saveRole( + new Role(Role::UNKNOWN_ROLE_ID, \MassMail\MassMailPermission::MASSMAIL_ROOT_ROLE) + ); + } + + DBManager::get()->exec("INSERT IGNORE INTO `config` + (`field`, `value`, `type`, `range`, `section`, `mkdate`, `chdate`, `description`) + VALUES + ( + 'MASSMAIL_LECTURER_SEM_CATEGORIES', + '[1]', + 'array', + 'global', + 'MassMail', + UNIX_TIMESTAMP(), + UNIX_TIMESTAMP(), + 'Veranstaltungskategorien, die für die Ermittlung aktiver Lehrender berücksichtigt werden' + )" + ); + DBManager::get()->exec("INSERT IGNORE INTO `config` + (`field`, `value`, `type`, `range`, `section`, `mkdate`, `chdate`, `description`) + VALUES + ( + 'MASSMAIL_GC_DAYS', + '7', + 'integer', + 'global', + 'MassMail', + UNIX_TIMESTAMP(), + UNIX_TIMESTAMP(), + 'Anzahl Tage, nach denen bereits verschickte Nachrichten aus der Datenbank entfernt werden (0 bedeutet nie)' + )" + ); + + SendMassmailsJob::register()->schedulePeriodic(-15)->activate(); + + /* + * Extend userfilter table so that we know from which context a specific UserFilter comes from, + * allowing us to check permissions for editing. + */ + if (!$this->columnExists('userfilter', 'range_id') && !$this->columnExists('userfilter', 'range_type')) { + DBManager::get()->exec("ALTER TABLE `userfilter` + ADD `range_id` VARCHAR(32) COLLATE `latin1_bin` NOT NULL AFTER `filter_id`, + ADD `range_type` VARCHAR(255) COLLATE `latin1_bin` NOT NULL AFTER `range_id`"); + } + + /* + * Set context values for existing userfilters (we only need to consider filters from admission rules + * as only those are part of the core so far) + */ + + // First: filters from ConditionalAdmissions + $conditions = DBManager::get()->fetchAll( + "SELECT DISTINCT c.`filter_id`, r.`set_id` FROM `admission_condition` c + JOIN `courseset_rule` r USING (`rule_id`)" + ); + // Second: filters from PreferentialAdmissions + $preferential = DBManager::get()->fetchAll( + "SELECT DISTINCT p.`condition_id` AS filter_id, r.`set_id` FROM `prefadmission_condition` p + JOIN `courseset_rule` r USING (`rule_id`)" + ); + foreach (array_merge($conditions, $preferential) as $filter) { + DBManager::get()->execute( + "UPDATE `userfilter` SET `range_id` = :range, `range_type` = :type WHERE `filter_id` = :filter", + ['range' => $filter['set_id'], 'type' => CourseSet::class, 'filter' => $filter['filter_id']] + ); + } + } + + protected function down() + { + $tables = [ + 'massmail_messages', + 'massmail_permissions', + 'massmail_permission_degree', + 'massmail_permission_subject', + 'massmail_permission_institute', + 'massmail_filter', + 'massmail_tokens', + 'massmail_markers' + ]; + DBManager::get()->execute( + "DROP TABLE IF EXISTS `" . implode('`,`', $tables) . "`"); + + $id = RolePersistence::getRoleIdByName(\MassMail\MassMailPermission::MASSMAIL_ROOT_ROLE); + if (!empty($id)) { + RolePersistence::deleteRole(new Role($id)); + } + + DBManager::get()->execute( + "DELETE FROM `config_values` WHERE `field` = :field", + ['field' => 'MASSMAIL_LECTURER_SEM_CATEGORIES'] + ); + DBManager::get()->execute( + "DELETE FROM `config` WHERE `field` = :field", + ['field' => 'MASSMAIL_LECTURER_SEM_CATEGORIES'] + ); + + SendMassmailsJob::unregister(); + + if ($this->columnExists('userfilter', 'range_id') && $this->columnExists('userfilter', 'range_type')) { + DBManager::get()->exec("ALTER TABLE `userfilter` DROP `range_id`, DROP `range_type`"); + } + } +}; diff --git a/lib/admissionrules/conditionaladmission/ConditionalAdmission.php b/lib/admissionrules/conditionaladmission/ConditionalAdmission.php index ab26cc29948..10bcb995fe5 100644 --- a/lib/admissionrules/conditionaladmission/ConditionalAdmission.php +++ b/lib/admissionrules/conditionaladmission/ConditionalAdmission.php @@ -446,6 +446,7 @@ class ConditionalAdmission extends AdmissionRule $groupqueries = []; $groupparameters = []; foreach ($this->ungrouped_conditions as $condition) { + $condition->setRange(CourseSet::class, $this->courseSetId); // Store each ungrouped condition... $condition->store(); $queries[] = "(?, ?, ?, ?)"; @@ -460,6 +461,7 @@ class ConditionalAdmission extends AdmissionRule $groupparameters[] = $conditiongroup_id; $groupparameters[] = $this->quota[$conditiongroup_id]; foreach ($conditions as $condition) { + $condition->setRange(CourseSet::class, $this->courseSetId); // Store each group of conditions... $condition->store(); $queries[] = "(?, ?, ?, ?)"; diff --git a/lib/admissionrules/preferentialadmission/PreferentialAdmission.php b/lib/admissionrules/preferentialadmission/PreferentialAdmission.php index 23633d6d62a..9617f2bb784 100644 --- a/lib/admissionrules/preferentialadmission/PreferentialAdmission.php +++ b/lib/admissionrules/preferentialadmission/PreferentialAdmission.php @@ -487,6 +487,7 @@ class PreferentialAdmission extends AdmissionRule $parameters = []; if ($this->conditions) { foreach ($this->conditions as $condition) { + $condition->setRange(CourseSet::class, $this->courseSetId); // Store each condition... $condition->store(); $queries[] = "(?, ?, ?)"; diff --git a/lib/classes/JsonApi/RouteMap.php b/lib/classes/JsonApi/RouteMap.php index b13887f3af3..ec3668d1b2c 100644 --- a/lib/classes/JsonApi/RouteMap.php +++ b/lib/classes/JsonApi/RouteMap.php @@ -133,6 +133,7 @@ class RouteMap $this->addAuthenticatedForumRoutes($group); $this->addAuthenticatedInstitutesRoutes($group); $this->addAuthenticatedLtiRoutes($group); + $this->addAuthenticatedMassMailRoutes($group); $this->addAuthenticatedMessagesRoutes($group); $this->addAuthenticatedNewsRoutes($group); $this->addAuthenticatedStockImagesRoutes($group); @@ -305,6 +306,14 @@ class RouteMap $group->get('/lti-tools', Routes\Lti\LtiToolsIndex::class); } + + private function addAuthenticatedMassMailRoutes(RouteCollectorProxy $group): void + { + $group->get('/mass-mails/messages', Routes\MassMail\MassMailMessagesIndex::class); + $group->get('/mass-mails/permissions', Routes\MassMail\MassMailPermissionsIndex::class); + $group->get('/mass-mails/permissions/{id}', Routes\MassMail\MassMailPermissionsShow::class); + } + private function addAuthenticatedNewsRoutes(RouteCollectorProxy $group): void { $group->post('/courses/{id}/news', Routes\News\CourseNewsCreate::class); diff --git a/lib/classes/JsonApi/Routes/MassMail/Authority.php b/lib/classes/JsonApi/Routes/MassMail/Authority.php new file mode 100644 index 00000000000..20a79ce7294 --- /dev/null +++ b/lib/classes/JsonApi/Routes/MassMail/Authority.php @@ -0,0 +1,30 @@ +<?php + +namespace JsonApi\Routes\MassMail; + +use MassMail\MassMailPermission; +use User; + +class Authority +{ + /** + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public static function canShowMassMailPermissions(User $user, MassMailPermission $permission): bool + { + return MassMailPermission::has($user->id, true); + } + + /** + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public static function canIndexMassMailPermissions(User $user): bool + { + return MassMailPermission::has($user->id, true); + } + + public static function canIndexMassMailMessages(User $user): bool + { + return MassMailPermission::has($user->id); + } +} diff --git a/lib/classes/JsonApi/Routes/MassMail/MassMailMessagesIndex.php b/lib/classes/JsonApi/Routes/MassMail/MassMailMessagesIndex.php new file mode 100644 index 00000000000..46f2c982df8 --- /dev/null +++ b/lib/classes/JsonApi/Routes/MassMail/MassMailMessagesIndex.php @@ -0,0 +1,71 @@ +<?php + +namespace JsonApi\Routes\MassMail; + +use JsonApi\Errors\AuthorizationFailedException; +use MassMail\MassMailMessage; +use MassMail\MassMailPermission; +use Psr\Http\Message\ResponseInterface as Response; +use Psr\Http\Message\ServerRequestInterface as Request; +use JsonApi\JsonApiController; + +class MassMailMessagesIndex extends JsonApiController +{ + protected $allowedPagingParameters = ['offset', 'limit']; + protected $allowedFilteringParameters = ['templates', 'queued', 'protected', 'locked', 'sent']; + protected $allowedIncludePaths = ['author', 'sender', 'filters']; + + public function __invoke(Request $request, Response $response, $args) + { + if (!Authority::canIndexMassMailMessages($this->getUser($request))) { + throw new AuthorizationFailedException(); + } + + $filters = $this->getContextFilters(); + + [$offset, $limit] = $this->getOffsetAndLimit(); + + $sql = "`is_template` = :template AND `locked` = :locked AND `sent` = :sent ". + "ORDER BY `chdate` DESC"; + $parameters = [ + 'template' => $filters['templates'] ? 1 : 0, + 'locked' => $filters['locked'] ? 1 : 0, + 'sent' => $filters['sent'] ? 1 : 0 + ]; + + if ($filters['protected']) { + $sql = "`protected` = :protected AND " . $sql; + $parameters['protected'] = 1; + } + + if (!MassMailPermission::has($this->getUser($request)->id, true) || $filters['templates']) { + $sql = "`author_id` = :author AND " . $sql; + $parameters['author'] = $this->getUser($request)->id; + } + + $total = MassMailMessage::countBySQL($sql, $parameters); + $messages = MassMailMessage::findBySQL( + $sql . " LIMIT :limit OFFSET :offset", + array_merge( + $parameters, + ['limit' => $limit,'offset' => $offset] + )); + + return $this->getPaginatedContentResponse($messages, $total); + } + + private function getContextFilters() + { + $defaults = [ + 'templates' => false, + 'queued' => false, + 'protected' => false, + 'locked' => false, + 'sent' => false + ]; + + $filtering = $this->getQueryParameters()->getFilteringParameters() ?: []; + + return array_merge($defaults, $filtering); + } +} diff --git a/lib/classes/JsonApi/Routes/MassMail/MassMailPermissionsIndex.php b/lib/classes/JsonApi/Routes/MassMail/MassMailPermissionsIndex.php new file mode 100644 index 00000000000..1d8f3abe24d --- /dev/null +++ b/lib/classes/JsonApi/Routes/MassMail/MassMailPermissionsIndex.php @@ -0,0 +1,31 @@ +<?php + +namespace JsonApi\Routes\MassMail; + +use JsonApi\Errors\AuthorizationFailedException; +use Psr\Http\Message\ResponseInterface as Response; +use Psr\Http\Message\ServerRequestInterface as Request; +use JsonApi\JsonApiController; + +class MassMailPermissionsIndex extends JsonApiController +{ + protected $allowedPagingParameters = ['offset', 'limit']; + protected $allowedIncludePaths = ['institute', 'allowed-degrees', 'allowed-subjects', 'allowed-institutes']; + + public function __invoke(Request $request, Response $response, $args) + { + if (!Authority::canIndexMassMailPermissions($this->getUser($request))) { + throw new AuthorizationFailedException(); + } + + [$offset, $limit] = $this->getOffsetAndLimit(); + + $total = \MassMail\MassMailPermission::countBySQL('1'); + $permissions = \MassMail\MassMailPermission::findBySQL( + "JOIN `Institute` ON (`Institute`.`Institut_id` = `massmail_permissions`.`institute_id`) + ORDER BY `Institute`.`Name` LIMIT ?, ?", + [$offset, $limit]); + + return $this->getPaginatedContentResponse($permissions, $total); + } +} diff --git a/lib/classes/JsonApi/Routes/MassMail/MassMailPermissionsShow.php b/lib/classes/JsonApi/Routes/MassMail/MassMailPermissionsShow.php new file mode 100644 index 00000000000..1f91b05996e --- /dev/null +++ b/lib/classes/JsonApi/Routes/MassMail/MassMailPermissionsShow.php @@ -0,0 +1,32 @@ +<?php + +namespace JsonApi\Routes\MassMail; + +use Psr\Http\Message\ServerRequestInterface as Request; +use Psr\Http\Message\ResponseInterface as Response; +use JsonApi\Errors\AuthorizationFailedException; +use JsonApi\Errors\RecordNotFoundException; +use JsonApi\JsonApiController; + +/** + * Displays settings for the given massmail permissions.. + */ +class MassMailPermissionsShow extends JsonApiController +{ + /** + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function __invoke(Request $request, Response $response, $args) + { + if (!$resource = \MassMail\MassMailPermission::find($args['id'])) { + throw new RecordNotFoundException(); + } + + if (!Authority::canShowMassMailPermissions($this->getUser($request), $resource)) { + throw new AuthorizationFailedException(); + } + + + return $this->getContentResponse($resource); + } +} diff --git a/lib/classes/JsonApi/Routes/UserFilters/Authority.php b/lib/classes/JsonApi/Routes/UserFilters/Authority.php index d934894bd5e..e84e4d0e146 100644 --- a/lib/classes/JsonApi/Routes/UserFilters/Authority.php +++ b/lib/classes/JsonApi/Routes/UserFilters/Authority.php @@ -2,17 +2,12 @@ namespace JsonApi\Routes\UserFilters; -use Config; -use User; +use Config, User, UserFilter; class Authority { - public static function canEditUserFilters(User $user): bool + public static function canEditUserFilters(User $user, UserFilter $filter): bool { - return $GLOBALS['perm']->have_perm('admin', $user->id) - || ( - Config::get()->ALLOW_DOZENT_COURSESET_ADMIN - && $GLOBALS['perm']->have_perm('dozent', $user->id) - ); + return $filter->canEdit($user); } } diff --git a/lib/classes/JsonApi/Routes/UserFilters/UserFilterFieldsIndex.php b/lib/classes/JsonApi/Routes/UserFilters/UserFilterFieldsIndex.php index ede43cf4397..d4e9efdb79d 100644 --- a/lib/classes/JsonApi/Routes/UserFilters/UserFilterFieldsIndex.php +++ b/lib/classes/JsonApi/Routes/UserFilters/UserFilterFieldsIndex.php @@ -4,17 +4,27 @@ namespace JsonApi\Routes\UserFilters; use Psr\Http\Message\ServerRequestInterface as Request; use Psr\Http\Message\ResponseInterface as Response; +use JsonApi\Errors\BadRequestException; use JsonApi\JsonApiController; class UserFilterFieldsIndex extends JsonApiController { + protected $allowedFilteringParameters = ['context', 'target']; + /** * @SuppressWarnings(PHPMD.UnusedFormalParameters) */ public function __invoke(Request $request, Response $response, $args) { + $error = $this->validateFilters(); + if ($error) { + throw new BadRequestException($error); + } + + $filters = $this->getContextFilters(); + $fields = []; - foreach (\UserFilterField::getAvailableFilterFields() as $class => $name) { + foreach (\UserFilterField::getAvailableFilterFields($filters['context'], $filters['target']) as $class => $name) { // Generic datafield conditions must be handled differently. if (str_contains($class, '_')) { [$classname, $typeparam] = explode('_', $class); @@ -27,4 +37,30 @@ class UserFilterFieldsIndex extends JsonApiController return $this->getContentResponse($fields); } + private function validateFilters() + { + $filtering = $this->getQueryParameters()->getFilteringParameters() ?: []; + + // context aka namespace filter + if ( + isset($filtering['context']) + && !file_exists( + $GLOBALS['STUDIP_BASE_PATH'] . '/lib/classes/UserFilterFields/' . $filtering['context'] + ) + ) { + return 'Requested context user filters do not exist.'; + } + } + + private function getContextFilters() + { + $defaults = [ + 'context' => '', + 'target' => '' + ]; + + $filtering = $this->getQueryParameters()->getFilteringParameters() ?: []; + + return array_merge($defaults, $filtering); + } } diff --git a/lib/classes/JsonApi/Routes/UserFilters/UserFiltersCreate.php b/lib/classes/JsonApi/Routes/UserFilters/UserFiltersCreate.php index 42cd58363ba..e57dc13367c 100644 --- a/lib/classes/JsonApi/Routes/UserFilters/UserFiltersCreate.php +++ b/lib/classes/JsonApi/Routes/UserFilters/UserFiltersCreate.php @@ -20,16 +20,16 @@ class UserFiltersCreate extends JsonApiController */ public function __invoke(Request $request, Response $response, $args) { + $filter = new \UserFilter(); + $filter->show_user_count = true; + $json = $this->validate($request); $user = $this->getUser($request); - if (!Authority::canEditUserFilters($user)) { + if (!Authority::canEditUserFilters($user, $filter)) { throw new AuthorizationFailedException(); } - $filter = new \UserFilter(); - $filter->show_user_count = true; - foreach (self::arrayGet($json, 'data.attributes.filters') as $one) { $classname = '\\' . $one['attributes']['type']; $field = !empty($one['attributes']['typeparam']) diff --git a/lib/classes/JsonApi/Routes/UserFilters/UserFiltersDelete.php b/lib/classes/JsonApi/Routes/UserFilters/UserFiltersDelete.php index 6f2b0cb3451..e3cd53467f7 100644 --- a/lib/classes/JsonApi/Routes/UserFilters/UserFiltersDelete.php +++ b/lib/classes/JsonApi/Routes/UserFilters/UserFiltersDelete.php @@ -18,18 +18,18 @@ class UserFiltersDelete extends JsonApiController */ public function __invoke(Request $request, Response $response, $args) { - $user = $this->getUser($request); - - if (!Authority::canEditUserFilters($user)) { - throw new AuthorizationFailedException(); - } - $filter = new \UserFilter($args['id']); if ($filter['id'] !== $args['id']) { throw new RecordNotFoundException(); } + $user = $this->getUser($request); + + if (!Authority::canEditUserFilters($user, $filter)) { + throw new AuthorizationFailedException(); + } + $filter->delete(); return $this->getCodeResponse(204); diff --git a/lib/classes/JsonApi/Routes/UserFilters/UserFiltersUpdate.php b/lib/classes/JsonApi/Routes/UserFilters/UserFiltersUpdate.php index 309da9b9646..f9adecc3871 100644 --- a/lib/classes/JsonApi/Routes/UserFilters/UserFiltersUpdate.php +++ b/lib/classes/JsonApi/Routes/UserFilters/UserFiltersUpdate.php @@ -21,21 +21,21 @@ class UserFiltersUpdate extends JsonApiController */ public function __invoke(Request $request, Response $response, $args) { - $user = $this->getUser($request); - - if (!Authority::canEditUserFilters($user)) { - throw new AuthorizationFailedException(); - } - $filter = new \UserFilter($args['id']); if ($filter['id'] !== $args['id']) { throw new RecordNotFoundException(); } + $user = $this->getUser($request); + + if (!Authority::canEditUserFilters($user, $filter)) { + throw new AuthorizationFailedException(); + } + $json = $this->validate($request); - $fields = $filter->getFields(); + $filter->fields = []; foreach (self::arrayGet($json, 'data.attributes.filters') as $one) { $classname = '\\' . $one['attributes']['type']; diff --git a/lib/classes/JsonApi/SchemaMap.php b/lib/classes/JsonApi/SchemaMap.php index 44bfd04dc5c..50d67606f00 100644 --- a/lib/classes/JsonApi/SchemaMap.php +++ b/lib/classes/JsonApi/SchemaMap.php @@ -2,8 +2,6 @@ namespace JsonApi; -use JsonApi\Schemas\Clipboard; - /** * @SuppressWarnings(PHPMD.CouplingBetweenObjects) */ @@ -37,6 +35,7 @@ class SchemaMap \CourseMember::class => Schemas\CourseMember::class, \CourseDate::class => Schemas\CourseEvent::class, \CourseExDate::class => Schemas\CourseEvent::class, + \Degree::class => Schemas\Degree::class, \FeedbackElement::class => Schemas\FeedbackElement::class, \FeedbackEntry::class => Schemas\FeedbackEntry::class, \JsonApi\Models\ForumCat::class => Schemas\ForumCategory::class, @@ -44,6 +43,8 @@ class SchemaMap \Institute::class => Schemas\Institute::class, \InstituteMember::class => Schemas\InstituteMember::class, \LtiTool::class => Schemas\LtiTool::class, + \MassMail\MassMailMessage::class => Schemas\MassMailMessage::class, + \MassMail\MassMailPermission::class => Schemas\MassMailPermission::class, \Message::class => Schemas\Message::class, \SemClass::class => Schemas\SemClass::class, \Semester::class => Schemas\Semester::class, @@ -64,7 +65,6 @@ class SchemaMap \FolderType::class => Schemas\Folder::class, \UserFilter::class => Schemas\UserFilter::class, \UserFilterField::class => Schemas\UserFilterField::class, - \Courseware\Block::class => Schemas\Courseware\Block::class, \Courseware\BlockComment::class => Schemas\Courseware\BlockComment::class, \Courseware\BlockFeedback::class => Schemas\Courseware\BlockFeedback::class, diff --git a/lib/classes/JsonApi/Schemas/Degree.php b/lib/classes/JsonApi/Schemas/Degree.php new file mode 100644 index 00000000000..05a308062b9 --- /dev/null +++ b/lib/classes/JsonApi/Schemas/Degree.php @@ -0,0 +1,78 @@ +<?php + +namespace JsonApi\Schemas; + +use Neomerx\JsonApi\Contracts\Schema\ContextInterface; +use Neomerx\JsonApi\Schema\Link; + +class Degree extends SchemaProvider +{ + const TYPE = 'degrees'; + + const REL_AUTHOR = 'author'; + const REL_EDITOR = 'editor'; + + public function getId($resource): ?string + { + return $resource->id; + } + + public function getAttributes($resource, ContextInterface $context): iterable + { + return [ + 'name' => $resource->name, + 'shortname' => $resource->name_kurz, + 'description' => $resource->beschreibung, + 'mkdate' => date('c', $resource->mkdate), + 'chdate' => date('c', $resource->chdate) + ]; + } + + public function getRelationships($resource, ContextInterface $context): iterable + { + $relationships = []; + + $relationships = $this->getAuthorRelationship($relationships, $resource, $this->shouldInclude($context, self::REL_AUTHOR)); + $relationships = $this->getEditorRelationship($relationships, $resource, $this->shouldInclude($context, self::REL_EDITOR)); + + return $relationships; + } + + private function getAuthorRelationship(array $relationships, \Degree $degree, $includeData) + { + $author = \User::find($degree->author_id); + + if ($author) { + $relationships[self::REL_AUTHOR] = [ + self::RELATIONSHIP_LINKS => [ + Link::RELATED => $this->createLinkToResource($author), + ] + ]; + + if ($includeData) { + $relationships[self::REL_AUTHOR][self::RELATIONSHIP_DATA] = $author; + } + } + + return $relationships; + } + + private function getEditorRelationship(array $relationships, \Degree $degree, $includeData) + { + $editor = \User::find($degree->editor_id); + + if ($editor) { + $relationships[self::REL_EDITOR] = [ + self::RELATIONSHIP_LINKS => [ + Link::RELATED => $this->createLinkToResource($editor), + ] + ]; + + if ($includeData) { + $relationships[self::REL_EDITOR][self::RELATIONSHIP_DATA] = $editor; + } + } + + return $relationships; + } +} diff --git a/lib/classes/JsonApi/Schemas/MassMailMessage.php b/lib/classes/JsonApi/Schemas/MassMailMessage.php new file mode 100644 index 00000000000..598b0f6a882 --- /dev/null +++ b/lib/classes/JsonApi/Schemas/MassMailMessage.php @@ -0,0 +1,84 @@ +<?php + +namespace JsonApi\Schemas; + +use Neomerx\JsonApi\Contracts\Schema\ContextInterface; +use Neomerx\JsonApi\Schema\Link; + +class MassMailMessage extends SchemaProvider +{ + const TYPE = 'mass-mail-messages'; + const REL_FILTERS = 'filters'; + const REL_SENDER = 'sender'; + const REL_AUTHOR = 'author'; + const REL_RECIPIENTS = 'recipients'; + const REL_FOLDER = 'folder'; + const REL_TOKENS = 'tokens'; + + public function getId($resource): ?string + { + return $resource->id; + } + + public function getAttributes($resource, ContextInterface $context): iterable + { + return [ + 'send-date' => date('d.m.Y H:i', $resource->send_at_date), + 'target' => \MassMail\MassMailMessage::getTargets()[$resource->target], + 'config' => $resource->config, + 'exclude-users' => $resource->exclude_users, + 'cc' => $resource->cc, + 'subject' => (string) $resource->subject, + 'message' => (string) $resource->message, + 'is-template' => (bool) $resource->is_template, + 'locked' => (bool) $resource->locked, + 'sent' => (bool) $resource->sent, + 'protected' => (bool) $resource->protected, + 'mkdate' => date('d.m.Y H:i', $resource->mkdate), + 'chdate' => date('d.m.Y H:i', $resource->chdate) + ]; + } + + public function getRelationships($resource, ContextInterface $context): iterable + { + $relationships = []; + + $relationships = $this->getAuthorRelationship($relationships, $resource, $this->shouldInclude($context, self::REL_AUTHOR)); + $relationships = $this->getSenderRelationship($relationships, $resource, $this->shouldInclude($context, self::REL_SENDER)); + + return $relationships; + } + + private function getAuthorRelationship(array $relationships, \MassMail\MassMailMessage $message, $includeData) + { + $relationships[self::REL_AUTHOR] = [ + self::RELATIONSHIP_LINKS => [ + Link::RELATED => $this->createLinkToResource($message->author), + ] + ]; + + if ($includeData) { + $relationships[self::REL_AUTHOR][self::RELATIONSHIP_DATA] = $message->author; + } + + return $relationships; + } + + private function getSenderRelationship(array $relationships, \MassMail\MassMailMessage $message, $includeData) + { + if ($message->sender_id && $message->sender) { + $relationships[self::REL_SENDER] = [ + self::RELATIONSHIP_LINKS => [ + Link::RELATED => $this->createLinkToResource($message->sender), + ] + ]; + + if ($includeData) { + $relationships[self::REL_SENDER][self::RELATIONSHIP_DATA] = $message->sender; + } + } + + return $relationships; + } + +} diff --git a/lib/classes/JsonApi/Schemas/MassMailPermission.php b/lib/classes/JsonApi/Schemas/MassMailPermission.php new file mode 100644 index 00000000000..27150b4865f --- /dev/null +++ b/lib/classes/JsonApi/Schemas/MassMailPermission.php @@ -0,0 +1,121 @@ +<?php + +namespace JsonApi\Schemas; + +use Neomerx\JsonApi\Contracts\Schema\ContextInterface; +use Neomerx\JsonApi\Schema\Link; + +class MassMailPermission extends SchemaProvider +{ + const TYPE = 'mass-mail-permissions'; + const REL_INSTITUTE = 'institute'; + const REL_ALLOWED_DEGREES = 'allowed-degrees'; + const REL_ALLOWED_SUBJECTS = 'allowed-subjects'; + const REL_ALLOWED_INSTITUTES = 'allowed-institutes'; + + public function getId($resource): ?string + { + return $resource->id; + } + + public function getAttributes($resource, ContextInterface $context): iterable + { + $user = $this->currentUser; + + return [ + 'min-perm' => $resource->min_perm, + 'mkdate' => date('c', $resource->mkdate), + 'chdate' => date('c', $resource->chdate) + ]; + } + + public function hasResourceMeta($resource): bool + { + return true; + } + + /** + * @param \MassMail\MassMailPermission $resource + */ + public function getResourceMeta($resource) + { + return [ + 'allowed-degrees-count' => count($resource->allowed_degrees), + 'allowed-subjects-count' => count($resource->allowed_subjects), + 'allowed-institutes-count' => count($resource->allowed_institutes) + ]; + } + + public function getRelationships($resource, ContextInterface $context): iterable + { + $relationships = []; + + $relationships = $this->getInstituteRelationship($relationships, $resource, $this->shouldInclude($context, self::REL_INSTITUTE)); + $relationships = $this->getAllowedDegreesRelationship($relationships, $resource, $this->shouldInclude($context, self::REL_ALLOWED_DEGREES)); + $relationships = $this->getAllowedSubjectsRelationship($relationships, $resource, $this->shouldInclude($context, self::REL_ALLOWED_SUBJECTS)); + $relationships = $this->getAllowedInstitutesRelationship($relationships, $resource, $this->shouldInclude($context, self::REL_ALLOWED_INSTITUTES)); + + return $relationships; + } + + private function getInstituteRelationship(array $relationships, \MassMail\MassMailPermission $permission, $includeData) + { + $relationships[self::REL_INSTITUTE] = [ + self::RELATIONSHIP_LINKS => [ + Link::RELATED => $this->createLinkToResource($permission->institute), + ] + ]; + + if ($includeData) { + $relationships[self::REL_INSTITUTE][self::RELATIONSHIP_DATA] = $permission->institute; + } + + return $relationships; + } + + private function getAllowedDegreesRelationship(array $relationships, \MassMail\MassMailPermission $permission, $includeData) + { + + $relationships[self::REL_ALLOWED_DEGREES] = [ + self::RELATIONSHIP_LINKS => [ + Link::RELATED => $this->getRelationshipRelatedLink($permission, self::REL_ALLOWED_DEGREES), + ] + ]; + + if ($includeData) { + $relationships[self::REL_ALLOWED_DEGREES][self::RELATIONSHIP_DATA] = $permission->allowed_degrees; + } + + return $relationships; + } + + private function getAllowedSubjectsRelationship(array $relationships, \MassMail\MassMailPermission $permission, $includeData) + { + $relationships[self::REL_ALLOWED_SUBJECTS] = [ + self::RELATIONSHIP_LINKS => [ + Link::RELATED => $this->getRelationshipRelatedLink($permission, self::REL_ALLOWED_SUBJECTS), + ] + ]; + + if ($includeData) { + $relationships[self::REL_ALLOWED_SUBJECTS][self::RELATIONSHIP_DATA] = $permission->allowed_subjects; + } + + return $relationships; + } + + private function getAllowedInstitutesRelationship(array $relationships, \MassMail\MassMailPermission $permission, $includeData) + { + $relationships[self::REL_ALLOWED_INSTITUTES] = [ + self::RELATIONSHIP_LINKS => [ + Link::RELATED => $this->getRelationshipRelatedLink($permission, self::REL_ALLOWED_INSTITUTES), + ] + ]; + + if ($includeData) { + $relationships[self::REL_ALLOWED_INSTITUTES][self::RELATIONSHIP_DATA] = $permission->allowed_institutes; + } + + return $relationships; + } +} diff --git a/lib/classes/admission/UserFilter.php b/lib/classes/UserFilter.php similarity index 71% rename from lib/classes/admission/UserFilter.php rename to lib/classes/UserFilter.php index fd160d68136..7745587a2b3 100644 --- a/lib/classes/admission/UserFilter.php +++ b/lib/classes/UserFilter.php @@ -16,7 +16,6 @@ * @license http://www.gnu.org/licenses/gpl-2.0.html GPL version 2 * @category Stud.IP */ - class UserFilter { // --- ATTRIBUTES --- @@ -31,6 +30,10 @@ class UserFilter */ public $id = ''; + // Data about where this filter belongs. + public string $range_id = ''; + public string $range_type = ''; + public $show_user_count = false; // --- OPERATIONS --- @@ -38,12 +41,11 @@ class UserFilter /** * Standard constructor. * - * @param String conditionId + * @param String conditionId * @return UserFilter */ - public function __construct($conditionId='') + public function __construct($conditionId = '') { - UserFilterField::getAvailableFilterFields(); $this->id = $conditionId; if ($conditionId) { $this->load(); @@ -56,7 +58,7 @@ class UserFilter /** * Add a new condition field. * - * @param ConditionField fieldId + * @param UserFilterField fieldId * @return UserFilter */ public function addField($field) @@ -69,7 +71,8 @@ class UserFilter /** * Deletes the condition and all associated fields. */ - public function delete() { + public function delete() + { // Delete condition data. $stmt = DBManager::get()->prepare("DELETE FROM `userfilter` WHERE `filter_id`=?"); @@ -83,11 +86,12 @@ class UserFilter /** * Generate a new unique ID. * - * @param String tableName + * @param String tableName */ - public function generateId() { + public function generateId() + { do { - $newid = md5(uniqid(get_class($this).microtime(), true)); + $newid = md5(uniqid(get_class($this) . microtime(), true)); $id = DBManager::get()->fetchColumn("SELECT `filter_id` FROM `userfilter` WHERE `filter_id`=?", [$newid]); } while ($id); @@ -102,8 +106,8 @@ class UserFilter */ public function getFields() { - uasort($this->fields, function($a, $b) { - return $a->sortOrder - $b->sortOrder; + uasort($this->fields, function ($a, $b) { + return $a->sortOrder - $b->sortOrder; }); return $this->fields; } @@ -123,7 +127,8 @@ class UserFilter * * @return Array */ - public function getUsers() { + public function getUsers() + { $users = null; foreach ($this->fields as $field) { // Check if restrictions for the field value must be taken into consideration. @@ -142,7 +147,7 @@ class UserFilter } $users = isset($users) ? array_intersect($users, $field->getUsers($restrictions)) : $field->getUsers($restrictions); } - return (array) $users; + return (array)$users; } /** @@ -152,7 +157,8 @@ class UserFilter * @param String $className the type to check for * @return UserFilterField Return the found field or null if not applicable. */ - public function hasField($className) { + public function hasField($className) + { foreach ($this->fields as $field) { if ($field instanceof $className) { return $field; @@ -182,13 +188,16 @@ class UserFilter /** * Helper function for loading data from DB. */ - public function load() { + public function load() + { // Load basic condition data. $stmt = DBManager::get()->prepare( "SELECT * FROM `userfilter` WHERE `filter_id`=? LIMIT 1"); $stmt->execute([$this->id]); if ($data = $stmt->fetch(PDO::FETCH_ASSOC)) { $this->id = $data['filter_id']; + $this->range_id = $data['range_id']; + $this->range_type = $data['range_type']; // Load the associated condition fields. $stmt = DBManager::get()->prepare( "SELECT `field_id`, `type` FROM `userfilter_fields` @@ -201,16 +210,16 @@ class UserFilter * been removed since saving data to DB. */ //try { - $chunks = explode('_', $data['type']); - $type = $chunks[0]; - $param = $chunks[1] ?? null; - if ($param) { - $field = new $type($param, $data['field_id']); - } else { - $field = new $type($data['field_id']); - } + $chunks = explode('_', $data['type']); + $type = $chunks[0]; + $param = $chunks[1] ?? null; + if ($param) { + $field = new $type($param, $data['field_id']); + } else { + $field = new $type($data['field_id']); + } - $this->fields[$field->getId()] = $field; + $this->fields[$field->getId()] = $field; //} catch (Exception $e) {} } } @@ -219,7 +228,7 @@ class UserFilter /** * Removes the field with the given ID from the condition fields. * - * @param String fieldId + * @param String fieldId * @return UserFilter */ public function removeField($fieldId) @@ -231,7 +240,8 @@ class UserFilter /** * Stores data to DB. */ - public function store() { + public function store() + { // Generate new ID if condition entry doesn't exist in DB yet. if (!$this->id) { $this->id = $this->generateId(); @@ -239,34 +249,36 @@ class UserFilter // Store condition data. $stmt = DBManager::get()->prepare("INSERT INTO `userfilter` - (`filter_id`, `mkdate`, `chdate`) - VALUES (?, ?, ?) - ON DUPLICATE KEY UPDATE `chdate`=VALUES(`chdate`)"); - $stmt->execute([$this->id, time(), time()]); + (`filter_id`, `range_id`, `range_type`, `mkdate`, `chdate`) + VALUES (?, ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE `chdate` = VALUES(`chdate`), `range_type` = VALUES(`range_type`), `range_id` = VALUES(`range_id`)"); + $stmt->execute([$this->id, $this->range_id, $this->range_type, time(), time()]); // Delete removed condition fields from DB. DBManager::get()->exec("DELETE FROM `userfilter_fields` - WHERE `filter_id`='".$this->id."' AND `field_id` NOT IN ('". - implode("', '", array_keys($this->fields))."')"); + WHERE `filter_id`='" . $this->id . "' AND `field_id` NOT IN ('" . + implode("', '", array_keys($this->fields)) . "')"); // Store all fields. foreach ($this->fields as $field) { $field->store($this->id); } } - public function toString() { + public function toString() + { $tpl = $GLOBALS['template_factory']->open('userfilter/display'); $tpl->set_attribute('filter', $this); return $tpl->render(); } - public function __toString() { + public function __toString() + { return $this->toString(); } public function __clone() { $this->id = md5(uniqid(get_class($this))); - $cloned_fields= []; + $cloned_fields = []; foreach ($this->fields as $field) { $dolly = clone $field; $dolly->conditionId = $this->id; @@ -275,6 +287,34 @@ class UserFilter $this->fields = $cloned_fields; } + /** + * Checks whether the given user can edit this filter. + * @return bool + */ + public function canEdit(User $user): bool + { + // This is a new object, we can always create that as it has no other connection to the system or database. + if (!$this->range_type || !$this->range_id) { + return true; + } + + // Check for an existing object, using range_type and tange_id. + $range = new $this->range_type($this->range_id); + return $range->canEditFilter($user, $this); + } + + /** + * Sets the range this UserFilter belongs to. + * @param string $type + * @param string|int $id + * @return void + */ + public function setRange(string $type, string|int $id): void + { + $this->range_type = $type; + $this->range_id = $id; + } + } /* end of class UserFilter */ ?> diff --git a/lib/classes/admission/UserFilterField.php b/lib/classes/UserFilterField.php similarity index 79% rename from lib/classes/admission/UserFilterField.php rename to lib/classes/UserFilterField.php index 2a3480700d4..d99748928af 100644 --- a/lib/classes/admission/UserFilterField.php +++ b/lib/classes/UserFilterField.php @@ -16,7 +16,6 @@ * @license http://www.gnu.org/licenses/gpl-2.0.html GPL version 2 * @category Stud.IP */ - class UserFilterField { // --- ATTRIBUTES --- @@ -55,7 +54,7 @@ class UserFilterField * Provide some kind of sort order for filter fields. By default, * all subclasses without an explicitly given order will be sorted at the end. */ - public $sortOrder = 99; + public static $sortOrder = 99; public static $isParameterized = false; @@ -80,6 +79,24 @@ class UserFilterField } + /** + * Which targets are allowed for this filter field? + * An empty array means: no restrictions + * @return array + */ + public static function getTargets() + { + return []; + } + + /** + * Indicates whether this filter field is active. + * @return true + */ + public static function isActive() + { + return true; + } /** * Standard constructor. @@ -100,8 +117,8 @@ class UserFilterField } else { // Get all available values from database. $stmt = DBManager::get()->query( - "SELECT DISTINCT `" . $this->valuesDbIdField . "`, `" . $this->valuesDbNameField . "` " . - "FROM `" . $this->valuesDbTable . "` ORDER BY `" . $this->valuesDbNameField . "` ASC"); + "SELECT DISTINCT `" . $this->valuesDbIdField . "`, `" . $this->valuesDbNameField . "` " . + "FROM `" . $this->valuesDbTable . "` ORDER BY `" . $this->valuesDbNameField . "` ASC"); while ($current = $stmt->fetch(PDO::FETCH_ASSOC)) { $this->validValues[$current[$this->valuesDbIdField]] = $current[$this->valuesDbNameField]; } @@ -121,7 +138,7 @@ class UserFilterField * value is compared to the currently selected value by using the * currently selected compare operator. * - * @param Array values + * @param Array values * @return Boolean */ public function checkValue($values) @@ -177,12 +194,12 @@ class UserFilterField /** * Generate a new unique ID. * - * @param String tableName + * @param String tableName */ public function generateId() { do { - $newid = md5(uniqid(get_class($this).microtime(), true)); + $newid = md5(uniqid(get_class($this) . microtime(), true)); $id = DBManager::get()->fetchColumn("SELECT `field_id` FROM `userfilter_fields` WHERE `field_id`=?", [$newid]); } while ($id); @@ -192,23 +209,46 @@ class UserFilterField /** * Reads all available UserFilterField subclasses and loads their definitions. */ - public static function getAvailableFilterFields() + public static function getAvailableFilterFields(string $context = '', string $target = '') { if (self::$available_filter_fields === null) { $fields = []; $i = new FileSystemIterator( - $GLOBALS['STUDIP_BASE_PATH'] . '/lib/classes/admission/userfilter', + $GLOBALS['STUDIP_BASE_PATH'] . '/lib/classes/UserFilterFields' . ($context !== '' ? '/' . $context : ''), FileSystemIterator::SKIP_DOTS ); foreach ($i as $class) { - require_once $class; + if ($class->isFile()) { + require_once $class; + } } + // Get all classes in given context. $classes = array_filter( get_declared_classes(), - fn($c) => is_subclass_of($c, UserFilterField::class) + function ($c) use ($context) { + $reflection_class = new \ReflectionClass($c); + $namespace = $reflection_class->getNamespaceName(); + return is_subclass_of($c, UserFilterField::class) + && $namespace === 'UserFilterFields' . ($context !== '' ? '\\' . $context : '') + && $c::isActive(); + } ); + + usort($classes, fn ($a, $b) => $a::$sortOrder - $b::$sortOrder); + + // If a target is given, return only matching classes + if ($target !== '') { + $classes = array_filter( + $classes, + function ($c) use ($target) { + $targets = $c::getTargets(); + return count($targets) === 0 || in_array($target, $targets); + } + ); + } + foreach ($classes as $class) { if ($class::$isParameterized) { $fields = array_merge($fields, $class::getParameterizedTypes()); @@ -217,7 +257,6 @@ class UserFilterField $fields[$class] = $filter->getName(); } } - asort($fields); self::$available_filter_fields = $fields; } return self::$available_filter_fields; @@ -281,10 +320,10 @@ class UserFilterField $db = DBManager::get(); $users = []; // Standard query getting the values without respecting other values. - $select = "SELECT DISTINCT `".$this->userDataDbTable."`.`user_id` "; - $from = "FROM `".$this->userDataDbTable."` "; - $where = "WHERE `".$this->userDataDbTable."`.`".$this->userDataDbField. - "`".$this->compareOperator."?"; + $select = "SELECT DISTINCT `" . $this->userDataDbTable . "`.`user_id` "; + $from = "FROM `" . $this->userDataDbTable . "` "; + $where = "WHERE `" . $this->userDataDbTable . "`.`" . $this->userDataDbField . + "`" . $this->compareOperator . "?"; $parameters = [$this->value]; $joinedTables = [ $this->userDataDbTable => true @@ -296,20 +335,20 @@ class UserFilterField // Do we need to join in another table? if (!$joinedTables[$restriction['table']]) { $joinedTables[$restriction['table']] = true; - $from .= " INNER JOIN `".$restriction['table']."` ON (`". - $this->userDataDbTable."`.`". - $this->relations[$otherField]['local_field']."`=`". - $restriction['table']."`.`". - $this->relations[$otherField]['foreign_field']."`)"; + $from .= " INNER JOIN `" . $restriction['table'] . "` ON (`" . + $this->userDataDbTable . "`.`" . + $this->relations[$otherField]['local_field'] . "`=`" . + $restriction['table'] . "`.`" . + $this->relations[$otherField]['foreign_field'] . "`)"; } // Expand WHERE statement with the value from restriction. - $where .= " AND `".$restriction['table']."`.`". - $restriction['field']."`".$restriction['compare']."?"; + $where .= " AND `" . $restriction['table'] . "`.`" . + $restriction['field'] . "`" . $restriction['compare'] . "?"; $parameters[] = $restriction['value']; } } // Get all the users that fulfill the condition. - $stmt = $db->prepare($select.$from.$where); + $stmt = $db->prepare($select . $from . $where); $stmt->execute($parameters); while ($current = $stmt->fetch(PDO::FETCH_ASSOC)) { $users[] = $current['user_id']; @@ -323,15 +362,15 @@ class UserFilterField * for the user. These can then be compared with the required degrees * whether they fit. * - * @param String $userId User to check. - * @param array $additional conditions that are required for check. + * @param String $userId User to check. + * @param array $additional conditions that are required for check. * @return array The value(s) for this user. */ public function getUserValues($userId, $additional = null) { $result = []; - $query = "SELECT DISTINCT `".$this->userDataDbField."` ". - "FROM `".$this->userDataDbTable."` ". + $query = "SELECT DISTINCT `" . $this->userDataDbField . "` " . + "FROM `" . $this->userDataDbTable . "` " . "WHERE `user_id`=?"; $parameters = [$userId]; // Additional requirements given... @@ -342,7 +381,7 @@ class UserFilterField foreach ($additional as $a_condition) { if ($a_condition->id != $this->id && $this->userDataDbTable == $a_condition->userDataDbTable && - !in_array($a_condition->userDataDbField, $usedFields)) { + !in_array($a_condition->userDataDbField, $usedFields)) { $query .= " AND `" . $a_condition->userDataDbField . "` " . $a_condition->compareOperator . "?"; $parameters[] = $a_condition->value; } @@ -406,7 +445,7 @@ class UserFilterField /** * Sets a new selected compare operator * - * @param String newOperator + * @param String newOperator * @return UserFilterField */ public function setCompareOperator($newOperator) @@ -422,7 +461,7 @@ class UserFilterField /** * Connects the current field to a UserFilter. * - * @param String $id ID of a UserFilter object. + * @param String $id ID of a UserFilter object. * @return UserFilterField */ public function setConditionId($id) @@ -434,7 +473,7 @@ class UserFilterField /** * Sets a new selected value. * - * @param String newValue + * @param String newValue * @return UserFilterField */ public function setValue($newValue) @@ -450,7 +489,7 @@ class UserFilterField /** * Stores data to DB. * - * @param String conditionId The condition this field belongs to. + * @param String conditionId The condition this field belongs to. */ public function store() { diff --git a/lib/classes/admission/userfilter/DatafieldCondition.php b/lib/classes/UserFilterFields/DatafieldCondition.php similarity index 81% rename from lib/classes/admission/userfilter/DatafieldCondition.php rename to lib/classes/UserFilterFields/DatafieldCondition.php index 1bc93e8b775..9e7a2c334eb 100644 --- a/lib/classes/admission/userfilter/DatafieldCondition.php +++ b/lib/classes/UserFilterFields/DatafieldCondition.php @@ -12,22 +12,24 @@ * @license http://www.gnu.org/licenses/gpl-2.0.html GPL version 2 * @category Stud.IP */ -class DatafieldCondition extends UserFilterField +namespace UserFilterFields; + +class DatafieldCondition extends \UserFilterField { public static $isParameterized = true; public $datafield_id, $null_yields, $datafield_name; - public $sortOrder = 6; + public static $sortOrder = 6; public static function getParameterizedTypes() { $ret = []; try { - foreach (DataField::findBySQL("object_type='user' AND (object_class & (1|2|4|8) OR object_class IS NULL) AND is_userfilter = 1 ORDER BY priority") as $df) { + foreach (\DataField::findBySQL("object_type='user' AND (object_class & (1|2|4|8) OR object_class IS NULL) AND is_userfilter = 1 ORDER BY priority") as $df) { $ret[__CLASS__ . '_' . $df->id] = utf8_encode(chr(160)) . _("Datenfeld") . ': ' . $df->name; } - } catch (PDOException $e) {} //migration 128 chokes on this... + } catch (\PDOException $e) {} //migration 128 chokes on this... return $ret; } /** @@ -49,26 +51,26 @@ class DatafieldCondition extends UserFilterField $this->datafield_id = $typeparam; } - $df = DataField::find($this->datafield_id); + $df = \DataField::find($this->datafield_id); if ($df) { $this->datafield_name = $df->name; } else { - throw new UnexpectedValueException('datafield not found, id: ' . $typeparam); + throw new \UnexpectedValueException('datafield not found, id: ' . $typeparam); } - $typed_df = DataFieldEntry::createDataFieldEntry($df); - if ($typed_df instanceof DataFieldBoolEntry) { + $typed_df = \DataFieldEntry::createDataFieldEntry($df); + if ($typed_df instanceof \DataFieldBoolEntry) { $this->validValues = [1 => _('Ja'), 0 => _('Nein')]; unset($this->validCompareOperators['>=']); unset($this->validCompareOperators['<=']); unset($this->validCompareOperators['!=']); $this->null_yields = 0; - } else if ($typed_df instanceof DataFieldSelectboxEntry) { + } else if ($typed_df instanceof \DataFieldSelectboxEntry) { list($valid_values, $is_assoc) = $typed_df->getParameters(); if (!$is_assoc) { $valid_values = array_combine($valid_values, $valid_values); } $this->validValues = $valid_values; - $this->null_yields = $typed_df instanceof DataFieldSelectboxMultipleEntry ? '' : key($valid_values); + $this->null_yields = $typed_df instanceof \DataFieldSelectboxMultipleEntry ? '' : key($valid_values); } else { $this->null_yields = ''; } @@ -87,7 +89,7 @@ class DatafieldCondition extends UserFilterField public function getUsers($restrictions = []) { - $db = DBManager::get(); + $db = \DBManager::get(); // Standard query getting the values without respecting other values. $select = "SELECT user_id FROM auth_user_md5 LEFT JOIN @@ -107,7 +109,7 @@ class DatafieldCondition extends UserFilterField */ public function getUserValues($userId, $additional = null) { - $result = DBManager::get()->fetchColumn( + $result = \DBManager::get()->fetchColumn( "SELECT content FROM datafields_entries WHERE datafield_id = ? AND range_id = ?", [$this->datafield_id, $userId]); return [$result === null || $result === false ? $this->null_yields : $result]; @@ -118,10 +120,10 @@ class DatafieldCondition extends UserFilterField */ public function load() { - $stmt = DBManager::get()->prepare( + $stmt = \DBManager::get()->prepare( "SELECT * FROM `userfilter_fields` WHERE `field_id`=? LIMIT 1"); $stmt->execute([$this->id]); - if ($data = $stmt->fetch(PDO::FETCH_ASSOC)) { + if ($data = $stmt->fetch(\PDO::FETCH_ASSOC)) { $this->conditionId = $data['filter_id']; $this->value = $data['value']; $this->compareOperator = $data['compare_op']; @@ -152,7 +154,7 @@ class DatafieldCondition extends UserFilterField $this->id = $this->generateId(); } // Store field data. - $stmt = DBManager::get()->prepare("INSERT INTO `userfilter_fields` + $stmt = \DBManager::get()->prepare("INSERT INTO `userfilter_fields` (`field_id`, `filter_id`, `type`, `value`, `compare_op`, `mkdate`, `chdate`) VALUES (?, ?, ?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE `filter_id`=VALUES(`filter_id`), diff --git a/lib/classes/admission/userfilter/DegreeCondition.php b/lib/classes/UserFilterFields/DegreeCondition.php similarity index 92% rename from lib/classes/admission/userfilter/DegreeCondition.php rename to lib/classes/UserFilterFields/DegreeCondition.php index 61ce456d0c4..6b26fb02e6a 100644 --- a/lib/classes/admission/userfilter/DegreeCondition.php +++ b/lib/classes/UserFilterFields/DegreeCondition.php @@ -1,4 +1,5 @@ <?php + /** * DegreeCondition.php * @@ -13,7 +14,9 @@ * @license http://www.gnu.org/licenses/gpl-2.0.html GPL version 2 * @category Stud.IP */ -class DegreeCondition extends UserFilterField +namespace UserFilterFields; + +class DegreeCondition extends \UserFilterField { // --- ATTRIBUTES --- public $valuesDbTable = 'abschluss'; @@ -22,7 +25,7 @@ class DegreeCondition extends UserFilterField public $userDataDbTable = 'user_studiengang'; public $userDataDbField = 'abschluss_id'; - public $sortOrder = 1; + public static $sortOrder = 1; /** * @see UserFilterField::__construct diff --git a/lib/classes/UserFilterFields/DomainCondition.php b/lib/classes/UserFilterFields/DomainCondition.php new file mode 100644 index 00000000000..125a16b2bc9 --- /dev/null +++ b/lib/classes/UserFilterFields/DomainCondition.php @@ -0,0 +1,45 @@ +<?php + +/** + * DomainCondition.php + * + * All conditions concerning the user domain in Stud.IP can be specified here. + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License as + * published by the Free Software Foundation; either version 2 of + * the License, or (at your option) any later version. + * + * @author Thomas Hackl <thomas.hackl@uni-passau.de> + * @license http://www.gnu.org/licenses/gpl-2.0.html GPL version 2 + * @category Stud.IP + */ +namespace UserFilterFields; + +class DomainCondition extends \UserFilterField +{ + // --- ATTRIBUTES --- + public $valuesDbTable = 'userdomains'; + public $valuesDbIdField = 'userdomain_id'; + public $valuesDbNameField = 'name'; + public $userDataDbTable = 'user_userdomains'; + public $userDataDbField = 'userdomain_id'; + + public static $sortOrder = 8; + + public static function isActive() + { + return \UserDomain::countBySQL("1") > 0; + } + + /** + * Get this field's display name. + * + * @return String + */ + public function getName() + { + return _('Domäne'); + } + +} diff --git a/lib/classes/UserFilterFields/MassMail/MassMailDegreeFilter.php b/lib/classes/UserFilterFields/MassMail/MassMailDegreeFilter.php new file mode 100644 index 00000000000..71640d49b08 --- /dev/null +++ b/lib/classes/UserFilterFields/MassMail/MassMailDegreeFilter.php @@ -0,0 +1,126 @@ +<?php + +namespace UserFilterFields\MassMail; + +use UserFilterFields\DegreeCondition; +use MassMail\MassMailPermission; +use User; +use DBManager; +use PDO; + +class MassMailDegreeFilter extends DegreeCondition +{ + /** + * @see \UserFilterField::getTargets() + */ + public static function getTargets() + { + return ['students']; + } + + public function __construct($fieldId = '') + { + parent::__construct($fieldId); + + if (!MassMailPermission::has(User::findCurrent()->id, true)) { + $this->validValues = []; + + $permission = MassMailPermission::getForUser(User::findCurrent(), true); + + foreach ($permission['allowed_degrees'] as [$id, $name]) { + $this->validValues[$id] = (string) $name; + } + } + } + + public function getUsers($restrictions = []) + { + $users = []; + + if (MassMailPermission::has(User::findCurrent()->id, true)) { + $users = parent::getUsers($restrictions); + } else if (count($this->validValues) > 0) { + // Standard query getting the values without respecting other values. + $select = "SELECT DISTINCT `" . $this->userDataDbTable . "`.`user_id` "; + $from = "FROM `" . $this->userDataDbTable . "` "; + $where = "WHERE `" . $this->userDataDbTable . "`.`" . $this->userDataDbField . + "`" . $this->compareOperator . "?"; + $parameters = [$this->value]; + $joinedTables = [ + $this->userDataDbTable => true + ]; + // Check if there are restrictions given. + foreach ($restrictions as $otherField => $restriction) { + // We only take the value into consideration if it represents a valid restriction. + if ($this->relations[$otherField]) { + // Do we need to join in another table? + if (!$joinedTables[$restriction['table']]) { + $joinedTables[$restriction['table']] = true; + $from .= " INNER JOIN `" . $restriction['table'] . "` ON (`" . + $this->userDataDbTable . "`.`" . + $this->relations[$otherField]['local_field'] . "`=`" . + $restriction['table'] . "`.`" . + $this->relations[$otherField]['foreign_field'] . "`)"; + } + // Expand WHERE statement with the value from restriction. + $where .= " AND `" . $restriction['table'] . "`.`" . + $restriction['field'] . "`" . $restriction['compare'] . "?"; + $parameters[] = $restriction['value']; + } + } + + $where .= " AND `" . $this->userDataDbTable . "`.`" . $this->userDataDbField . "` IN (?)"; + $parameters[] = array_keys($this->validValues); + + // Get all the users that fulfill the condition. + $users = \DBManager::get()->fetchFirst($select . $from . $where, $parameters); + } + + return $users; + } + + /** + * Gets the value for the given user that is relevant for this + * condition field. Here, this method looks up the study degree(s) + * for the user. These can then be compared with the required degrees + * whether they fit. + * + * @param String $userId User to check. + * @param array $additional conditions that are required for check. + * @return array The value(s) for this user. + */ + public function getUserValues($userId, $additional = null) + { + if (MassMailPermission::has(User::findCurrent()->id, true)) { + $result = parent::getUserValues($userId, $additional); + } else { + $result = []; + $query = "SELECT DISTINCT `" . $this->userDataDbField . "` " . + "FROM `" . $this->userDataDbTable . "` " . + "WHERE `user_id`=?"; + $parameters = [$userId]; + // Additional requirements given... + if (is_array($additional)) { + + // Don't use the same database field twice as this can only get ugly. + $usedFields = [$this->userDataDbField]; + + foreach ($additional as $a_condition) { + if ($a_condition->id != $this->id && $this->userDataDbTable == $a_condition->userDataDbTable && + !in_array($a_condition->userDataDbField, $usedFields)) { + $query .= " AND `" . $a_condition->userDataDbField . "` " . $a_condition->compareOperator . "?"; + $parameters[] = $a_condition->value; + } + } + } + // Get semester of study for user. + $stmt = DBManager::get()->prepare($query); + $stmt->execute($parameters); + while ($current = $stmt->fetch(PDO::FETCH_ASSOC)) { + $result[] = $current[$this->userDataDbField]; + } + } + return $result; + } + +} diff --git a/lib/classes/UserFilterFields/MassMail/MassMailDomainFilter.php b/lib/classes/UserFilterFields/MassMail/MassMailDomainFilter.php new file mode 100644 index 00000000000..7d7a9225fff --- /dev/null +++ b/lib/classes/UserFilterFields/MassMail/MassMailDomainFilter.php @@ -0,0 +1,75 @@ +<?php + +/** + * DomainCondition.php + * + * All conditions concerning the user domain in Stud.IP can be specified here. + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License as + * published by the Free Software Foundation; either version 2 of + * the License, or (at your option) any later version. + * + * @author Thomas Hackl <thomas.hackl@uni-passau.de> + * @license http://www.gnu.org/licenses/gpl-2.0.html GPL version 2 + * @category Stud.IP + */ +namespace UserFilterFields\MassMail; + +use MassMail\MassMailPermission; +use UserFilterFields\DomainCondition; +use DBManager; +use User; + +class MassMailDomainFilter extends DomainCondition +{ + + public string $target = ''; + + /** + * Gets all users belonging to given domain. + * + * @return array All users that are affected by the current condition + * field. + */ + public function getUsers($restrictions = []) + { + $users = []; + if (MassMailPermission::has(User::findCurrent()->id, true)) { + $users = parent::getUsers($restrictions); + } else if (MassMailPermission::has(User::findCurrent()->id)) { + + $allowed = MassMailPermission::getForUser(User::findCurrent()); + + switch ($this->target) { + case 'employees': + $sql = "SELECT DISTINCT `" . $this->userDataDbTable . "`.`user_id` FROM `" . $this->userDataDbTable + . "`JOIN `user_inst` USING (`user_id`) "; + $where = "WHERE `" . $this->userDataDbTable . "`.`" . $this->userDataDbField . "`" . $this->compareOperator + . ":value AND `user_inst`.`Institut_id` IN (:institutes) AND `user_inst`.`inst_perms` IN (:perms)"; + $parameters = [ + 'value' => $this->value, + 'institutes' => $allowed['allowed_institutes'], + 'perms' => ['autor', 'tutor', 'dozent', 'admin'] + ]; + break; + case 'students': + default: + $sql = "SELECT DISTINCT `" . $this->userDataDbTable . "`.`user_id` FROM `" . $this->userDataDbTable + . "`JOIN `user_studiengang` USING (`user_id`) "; + $where = "WHERE `" . $this->userDataDbTable . "`.`" . $this->userDataDbField . "`" . $this->compareOperator + . ":value AND `user_studiengang`.`abschluss_id` IN (:degrees) + AND `user_studiengang`.`fach_id` IN (:subjects)"; + $parameters = [ + 'value' => $this->value, + 'degrees' => $allowed['allowed_degrees'], + 'subjects' => $allowed['allowed_subjects'] + ]; + break; + } + $users = DBManager::get()->fetchFirst($sql . $where, $parameters); + } + + return $users; + } +} diff --git a/lib/classes/UserFilterFields/MassMail/MassMailGenderFilter.php b/lib/classes/UserFilterFields/MassMail/MassMailGenderFilter.php new file mode 100644 index 00000000000..089fa95c255 --- /dev/null +++ b/lib/classes/UserFilterFields/MassMail/MassMailGenderFilter.php @@ -0,0 +1,74 @@ +<?php + +namespace UserFilterFields\MassMail; + +use UserFilterField; +use MassMail\MassMailPermission; +use User; +use DBManager; + +class MassMailGenderFilter extends UserFilterField +{ + public $userDataDbField = 'geschlecht'; + public $userDataDbTable = 'user_info'; + + public static $sortOrder = 8; + + public $target = ''; + + public function __construct($fieldId = '') + { + parent::__construct($fieldId); + + $this->validCompareOperators = [ + '=' => _('ist'), + '!=' => _('ist nicht'), + ]; + + $this->validValues = [ + 0 => _('unbekannt'), + 1 => _('männlich'), + 2 => _('weiblich'), + 3 => _('divers') + ]; + } + + public function getName() + { + return _('Geschlecht'); + } + + /** + * Gets all users with given gender. + * + * @return array All users that are affected by the current condition + * field. + */ + public function getUsers($restrictions = []) + { + $users = []; + if (MassMailPermission::has(User::findCurrent()->id, true)) { + $users = DBManager::get()->fetchFirst("SELECT DISTINCT `user_id` " . + "FROM `" . $this->userDataDbTable . "` " . + "WHERE `" . $this->userDataDbField . "`" . $this->compareOperator . + "?", [$this->value]); + } else if (MassMailPermission::has(User::findCurrent()->id)) { + + $allowed = MassMailPermission::getForUser(User::findCurrent()); + + $sql = "SELECT DISTINCT `" . $this->userDataDbTable . "`.`user_id` FROM `" . $this->userDataDbTable + . "`JOIN `user_inst` USING (`user_id`) "; + $where = "WHERE `" . $this->userDataDbTable . "`.`" . $this->userDataDbField . "`" . $this->compareOperator + . ":value AND `user_inst`.`Institut_id` IN (:institutes) AND `user_inst`.`inst_perms` IN (:perms)"; + $parameters = [ + 'value' => $this->value, + 'institutes' => $allowed['allowed_institutes'], + 'perms' => ['autor', 'tutor', 'dozent', 'admin'] + ]; + + $users = DBManager::get()->fetchFirst($sql.$where, $parameters); + } + + return $users; + } +} diff --git a/lib/classes/UserFilterFields/MassMail/MassMailInstituteFilter.php b/lib/classes/UserFilterFields/MassMail/MassMailInstituteFilter.php new file mode 100644 index 00000000000..4b4d49db244 --- /dev/null +++ b/lib/classes/UserFilterFields/MassMail/MassMailInstituteFilter.php @@ -0,0 +1,140 @@ +<?php + +namespace UserFilterFields\MassMail; + +use UserFilterField; +use MassMail\MassMailPermission; +use User; +use DBManager; + +class MassMailInstituteFilter extends UserFilterField +{ + public $valuesDbTable = 'Institute'; + public $valuesDbIdField = 'Institut_id'; + public $valuesDbNameField = 'Name'; + public $userDataDbTable = 'user_inst'; + public $userDataDbField = 'Institut_id'; + + public static $sortOrder = 9; + + public static function getTargets() + { + return ['employees']; + } + + public function __construct($fieldId = '') + { + parent::__construct($fieldId); + + $this->validCompareOperators = [ + '=' => _('ist'), + '!=' => _('ist nicht'), + ]; + + if (MassMailPermission::has(User::findCurrent()->id, true)) { + // Get all available institutes from database, grouped by faculty. + $faculties = DBManager::get()->fetchAll( + "SELECT `Institut_id`, `Name` FROM `Institute` + WHERE `fakultaets_id` = `Institut_id` ORDER BY `Name`" + ); + foreach ($faculties as $f) { + $this->validValues[$f[$this->valuesDbIdField]] = $f[$this->valuesDbNameField]; + $this->validValues[$f[$this->valuesDbIdField].'_children'] = + sprintf(_('%s und Untereinrichtungen'), + $f[$this->valuesDbNameField]); + $institutes = DBManager::get()->fetchAll( + "SELECT `Institut_id`, `Name` FROM `Institute` + WHERE `fakultaets_id` = :fak AND `Institut_id` != :fak ORDER BY `Name`", + ['fak' => $f[$this->valuesDbIdField]] + ); + foreach ($institutes as $i) { + $this->validValues[$i[$this->valuesDbIdField]] = $i[$this->valuesDbNameField]; + } + } + } else if (MassMailPermission::has(User::findCurrent()->id)) { + $this->validValues = []; + + $allowed = MassMailPermission::getForUser(User::findCurrent()); + + // Get all available institutes from database, grouped by faculty. + $faculties = DBManager::get()->fetchAll( + "SELECT `Institut_id`, `Name` FROM `Institute` + WHERE `fakultaets_id` = `Institut_id` AND `Institut_id` IN (:allowed) + ORDER BY `Name`", + ['allowed' => $allowed['allowed_institutes']] + ); + foreach ($faculties as $f) { + $this->validValues[$f[$this->valuesDbIdField]] = $f[$this->valuesDbNameField]; + $this->validValues[$f[$this->valuesDbIdField] . '_children'] = + sprintf(_('%s und Untereinrichtungen'), + $f[$this->valuesDbNameField]); + $institutes = DBManager::get()->fetchAll( + "SELECT `Institut_id`, `Name` FROM `Institute` + WHERE `fakultaets_id` = :fak AND `Institut_id` != :fak AND `Institut_id` IN (:allowed) + ORDER BY `Name`", + ['fak' => $f[$this->valuesDbIdField], 'allowed' => $allowed['allowed_institutes']] + ); + foreach ($institutes as $i) { + $this->validValues[$i[$this->valuesDbIdField]] = $i[$this->valuesDbNameField]; + } + } + + $institutes = DBManager::get()->fetchAll( + "SELECT `Institut_id`, `Name` + FROM `Institute` + WHERE `Institut_id` IN (:allowed) + AND `Institut_id` NOT IN (:processed) + ORDER BY `Name`", + [ + 'allowed' => $allowed['allowed_institutes'], + 'processed' => count($this->validValues) > 0 ? array_keys($this->validValues) : '' + ] + ); + foreach ($institutes as $i) { + $this->validValues[$i[$this->valuesDbIdField]] = $i[$this->valuesDbNameField]; + } + } + } + + public function getName() + { + return _('Einrichtung'); + } + + /** + * Gets all users belonging to a statusgroup with the given name. This is not done via statusgroup_id + * in ordner to enable several institutes as filter. + * + * @return array All users that are affected by the current condition + * field. + */ + public function getUsers($restrictions = []) + { + $users = []; + if (MassMailPermission::has(User::findCurrent()->id, true)) { + $users = DBManager::get()->fetchFirst( + "SELECT DISTINCT `user_id` " . + "FROM `" . $this->userDataDbTable . "` " . + "WHERE `" . $this->userDataDbField . "`" . $this->compareOperator . + ":value AND `inst_perms` IN (:perms)", ['value' => $this->value, + 'perms' => ['autor', 'tutor', 'dozent', 'admin']] + ); + } else if (MassMailPermission::has(User::findCurrent()->id)) { + + $allowed = MassMailPermission::getForUser(User::findCurrent()); + + $sql = "SELECT DISTINCT `" . $this->userDataDbTable . "`.`user_id` FROM `" . $this->userDataDbTable . "` "; + $where = "WHERE `" . $this->userDataDbTable . "`.`" . $this->userDataDbField . "`" . $this->compareOperator + . ":value AND `Institut_id` IN (:institutes) AND `inst_perms` IN (:perms)"; + $parameters = [ + 'value' => $this->value, + 'institutes' => $allowed['allowed_institutes'], + 'perms' => ['autor', 'tutor', 'dozent', 'admin'] + ]; + + $users = DBManager::get()->fetchFirst($sql.$where, $parameters); + } + + return $users; + } +} diff --git a/lib/classes/UserFilterFields/MassMail/MassMailPermissionFilter.php b/lib/classes/UserFilterFields/MassMail/MassMailPermissionFilter.php new file mode 100644 index 00000000000..eba33076beb --- /dev/null +++ b/lib/classes/UserFilterFields/MassMail/MassMailPermissionFilter.php @@ -0,0 +1,111 @@ +<?php + +/** + * PermissionCondition.php + * + * All conditions concerning the semester of study in Stud.IP can be specified here. + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License as + * published by the Free Software Foundation; either version 2 of + * the License, or (at your option) any later version. + * + * @author Elmar Ludwig <elmar.ludwig@uos.de> + * @license http://www.gnu.org/licenses/gpl-2.0.html GPL version 2 + * @category Stud.IP + */ +namespace UserFilterFields\MassMail; + +use UserFilterFields\PermissionCondition; +use User; +use DBManager; +use MassMail\MassMailPermission; + +class MassMailPermissionFilter extends PermissionCondition +{ + + public string $target = ''; + + public static $sortOrder = 10; + + /** + * @see \UserFilterField::getTargets() + */ + public static function getTargets() + { + return ['employees']; + } + + /** + * @see UserFilterField::__construct + */ + public function __construct($fieldId = '') + { + $this->userDataDbTable = 'auth_user_md5'; + $this->userDataDbField = 'perms'; + + parent::__construct($fieldId); + + $this->validValues = [ + 'autor' => _('Student/in'), + 'tutor' => _('Tutor/in'), + 'dozent' => _('Lehrende/r') + ]; + } + + /** + * Get this field's display name. + * + * @return String + */ + public function getName() + { + return _('Globaler Status'); + } + + /** + * Gets all users with given gender. + * + * @return array All users that are affected by the current condition + * field. + */ + public function getUsers($restrictions = array()) + { + $users = []; + if (MassMailPermission::has(User::findCurrent()->id, true)) { + $users = DBManager::get()->fetchFirst("SELECT DISTINCT `user_id` " . + "FROM `" . $this->userDataDbTable . "` " . + "WHERE `" . $this->userDataDbField . "`" . $this->compareOperator . + "?", [$this->value]); + } else if (MassMailPermission::has(User::findCurrent()->id)) { + + $allowed = MassMailPermission::getForUser(User::findCurrent()); + + $sql = "SELECT DISTINCT `" . $this->userDataDbTable . "`.`user_id` FROM `" . $this->userDataDbTable . "` "; + $where = "WHERE `" . $this->userDataDbTable . "`.`" . $this->userDataDbField . "`" . $this->compareOperator . ":value"; + $parameters = ['value' => $this->value]; + + switch ($this->target) { + case 'employees': + $sql .= "JOIN `user_inst` USING (`user_id`) "; + $where .= " AND `user_inst`.`Institut_id` IN (:institutes) AND `user_inst`.`inst_perms` IN (:perms)"; + $parameters['institutes'] = $allowed['allowed_institutes']; + $parameters['perms'] = ['autor', 'tutor', 'dozent', 'admin']; + break; + case 'students': + default: + $sql .= "JOIN `user_studiengang` USING (`user_id`) "; + $where .= " AND ( + `user_studiengang`.`abschluss_id` IN (:degrees) + OR `user_studiengang`.`fach_id` IN (:subjects) + )"; + $parameters['degrees'] = $allowed['allowed_degrees']; + $parameters['subjects'] = $allowed['allowed_subjects']; + } + + $users = DBManager::get()->fetchFirst($sql.$where, $parameters); + } + + return $users; + } +} diff --git a/lib/classes/UserFilterFields/MassMail/MassMailSelfAssignedInstituteFilter.php b/lib/classes/UserFilterFields/MassMail/MassMailSelfAssignedInstituteFilter.php new file mode 100644 index 00000000000..8b732d20258 --- /dev/null +++ b/lib/classes/UserFilterFields/MassMail/MassMailSelfAssignedInstituteFilter.php @@ -0,0 +1,137 @@ +<?php + +namespace UserFilterFields\MassMail; + +use UserFilterField; +use MassMail\MassMailPermission; +use User; +use DBManager; + +class MassMailSelfAssignedInstituteFilter extends UserFilterField +{ + public $valuesDbTable = 'Institute'; + public $valuesDbIdField = 'Institut_id'; + public $valuesDbNameField = 'Name'; + public $userDataDbTable = 'user_inst'; + public $userDataDbField = 'Institut_id'; + + public static $sortOrder = 9; + + public static function getTargets() + { + return ['students']; + } + + public function __construct($fieldId = '') + { + parent::__construct($fieldId); + + $this->validCompareOperators = [ + '=' => _('ist'), + '!=' => _('ist nicht'), + ]; + + if (MassMailPermission::has(User::findCurrent()->id, true)) { + // Get all available institutes from database, grouped by faculty. + $faculties = DBManager::get()->fetchAll( + "SELECT `Institut_id`, `Name` FROM `Institute` + WHERE `fakultaets_id` = `Institut_id` ORDER BY `Name`" + ); + foreach ($faculties as $f) { + $this->validValues[$f[$this->valuesDbIdField]] = $f[$this->valuesDbNameField]; + $this->validValues[$f[$this->valuesDbIdField].'_children'] = + sprintf(_('%s und Untereinrichtungen'), + $f[$this->valuesDbNameField]); + $institutes = DBManager::get()->fetchAll( + "SELECT `Institut_id`, `Name` FROM `Institute` + WHERE `fakultaets_id` = :fak AND `Institut_id` != :fak ORDER BY `Name`", + ['fak' => $f[$this->valuesDbIdField]] + ); + foreach ($institutes as $i) { + $this->validValues[$i[$this->valuesDbIdField]] = $i[$this->valuesDbNameField]; + } + } + } else if (MassMailPermission::has(User::findCurrent()->id)) { + $this->validValues = []; + + $allowed = MassMailPermission::getForUser(User::findCurrent()); + + // Get all available institutes from database, grouped by faculty. + $faculties = DBManager::get()->fetchAll( + "SELECT `Institut_id`, `Name` FROM `Institute` + WHERE `fakultaets_id` = `Institut_id` AND `Institut_id` IN (:allowed) + ORDER BY `Name`", + ['allowed' => $allowed['allowed_institutes']] + ); + foreach ($faculties as $f) { + $this->validValues[$f[$this->valuesDbIdField]] = $f[$this->valuesDbNameField]; + $this->validValues[$f[$this->valuesDbIdField] . '_children'] = + sprintf(_('%s und Untereinrichtungen'), + $f[$this->valuesDbNameField]); + $institutes = DBManager::get()->fetchAll( + "SELECT `Institut_id`, `Name` FROM `Institute` + WHERE `fakultaets_id` = :fak AND `Institut_id` != :fak AND `Institut_id` IN (:allowed) + ORDER BY `Name`", + ['fak' => $f[$this->valuesDbIdField], 'allowed' => $allowed['allowed_institutes']] + ); + foreach ($institutes as $i) { + $this->validValues[$i[$this->valuesDbIdField]] = $i[$this->valuesDbNameField]; + } + } + + $institutes = DBManager::get()->fetchAll( + "SELECT `Institut_id`, `Name` + FROM `Institute` + WHERE `Institut_id` IN (:allowed) + AND `Institut_id` NOT IN (:processed) + ORDER BY `Name`", + [ + 'allowed' => $allowed['allowed_institutes'], + 'processed' => count($this->validValues) > 0 ? array_keys($this->validValues) : '' + ] + ); + foreach ($institutes as $i) { + $this->validValues[$i[$this->valuesDbIdField]] = $i[$this->valuesDbNameField]; + } + } + } + + public function getName() + { + return _('Selbst zugeordnete Einrichtung'); + } + + /** + * Gets all users belonging to a statusgroup with the given name. This is not done via statusgroup_id + * in ordner to enable several institutes as filter. + * + * @return array All users that are affected by the current condition + * field. + */ + public function getUsers($restrictions = []) + { + $users = []; + if (MassMailPermission::has(User::findCurrent()->id, true)) { + $users = DBManager::get()->fetchFirst("SELECT DISTINCT `user_id` " . + "FROM `" . $this->userDataDbTable . "` " . + "WHERE `" . $this->userDataDbField . "`" . $this->compareOperator . + "? AND `inst_perms` = 'user'", [$this->value]); + } else if (MassMailPermission::has(User::findCurrent()->id)) { + + $allowed = MassMailPermission::getForUser(User::findCurrent()); + + $sql = "SELECT DISTINCT `" . $this->userDataDbTable . "`.`user_id` FROM `" . $this->userDataDbTable + . "`JOIN `user_inst` USING (`user_id`) "; + $where = "WHERE `" . $this->userDataDbTable . "`.`" . $this->userDataDbField . "`" . $this->compareOperator + . ":value AND `user_inst`.`Institut_id` IN (:institutes) AND `user_inst`.`inst_perms` = 'user'"; + $parameters = [ + 'value' => $this->value, + 'institutes' => $allowed['institutes']->pluck('id') + ]; + + $users = DBManager::get()->fetchFirst($sql.$where, $parameters); + } + + return $users; + } +} diff --git a/lib/classes/UserFilterFields/MassMail/MassMailSemesterOfStudyFilter.php b/lib/classes/UserFilterFields/MassMail/MassMailSemesterOfStudyFilter.php new file mode 100644 index 00000000000..f92c220079d --- /dev/null +++ b/lib/classes/UserFilterFields/MassMail/MassMailSemesterOfStudyFilter.php @@ -0,0 +1,75 @@ +<?php + +namespace UserFilterFields\MassMail; + +use UserFilterFields\SemesterOfStudyCondition; + +class MassMailSemesterOfStudyFilter extends SemesterOfStudyCondition +{ + // --- ATTRIBUTES --- + public $valuesDbTable = 'user_studiengang'; + public $valuesDbIdField = 'semester'; + public $userDataDbTable = 'user_studiengang'; + public $userDataDbField = 'semester'; + + /** + * @see \UserFilterField::getTargets() + */ + public static function getTargets() + { + return ['students']; + } + + /** + * @see UserFilterField::__construct + */ + public function __construct($fieldId='') + { + parent::__construct($fieldId); + $this->relations = [ + 'MassMailDegreeFilter' => [ + 'local_field' => 'abschluss_id', + 'foreign_field' => 'abschluss_id' + ], + 'MassMailSubjectFilter' => [ + 'local_field' => 'fach_id', + 'foreign_field' => 'fach_id' + ] + ]; + $this->validCompareOperators = [ + '>=' => _('mindestens'), + '<=' => _('höchstens'), + '=' => _('ist'), + '!=' => _('ist nicht') + ]; + if (isset(self::$cached_valid_values[static::class])) { + $this->validValues = self::$cached_valid_values[static::class]; + } else { + // Initialize to some value in case there are no semester numbers. + $maxsem = 15; + // Calculate the maximal available semester. + $stmt = \DBManager::get()->query("SELECT MAX(" . $this->valuesDbIdField . ") AS maxsem " . + "FROM `" . $this->valuesDbTable . "`"); + if ($current = $stmt->fetch(\PDO::FETCH_ASSOC)) { + if ($current['maxsem']) { + $maxsem = $current['maxsem']; + } + } + for ($i = 1; $i <= $maxsem; $i++) { + $this->validValues[$i] = $i; + } + self::$cached_valid_values[static::class] = $this->validValues; + } + } + + /** + * Get this field's display name. + * + * @return String + */ + public function getName() + { + return _('Fachsemester'); + } + +} diff --git a/lib/classes/UserFilterFields/MassMail/MassMailStatusgroupFilter.php b/lib/classes/UserFilterFields/MassMail/MassMailStatusgroupFilter.php new file mode 100644 index 00000000000..61a04be882a --- /dev/null +++ b/lib/classes/UserFilterFields/MassMail/MassMailStatusgroupFilter.php @@ -0,0 +1,88 @@ +<?php + +namespace UserFilterFields\MassMail; + +use UserFilterField; +use MassMail\MassMailPermission; +use User; +use DBManager; + +class MassMailStatusgroupFilter extends UserFilterField +{ + public $valuesDbTable = 'statusgruppen'; + public $valuesDbIdField = 'statusgruppe_id'; + public $valuesDbNameField = 'name'; + public $userDataDbTable = 'statusgruppe_user'; + public $userDataDbField = 'statusgruppe_id'; + + public static $sortOrder = 10; + + public static function getTargets() + { + return ['employees']; + } + + public function __construct($fieldId = '') + { + parent::__construct($fieldId); + + $this->validCompareOperators = [ + '=' => _('ist'), + '!=' => _('ist nicht'), + ]; + + $this->validValues = []; + if (MassMailPermission::has(User::findCurrent()->id, true)) { + $this->validValues = DBManager::get()->fetchFirst( + "SELECT DISTINCT `name` FROM `statusgruppen` ORDER BY `name` ASC" + ); + } else if (MassMailPermission::has(User::findCurrent()->id)) { + $allowed = MassMailPermission::getForUser(User::findCurrent()); + + $this->validValues = DBManager::get()->fetchFirst( + "SELECT DISTINCT `name` FROM `statusgruppen` WHERE `range_id` IN (:institutes) ORDER BY `name` ASC", + ['institutes' => $allowed['allowed_institutes']] + ); + } + } + + public function getName() + { + return _('Statusgruppe'); + } + + /** + * Gets all users belonging to a statusgroup with the given name. This is not done via statusgroup_id + * in ordner to enable several institutes as filter. + * + * @return array All users that are affected by the current condition + * field. + */ + public function getUsers($restrictions = []) + { + $users = []; + if (MassMailPermission::has(User::findCurrent()->id, true)) { + $users = DBManager::get()->fetchFirst("SELECT DISTINCT `user_id` " . + "FROM `" . $this->userDataDbTable . "` " . + "WHERE `" . $this->userDataDbField . "`" . $this->compareOperator . + "?", [$this->value]); + } else if (MassMailPermission::has(User::findCurrent()->id)) { + + $allowed = MassMailPermission::getForUser(User::findCurrent()); + + $sql = "SELECT DISTINCT `" . $this->userDataDbTable . "`.`user_id` FROM `" . $this->userDataDbTable + . "`JOIN `user_inst` USING (`user_id`) "; + $where = "WHERE `" . $this->userDataDbTable . "`.`" . $this->userDataDbField . "`" . $this->compareOperator + . ":value AND `user_inst`.`Institut_id` IN (:institutes) AND `user_inst`.`inst_perms` IN (:perms)"; + $parameters = [ + 'value' => $this->value, + 'institutes' => $allowed['allowed_institutes'], + 'perms' => ['autor', 'tutor', 'dozent', 'admin'] + ]; + + $users = DBManager::get()->fetchFirst($sql.$where, $parameters); + } + + return $users; + } +} diff --git a/lib/classes/UserFilterFields/MassMail/MassMailSubjectFilter.php b/lib/classes/UserFilterFields/MassMail/MassMailSubjectFilter.php new file mode 100644 index 00000000000..977b2775924 --- /dev/null +++ b/lib/classes/UserFilterFields/MassMail/MassMailSubjectFilter.php @@ -0,0 +1,125 @@ +<?php + +namespace UserFilterFields\MassMail; + +use UserFilterFields\SubjectCondition; + +class MassMailSubjectFilter extends SubjectCondition +{ + /** + * @see \UserFilterField::getTargets() + */ + public static function getTargets() + { + return ['students']; + } + + public function __construct($fieldId = '') + { + parent::__construct($fieldId); + + if (!\MassMail\MassMailPermission::has(\User::findCurrent()->id, true)) { + $this->validValues = []; + + $permission = \MassMail\MassMailPermission::getForUser(\User::findCurrent(), true); + + foreach ($permission['allowed_subjects'] as [$id, $name]) { + $this->validValues[$id] = (string) $name; + } + } + } + + public function getUsers($restrictions = []) + { + $users = []; + + if (\MassMail\MassMailPermission::has(\User::findCurrent()->id, true)) { + $users = parent::getUsers($restrictions); + } else if (count($this->validValues) > 0) { + // Standard query getting the values without respecting other values. + $select = "SELECT DISTINCT `" . $this->userDataDbTable . "`.`user_id` "; + $from = "FROM `" . $this->userDataDbTable . "` "; + $where = "WHERE `" . $this->userDataDbTable . "`.`" . $this->userDataDbField . + "`" . $this->compareOperator . "?"; + $parameters = [$this->value]; + $joinedTables = [ + $this->userDataDbTable => true + ]; + // Check if there are restrictions given. + foreach ($restrictions as $otherField => $restriction) { + // We only take the value into consideration if it represents a valid restriction. + if ($this->relations[$otherField]) { + // Do we need to join in another table? + if (!$joinedTables[$restriction['table']]) { + $joinedTables[$restriction['table']] = true; + $from .= " INNER JOIN `" . $restriction['table'] . "` ON (`" . + $this->userDataDbTable . "`.`" . + $this->relations[$otherField]['local_field'] . "`=`" . + $restriction['table'] . "`.`" . + $this->relations[$otherField]['foreign_field'] . "`)"; + } + // Expand WHERE statement with the value from restriction. + $where .= " AND `" . $restriction['table'] . "`.`" . + $restriction['field'] . "`" . $restriction['compare'] . "?"; + $parameters[] = $restriction['value']; + } + } + + $where .= " AND `" . $this->userDataDbTable . "`.`" . $this->userDataDbField . "` IN (?)"; + $parameters[] = array_keys($this->validValues); + + // Get all the users that fulfill the condition. + $users = \DBManager::get()->fetchFirst($select . $from . $where, $parameters); + } + + return $users; + } + + /** + * Gets the value for the given user that is relevant for this + * condition field. Here, this method looks up the study degree(s) + * for the user. These can then be compared with the required degrees + * whether they fit. + * + * @param String $userId User to check. + * @param array $additional conditions that are required for check. + * @return array The value(s) for this user. + */ + public function getUserValues($userId, $additional = null) + { + if (\MassMail\MassMailPermission::has(\User::findCurrent()->id, true)) { + $result = parent::getUserValues($userId, $additional); + } else { + $result = []; + $query = "SELECT DISTINCT `" . $this->userDataDbField . "` " . + "FROM `" . $this->userDataDbTable . "` " . + "WHERE `user_id`=?"; + $parameters = [$userId]; + // Additional requirements given... + if (is_array($additional)) { + + // Don't use the same database field twice as this can only get ugly. + $usedFields = [$this->userDataDbField]; + + foreach ($additional as $a_condition) { + if ( + $a_condition->id != $this->id + && $this->userDataDbTable === $a_condition->userDataDbTable + && !in_array($a_condition->userDataDbField, $usedFields) + ) { + $query .= " AND `" . $a_condition->userDataDbField . "` " . $a_condition->compareOperator . "?"; + $parameters[] = $a_condition->value; + } + } + } + // Get semester of study for user. + $stmt = \DBManager::get()->prepare($query); + $stmt->execute($parameters); + while ($current = $stmt->fetch(\PDO::FETCH_ASSOC)) { + $result[] = $current[$this->userDataDbField]; + } + } + return $result; + } + +} diff --git a/lib/classes/admission/userfilter/PermissionCondition.php b/lib/classes/UserFilterFields/PermissionCondition.php similarity index 90% rename from lib/classes/admission/userfilter/PermissionCondition.php rename to lib/classes/UserFilterFields/PermissionCondition.php index fe9458c6f3a..10212c76128 100644 --- a/lib/classes/admission/userfilter/PermissionCondition.php +++ b/lib/classes/UserFilterFields/PermissionCondition.php @@ -1,4 +1,5 @@ <?php + /** * PermissionCondition.php * @@ -13,9 +14,11 @@ * @license http://www.gnu.org/licenses/gpl-2.0.html GPL version 2 * @category Stud.IP */ -class PermissionCondition extends UserFilterField +namespace UserFilterFields; + +class PermissionCondition extends \UserFilterField { - public $sortOrder = 7; + public static $sortOrder = 7; /** * @see UserFilterField::__construct diff --git a/lib/classes/admission/userfilter/SemesterOfStudyCondition.php b/lib/classes/UserFilterFields/SemesterOfStudyCondition.php similarity index 89% rename from lib/classes/admission/userfilter/SemesterOfStudyCondition.php rename to lib/classes/UserFilterFields/SemesterOfStudyCondition.php index 5794f759e1e..f66789fc044 100644 --- a/lib/classes/admission/userfilter/SemesterOfStudyCondition.php +++ b/lib/classes/UserFilterFields/SemesterOfStudyCondition.php @@ -1,4 +1,5 @@ <?php + /** * SemesterOfStudyCondition.php * @@ -13,7 +14,9 @@ * @license http://www.gnu.org/licenses/gpl-2.0.html GPL version 2 * @category Stud.IP */ -class SemesterOfStudyCondition extends UserFilterField +namespace UserFilterFields; + +class SemesterOfStudyCondition extends \UserFilterField { // --- ATTRIBUTES --- public $valuesDbTable = 'user_studiengang'; @@ -21,7 +24,7 @@ class SemesterOfStudyCondition extends UserFilterField public $userDataDbTable = 'user_studiengang'; public $userDataDbField = 'semester'; - public $sortOrder = 4; + public static $sortOrder = 4; // --- OPERATIONS --- @@ -54,9 +57,9 @@ class SemesterOfStudyCondition extends UserFilterField // Initialize to some value in case there are no semester numbers. $maxsem = 15; // Calculate the maximal available semester. - $stmt = DBManager::get()->query("SELECT MAX(" . $this->valuesDbIdField . ") AS maxsem " . + $stmt = \DBManager::get()->query("SELECT MAX(" . $this->valuesDbIdField . ") AS maxsem " . "FROM `" . $this->valuesDbTable . "`"); - if ($current = $stmt->fetch(PDO::FETCH_ASSOC)) { + if ($current = $stmt->fetch(\PDO::FETCH_ASSOC)) { if ($current['maxsem']) { $maxsem = $current['maxsem']; } diff --git a/lib/classes/admission/userfilter/StgteilVersionCondition.php b/lib/classes/UserFilterFields/StgteilVersionCondition.php similarity index 84% rename from lib/classes/admission/userfilter/StgteilVersionCondition.php rename to lib/classes/UserFilterFields/StgteilVersionCondition.php index ec5c1f38033..59bb035aa1f 100644 --- a/lib/classes/admission/userfilter/StgteilVersionCondition.php +++ b/lib/classes/UserFilterFields/StgteilVersionCondition.php @@ -1,4 +1,5 @@ <?php + /** * StgteilVersionCondition.php * @@ -13,7 +14,9 @@ * @license http://www.gnu.org/licenses/gpl-2.0.html GPL version 2 * @category Stud.IP */ -class StgteilVersionCondition extends UserFilterField +namespace UserFilterFields; + +class StgteilVersionCondition extends \UserFilterField { // --- ATTRIBUTES --- public $valuesDbTable = 'mvv_stgteilversion'; @@ -22,14 +25,14 @@ class StgteilVersionCondition extends UserFilterField public $userDataDbTable = 'user_studiengang'; public $userDataDbField = 'version_id'; - public $sortOrder = 5; + public static $sortOrder = 5; public static $isParameterized = true; public static function getParameterizedTypes() { - if (Config::get()->DISPLAY_STGTEILVERSION_USERFILTER) { - $filter = new StgteilVersionCondition; + if (\Config::get()->DISPLAY_STGTEILVERSION_USERFILTER) { + $filter = new StgteilVersionCondition(); $fields['StgteilVersionCondition'] = $filter->getName(); return $fields; } else { @@ -48,13 +51,13 @@ class StgteilVersionCondition extends UserFilterField ]; if ($this->valuesDbNameField) { // Get all available values from database. - $stmt = DBManager::get()->query( + $stmt = \DBManager::get()->query( "SELECT DISTINCT `version_id`, `fach`.`name` ". "FROM `mvv_stgteilversion` LEFT JOIN mvv_stgteil USING (stgteil_id)". "LEFT JOIN fach USING (fach_id)". "WHERE `mvv_stgteilversion`.`stat` = 'genehmigt' ORDER BY `fach`.`name` ASC"); - while ($current = $stmt->fetch(PDO::FETCH_ASSOC)) { + while ($current = $stmt->fetch(\PDO::FETCH_ASSOC)) { $this->validValues[$current[$this->valuesDbIdField]] = $current[$this->valuesDbNameField]; } } @@ -66,7 +69,7 @@ class StgteilVersionCondition extends UserFilterField } foreach ($this->validValues as $version_id => $name) { - $stgteilversion = StgteilVersion::find($version_id); + $stgteilversion = \StgteilVersion::find($version_id); $this->validValues[$version_id] = $stgteilversion->getDisplayName(); } } diff --git a/lib/classes/admission/userfilter/SubjectCondition.php b/lib/classes/UserFilterFields/SubjectCondition.php similarity index 92% rename from lib/classes/admission/userfilter/SubjectCondition.php rename to lib/classes/UserFilterFields/SubjectCondition.php index 7aa5f26cc36..e9ac1a07510 100644 --- a/lib/classes/admission/userfilter/SubjectCondition.php +++ b/lib/classes/UserFilterFields/SubjectCondition.php @@ -1,4 +1,5 @@ <?php + /** * SubjectCondition.php * @@ -13,7 +14,9 @@ * @license http://www.gnu.org/licenses/gpl-2.0.html GPL version 2 * @category Stud.IP */ -class SubjectCondition extends UserFilterField +namespace UserFilterFields; + +class SubjectCondition extends \UserFilterField { // --- ATTRIBUTES --- public $valuesDbTable = 'fach'; @@ -22,7 +25,7 @@ class SubjectCondition extends UserFilterField public $userDataDbTable = 'user_studiengang'; public $userDataDbField = 'fach_id'; - public $sortOrder = 2; + public static $sortOrder = 2; // --- OPERATIONS --- diff --git a/lib/classes/admission/userfilter/SubjectConditionAny.php b/lib/classes/UserFilterFields/SubjectConditionAny.php similarity index 88% rename from lib/classes/admission/userfilter/SubjectConditionAny.php rename to lib/classes/UserFilterFields/SubjectConditionAny.php index 3a3712ba040..c99bcb8dd7b 100644 --- a/lib/classes/admission/userfilter/SubjectConditionAny.php +++ b/lib/classes/UserFilterFields/SubjectConditionAny.php @@ -14,15 +14,15 @@ * @category Stud.IP */ -require_once realpath(__DIR__ . '/..') . '/UserFilterField.php'; +namespace UserFilterFields; -class SubjectConditionAny extends UserFilterField +class SubjectConditionAny extends \UserFilterField { // --- ATTRIBUTES --- public $userDataDbTable = 'user_studiengang'; public $userDataDbField = 'fach_id'; - public $sortOrder = 3; + public static $sortOrder = 3; // --- OPERATIONS --- diff --git a/lib/classes/UserFilterRange.php b/lib/classes/UserFilterRange.php new file mode 100644 index 00000000000..a5a53e1810f --- /dev/null +++ b/lib/classes/UserFilterRange.php @@ -0,0 +1,29 @@ +<?php + +/** + * UserFilterRange.php + * + * An interface that provides information about necessary permissions for editing a UserFilter object. + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License as + * published by the Free Software Foundation; either version 2 of + * the License, or (at your option) any later version. + * + * @author Thomas Hackl <thomas.hackl@uni-passau.de> + * @since 6.0 + * @license http://www.gnu.org/licenses/gpl-2.0.html GPL version 2 + * @category Stud.IP + */ + +interface UserFilterRange { + + /** + * Check whether the given user can edit the given UserFilter object. + * @param User $user + * @param UserFilter $filter + * @return bool + */ + public function canEditFilter(User $user, UserFilter $filter): bool; + +} diff --git a/lib/classes/admission/CourseSet.php b/lib/classes/admission/CourseSet.php index e7f3dee9d39..d93cfb0e2ac 100644 --- a/lib/classes/admission/CourseSet.php +++ b/lib/classes/admission/CourseSet.php @@ -15,7 +15,7 @@ * @category Stud.IP */ -class CourseSet +class CourseSet implements UserFilterRange { // --- ATTRIBUTES --- @@ -969,6 +969,7 @@ class CourseSet } // Store all rules. foreach ($this->admissionRules as $rule) { + $rule->courseSetId = $this->id; // Store each rule... $rule->store(); // ... and its connection to the current course set. @@ -1194,4 +1195,47 @@ class CourseSet $this->admissionRules = $cloned_rules; } + /** + * @see UserFilterRange::canEdit() + */ + public function canEditFilter(User $user, UserFilter $filter): bool + { + if ($GLOBALS['perm']->have_perm('root', $user->id)) { + return true; + } + + // Check general permissions on course set creation/editing. + $permission = $GLOBALS['perm']->have_perm('admin', $user->id) + || ( + Config::get()->ALLOW_DOZENT_COURSESET_ADMIN + && $GLOBALS['perm']->have_perm('dozent', $user->id) + ); + + // Get all rules where filter can be present. + $ruleTypes = array_filter( + $this->getAdmissionRules(), + fn($rule) => in_array(get_class($rule), [ConditionalAdmission::class, PreferentialAdmission::class]) + ); + + // Get my institute's IDs. + $institutes = array_map( + fn ($i) => $i['Institut_id'], + Institute::getMyInstitutes($user->id) + ); + $matchingInstitutes = array_intersect(array_keys($this->institutes), $institutes); + + /* + * Check whether: + * - this course set has rules than can have UserFilter objects + * - the given user is allowed to create/edit course sets at all + * - this course set belongs to the given user or is not private and belongs to one of this user's institutes + */ + return $permission + && count($ruleTypes) > 0 + && ( + $this->user_id === $user->id + || !$this->private && count($matchingInstitutes) > 0 + ); + } + } /* end of class CourseSet */ diff --git a/lib/classes/forms/CheckboxCollectionInput.php b/lib/classes/forms/CheckboxCollectionInput.php new file mode 100644 index 00000000000..7859b9ac6b5 --- /dev/null +++ b/lib/classes/forms/CheckboxCollectionInput.php @@ -0,0 +1,25 @@ +<?php + +namespace Studip\Forms; + +class CheckboxCollectionInput extends Input +{ + public function render() + { + $template = $GLOBALS['template_factory']->open('forms/checkbox_collection_input'); + $template->title = $this->title; + $template->name = $this->name; + $template->selected = $this->value; + $template->required = $this->required; + + $template->collapsable = $this->attributes['collapsable'] ?? false; + if (isset($this->attributes['collapsable'])) { + unset($this->attributes['collapsable']); + } + $options = $this->extractOptionsFromAttributes($this->attributes); + + $template->attributes = arrayToHtmlAttributes($this->attributes); + $template->options = $options; + return $template->render(); + } +} diff --git a/lib/classes/forms/Fieldset.php b/lib/classes/forms/Fieldset.php index e7bced0a1b8..d1915bd3d52 100644 --- a/lib/classes/forms/Fieldset.php +++ b/lib/classes/forms/Fieldset.php @@ -5,6 +5,8 @@ namespace Studip\Forms; class Fieldset extends Part { protected $legend = null; + protected bool $collapsable = false; + protected bool $collapsed = false; public function __construct($legend = null) { @@ -16,10 +18,25 @@ class Fieldset extends Part $this->legend = $legend; } + + public function setCollapsable(bool $state = true): Fieldset + { + $this->collapsable = $state; + return $this; + } + + public function setCollapsed(bool $state = true): Fieldset + { + $this->collapsed = $state; + return $this; + } + public function render() { $template = $GLOBALS['template_factory']->open('forms/fieldset'); $template->legend = $this->legend; + $template->collapsable = $this->collapsable; + $template->collapsed = $this->collapsable && $this->collapsed; $template->parts = $this->parts; return $template->render(); } diff --git a/lib/classes/forms/FileInput.php b/lib/classes/forms/FileInput.php new file mode 100644 index 00000000000..5862b3c8788 --- /dev/null +++ b/lib/classes/forms/FileInput.php @@ -0,0 +1,23 @@ +<?php + +namespace Studip\Forms; + +class FileInput extends Input +{ + + public function render() + { + $template = $GLOBALS['template_factory']->open('forms/file_input'); + $template->title = $this->title; + $template->name = $this->name; + $template->folder = $this->value; + $template->id = md5(uniqid()); + $template->uploadUrl = $this->attributes['upload_url']; + $template->multiple = $this->attributes['multiple'] ?? ''; + $template->accept = $this->attributes['accept'] ?? '*/*'; + $template->required = $this->attributes['required'] ?? ''; + + return $template->render(); + } + +} diff --git a/lib/classes/forms/Form.php b/lib/classes/forms/Form.php index 0148c9ccb81..f9b27cd3cee 100644 --- a/lib/classes/forms/Form.php +++ b/lib/classes/forms/Form.php @@ -151,7 +151,9 @@ class Form extends Part //Now initializing the fieldset: $fieldset = new Fieldset($params['legend'] ?: _("Daten")); - $fieldset->setContextObject($object); + $fieldset->setContextObject($object) + ->setCollapsable($params['collapsable'] ?? false) + ->setCollapsed($params['collapsed'] ?? false); $this->addPart($fieldset); foreach ($fields as $fieldname => $fielddata) { @@ -578,4 +580,20 @@ class Form extends Part } return $value; } + + /** + * Checks whether this form has a file input and thus needs its enctype set. + * @return bool + */ + public function hasFileInput() + { + foreach ($this->getAllInputs() as $input) { + if (get_class($input) === FileInput::class) { + return true; + } + } + + return false; + } + } diff --git a/lib/classes/forms/QuicksearchListInput.php b/lib/classes/forms/QuicksearchListInput.php new file mode 100644 index 00000000000..3cbc29a35d0 --- /dev/null +++ b/lib/classes/forms/QuicksearchListInput.php @@ -0,0 +1,19 @@ +<?php + +namespace Studip\Forms; + +class QuicksearchListInput extends Input +{ + public function render() + { + $template = $GLOBALS['template_factory']->open('forms/quicksearchlist_input'); + $template->title = $this->title; + $template->name = $this->name; + $template->value = $this->value; + $template->id = md5(uniqid()); + $template->required = $this->required; + $template->attributes = arrayToHtmlAttributes($this->attributes); + + return $template->render(); + } +} diff --git a/lib/classes/forms/SerialWysiwygInput.php b/lib/classes/forms/SerialWysiwygInput.php new file mode 100644 index 00000000000..3d4551f248c --- /dev/null +++ b/lib/classes/forms/SerialWysiwygInput.php @@ -0,0 +1,34 @@ +<?php + +namespace Studip\Forms; + +use MassMail\MassMailMarker; + +class SerialWysiwygInput extends WysiwygInput +{ + + public function render() + { + if (!isset($this->attributes['id'])) { + $id = md5(uniqid()); + $this->attributes['id'] = $id; + } else { + $id = $this->attributes['id']; + } + + $template = $GLOBALS['template_factory']->open('forms/serial_wysiwyg_input'); + $template->title = $this->title; + $template->name = $this->name; + $template->value = $this->value; + $template->id = $id; + $template->required = $this->required; + $template->markers = $this->attributes['markers']; + $template->attributes = $this->attributes; + return $template->render(); + } + + public function getRequestValue() + { + return \Studip\Markup::purifyHtml(\Request::get($this->name)); + } +} diff --git a/lib/classes/forms/UserFilterInput.php b/lib/classes/forms/UserFilterInput.php new file mode 100644 index 00000000000..0b415b72689 --- /dev/null +++ b/lib/classes/forms/UserFilterInput.php @@ -0,0 +1,60 @@ +<?php + +namespace Studip\Forms; + +/** + * The Text class represents a part of a form that displays a user filter selection. + */ +class UserFilterInput extends Input +{ + + public function getValue() + { + $value = []; + foreach ($this->getContextObject()->filters as $connection) { + $filter = $connection->userfilter; + $one = [ + 'id' => $filter->getId(), + 'attributes' => [ + 'text' => $filter->toString(), + 'fields' => [] + ] + ]; + foreach ($filter->getFields() as $field) { + $one['attributes']['fields'][] = [ + 'id' => $field->getId(), + 'attributes' => [ + 'type' => get_class($field), + 'compare-operator' => $field->getCompareOperator(), + 'value' => $field->getValue() + ] + ]; + } + $value[] = $one; + } + return json_encode($value); + } + + public function getRequestValue() + { + return json_decode(\Request::get($this->name), true); + } + + public function hasValidation(): bool + { + return false; + } + + public function render(): string + { + $template = $GLOBALS['template_factory']->open('forms/user_filter_input'); + $template->title = $this->title; + $template->name = $this->name; + $template->value = $this->value; + $template->id = md5(uniqid()); + $template->required = $this->required; + $template->attributes = arrayToHtmlAttributes($this->attributes); + return $template->render(); + } + +} diff --git a/lib/cronjobs/send_massmails.php b/lib/cronjobs/send_massmails.php new file mode 100644 index 00000000000..f712f603769 --- /dev/null +++ b/lib/cronjobs/send_massmails.php @@ -0,0 +1,107 @@ +<?php +/** + * send_massmails.php + * + * @author Thomas Hackl <hackl@data-quest.de> + * @access public + * @since 6.0 + */ + +/** + * Cronjob class to send massmails. + */ +class SendMassmailsJob extends CronJob +{ + + /** + * Returns the name of the cronjob. + * @return string : name of the cronjob + */ + public static function getName() + { + return _('Nachrichten an Zielgruppen senden'); + } + + /** + * Returns the description of the cronjob. + * @return string : description of the cronjob. + */ + public static function getDescription() + { + return _('Sendet alle anstehenden Nachrichten an Zielgruppen und räumt bereits gesendete auf.'); + } + + /** + * Sends all mass mails. + * @param integer $last_result : not evaluated for execution, so any integer + * will do. Usually it would be a unix-timestamp of last execution. But in + * this case we don't care at all. + * @param array $parameters : not needed here + */ + public function execute($last_result, $parameters = []) + { + // Find all messages that need to be sent: + foreach (\MassMail\MassMailMessage::findUnsent() as $message) { + // Mark message as "currently working on". + $message->locked = 1; + $message->store(); + + $messaging = new messaging(); + + // Markers present: this must be a personalized message to every recipient. + if ($message->hasMarkers()) { + + foreach ($message->getRecipients() as $recipient) { + + $mail = new Message(); + $mail->setId($mail->getNewId()); + + $result = $messaging->insert_message( + $message->replaceMarkers(User::findOneByUsername($recipient)), + $recipient, + $message->sender_id, + time(), + $mail->id, + '', + '', + $message->subject + ); + + echo sprintf("Sending message %s to %s\n", $message->subject, $recipient); + } + + // No markers -> we can send this as one single message to everyone at once. + } else { + + $mail = new Message(); + $mail->setId($mail->getNewId()); + + $result = $messaging->insert_message( + $message->message, + $message->getRecipients(), + $message->sender_id, + time(), + $mail->id, + '', + '', + $message->subject + ); + + echo sprintf("Sending message %s to %u recipients\n", $message->subject, count($message->getRecipients())); + } + + if ($result) { + echo "Success!\n"; + $message->locked = 0; + $message->sent = 1; + $message->store(); + } + + } + + // Now cleanup all messages that have been sent and are older than the configured number of days. + foreach (\MassMail\MassMailMessage::findObsolete() as $message) { + $message->delete(); + } + } +} diff --git a/lib/models/MassMail/MassMailFilter.php b/lib/models/MassMail/MassMailFilter.php new file mode 100644 index 00000000000..8d88c65a886 --- /dev/null +++ b/lib/models/MassMail/MassMailFilter.php @@ -0,0 +1,34 @@ +<?php + +namespace MassMail; + +class MassMailFilter extends \SimpleORMap +{ + + protected static function configure($config = []) + { + $config['db_table'] = 'massmail_filter'; + + $config['additional_fields']['userfilter']['get'] = function ($entry) { + return new \UserFilter($entry->filter_id); + }; + $config['registered_callbacks']['before_delete'][] = 'cbDeleteUserFilter'; + $config['registered_callbacks']['after_store'][] = 'cbUpdateUserFilterRange'; + + parent::configure($config); + } + + public function cbDeleteUserFilter() + { + $filter = new \UserFilter($this->filter_id); + $filter->delete(); + } + + public function cbUpdateUserFilterRange() + { + $filter = new \UserFilter($this->filter_id); + $filter->setRange(MassMailMessage::class, $this->message_id); + $filter->store(); + } + +} diff --git a/lib/models/MassMail/MassMailMarker.php b/lib/models/MassMail/MassMailMarker.php new file mode 100644 index 00000000000..d3dca6e4f9d --- /dev/null +++ b/lib/models/MassMail/MassMailMarker.php @@ -0,0 +1,181 @@ +<?php + +namespace MassMail; + +use \User, \DBManager, \StudipPDO, \PDO; + +class MassMailMarker extends \SimpleORMap +{ + + /* + * This seems to be necessary because of the direct reference in getDescription. + * Otherwise a PHP warning is thrown. + */ + public string $description; + + protected static function configure($config = []) + { + $config['db_table'] = 'massmail_markers'; + + parent::configure($config); + } + + public static function findAll($root = false) { + return $root + ? static::findBySQL("1 ORDER BY `position`") + : static::findbyRoot_only("0 ORDER BY `position`"); + } + + /** + * Replaces markers contained in the given text with their replacement value for the given user. + * + * @param string $text + * @param User $user + * @param MassMailMarker[] $markers + * @return string|string[] + */ + public static function processText(string $text, User $user, array $markers) { + $find = []; + $replace = []; + foreach ($markers as $marker) { + if ((!$marker->root_only || MassMailPermission::has(User::findCurrent()->id, true)) + && strpos($text, '{{' . $marker->marker . '}}') !== false + && $marker->type != 'token') { + $find[] = '{{' . $marker->marker . '}}'; + $replace[] = $marker->replaceMarker($user); + } + } + $text = str_replace($find, $replace, $text); + return $text; + } + + /** + * Replaces tokens in the given text with a token for the given user. + * @param int $message_id + * @param string $text + * @param User $user + * @return string|string[] + */ + public static function processToken(int $message_id, string $text, User $user) + { + foreach (self::findByType('token') as $marker) { + if ((!$marker->root_only || MassMailPermission::has(User::findCurrent()->id, true)) && + strpos($text, '{{' . $marker->marker . '}}') !== false) { + $text = str_replace('{{' . $marker->marker . '}}', + $marker->getReplacementToken($message_id, $user), + $text + ); + } + } + return $text; + } + + /** + * This is a helper get function which gets the translated marker description. As the regular i18 mechanism for + * translateable content is not working here (thie is just shown in the GUI but stored dynamically in the database) + * I really do not know how to do that otherwise. + * + * @return string + */ + public function getDescription(): string + { + return _($this->description); + } + + /** + * Replaces the current marker text according to the given user. + * @param User $user + * @return array|mixed|\SimpleORMapCollection|string|string[]|void|null + */ + public function replaceMarker(User $user) + { + $replacement = $this->replacement; + + switch ($user->geschlecht) { + case 2: + if ($this->replacement_female) { + $replacement = $this->replacement_female; + } + break; + case 0: + case 3: + if ($this->replacement_unknown) { + $replacement = $this->replacement_unknown; + } + break; + } + + switch ($this->type) { + // Just plain text replacing the marker, just check if other markers are included here. + case 'text': + if (strpos($replacement, '{{') !== false) { + $matches = []; + preg_match_all('/{{([a-zA-Z0-9\-_]+)}}/m', $replacement, $matches); + foreach ($matches[1] as $match) { + $replacement = str_replace('{' . $match . '}', + MassMailMarker::findOneByMarker(trim($match))->replaceMarker($user), + $replacement + ); + } + } + return $replacement; + + // Content from one or more database columns replaces the marker. + case 'database': + $data = words($replacement); + $find = []; + $replace = []; + foreach ($data as $entry) { + if (strpos($entry, '{') !== false) { + $matches = []; + preg_match_all('/{{([a-zA-Z0-9\-_]+)}}/m', $entry, $matches); + foreach ($matches[1] as $match) { + $replacement = str_replace($entry, + MassMailMarker::findOneByMarker(trim($match))->replaceMarker($user), + $replacement + ); + } + } else { + // Extract the database fields... + [$table, $column] = explode('.', $entry); + // ... and query database for values to insert. + $stmt = DBManager::get()->prepare("SELECT `:column` + FROM `:table` WHERE `user_id` = :userid LIMIT 1"); + $stmt->bindParam('column', $column, StudipPDO::PARAM_COLUMN); + $stmt->bindParam('table', $table, StudipPDO::PARAM_COLUMN); + $stmt->bindParam('userid', $user->id); + $stmt->execute(); + $dbdata = $stmt->fetch(PDO::FETCH_ASSOC); + $replacement = str_replace($entry, $dbdata[$column], $replacement); + } + } + // If we have empty values from database, there could be excess whitespace -> remove. + return trim(preg_replace('/(\s)+/', ' ', $replacement)); + + // The marker is replaced by the result of a function call. + case 'function': + $data = words($replacement); + $function = array_shift($data); + return call_user_func_array($function, $data); + } + } + + /** + * Gets a token and assigns it to the given user. + */ + public function getReplacementToken($message_id, $user): string + { + $token = MassMailToken::findOneBySQL( + "`message_id` = :id AND `user_id`IS NULL" + ); + + if ($token) { + $token->user_id = $user->id; + $token->store(); + return $token->token; + } else { + throw new \Exception('No free token available.'); + } + } + +} diff --git a/lib/models/MassMail/MassMailMessage.php b/lib/models/MassMail/MassMailMessage.php new file mode 100644 index 00000000000..d2cd8441c65 --- /dev/null +++ b/lib/models/MassMail/MassMailMessage.php @@ -0,0 +1,373 @@ +<?php + +namespace MassMail; + +use \Semester, \DBManager, \UserFilter, \Folder, \User, \Config; + +class MassMailMessage extends \SimpleORMap implements \UserFilterRange +{ + + protected static function configure($config = []) + { + $config['db_table'] = 'massmail_messages'; + + $config['serialized_fields']['config'] = \JSONArrayObject::class; + + $config['has_one']['author'] = [ + 'class_name' => User::class, + 'foreign_key' => 'author_id', + 'assoc_foreign_key' => 'user_id' + ]; + $config['has_one']['sender'] = [ + 'class_name' => User::class, + 'foreign_key' => 'sender_id', + 'assoc_foreign_key' => 'user_id' + ]; + $config['has_many']['filters'] = [ + 'class_name' => MassMailFilter::class, + 'assoc_foreign_key' => 'message_id', + 'on_store' => 'store', + 'on_delete' => 'delete' + ]; + $config['has_one']['folder'] = [ + 'class_name' => Folder::class, + 'foreign_key' => 'folder_id', + 'assoc_foreign_key' => 'id', + 'on_store' => 'store', + 'on_delete' => 'delete' + ]; + $config['has_many']['tokens'] = [ + 'class_name' => MassMailToken::class, + 'assoc_foreign_key' => 'message_id', + 'on_store' => 'store', + 'on_delete' => 'delete' + ]; + + parent::configure($config); + } + + /** + * Finds all messages that are currently due to be sent. + * @return MassMailMessage[] + */ + public static function findUnsent(): array + { + return static::findBySQL( + "`is_template` = 0 + AND `sent` = 0 + AND `locked` = 0 + AND (`send_at_date` IS NULL OR `send_at_date` <= UNIX_TIMESTAMP()) + ORDER BY `mkdate`" + ); + } + + /** + * Finds all messages that have been successfully sent and can be deleted now according to their age. + * @return MassMailMessage[] + */ + public static function findObsolete(): array + { + return static::findBySQL( + "`sent` = 1 AND `is_template` = 0 AND `protected` = 0 AND `chdate` <= :threshold", + ['threshold' => time() - (Config::get()->MASSMAIL_GC_DAYS * 24 * 60 * 60)] + ); + } + + /** + * Possible targets for mass mails. + * @return array + */ + public static function getTargets(): array + { + return [ + 'all' => _('alle'), + 'students' => _('Studierende'), + 'employees' => _('Beschäftigte'), + 'lecturers' => _('Aktive Lehrende'), + 'courses' => _('Veranstaltungen'), + 'usernames' => _('Liste von Benutzernamen'), + ]; + } + + /** + * Fetches all semesters. + * @return array + */ + public static function getSemesters(): array + { + $semesters = []; + + foreach (array_reverse(Semester::getAll()) as $one) { + $semesters[$one->id] = $one->name; + } + + return $semesters; + } + + /** + * Get the folder belonging to this message. If none is found, it will be auto-created as a + * personal folder of the current user.. + * @param string $id + * @return \FolderType + */ + public function findFolder(string $id): \FolderType + { + $messageFolder = Folder::findOneBySQL( + "`range_id` = :id AND `range_type` = 'massmail'", + ['id' => $id] + ); + if (!$messageFolder) { + $messageFolder = new \StandardFolder([ + 'user_id' => User::findCurrent()->id, + 'range_id' => $id, + 'range_type' => 'massmail', + 'parent_id' => 'root', + 'name' => _('Nachricht an Zielgruppen') + ]); + $messageFolder->store(); + } else { + $messageFolder = $messageFolder->getTypedFolder(); + } + + return $messageFolder; + } + + /** + * Gets the real recipient list for this message. + * @return string[] the usernames that will get this message. + */ + public function getRecipients(): array + { + $ids = []; + + switch ($this->target) { + // Everyone studying something or working at an institute. + case 'all': + + $sql = "SELECT DISTINCT `user_id` FROM `user_studiengang`"; + $parameters = []; + if (!MassMailPermission::has($this->author_id, true)) { + + $permission = MassMailPermission::getForUser($this->author); + + $sql .= " WHERE `abschluss_id` IN (:degrees) OR `fach_id` IN (:subjects)"; + $parameters = [ + 'degrees' => $permission['allowed_degrees'], + 'subjects' => $permission['allowed_subjects'] + ]; + } + $students = DBManager::get()->fetchFirst($sql, $parameters); + + $sql = "SELECT DISTINCT `user_id` FROM `user_inst` WHERE `inst_perms` IN (:perms)"; + $parameters = ['perms' => ['autor', 'tutor', 'dozent']]; + if (!MassMailPermission::has($this->author_id, true)) { + $sql .= " AND `Institut_id` IN (:institutes)"; + $parameters = [ + 'institutes' => $permission['allowed_institutes'] + ]; + } + $employees = DBManager::get()->fetchFirst($sql, $parameters); + + $ids = array_unique(array_merge($students, $employees)); + + break; + + // Students are users with at least one studycourse assignment in user_studiengang. + case 'students': + + $sql = "SELECT DISTINCT `user_id` FROM `user_studiengang`"; + $parameters = []; + + if (!MassMailPermission::has($this->author_id, true)) { + $permission = MassMailPermission::getForUser($this->author); + + $sql .= " WHERE `abschluss_id` IN (:degrees) OR `fach_id` IN (:subjects)"; + $parameters = [ + 'degrees' => $permission['allowed_degrees'], + 'subjects' => $permission['allowed_subjects'] + ]; + } + $ids = DBManager::get()->fetchFirst($sql, $parameters); + + 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': + + $sql = "SELECT DISTINCT `user_id` FROM `user_inst` WHERE `inst_perms` IN (:perms)"; + $parameters = ['perms' => ['autor', 'tutor', 'dozent']]; + if (!MassMailPermission::has($this->author_id, true)) { + $permission = MassMailPermission::getForUser($this->author); + + $sql .= " AND `Institut_id` IN (:institutes)"; + $parameters = [ + 'institutes' => $permission->allowed_institutes ? $permission->allowed_institutes->pluck('id') : [] + ]; + } + $ids = DBManager::get()->fetchFirst($sql, $parameters); + + 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->config['courses']->getArrayCopy() + ); + $permission = $this->config['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->config['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->config['usernames'])] + ); + } + + return DBManager::get()->fetchFirst( + "SELECT DISTINCT `username` + 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) : [''] + ] + ); + } + + /** + * Checks whether this message has replacement markers in its message text. + * @param $with_tokens Check for tokens or just for "normal" markers? + * @return bool + */ + public function hasMarkers($type = 'all'): bool + { + $markers = MassMailMarker::findAndMapBySQL( + fn($m) => '{{' . $m->marker . '}}', + $type === 'all' ? "1" : "`type` = :type", + $type === 'all' ? [] : ['type' => $type] + ); + foreach ($markers as $marker) { + if (str_contains($this->message, $marker)) { + return true; + } + } + return false; + } + + /** + * Replaces serial message markers with the data of the given user. + * @param User $user + * @return string + */ + public function replaceMarkers(User $user): string + { + $text = MassMailMarker::processText($this->message, $user, $this->getMarkers()); + + if (count($this->tokens) > 0) { + $text = MassMailMarker::processToken($this->message, $text, $user); + } + + return $text; + } + + /** + * Get available serial message markers, optionally including person token markers + * @param bool $with_tokens + * @return array + */ + private function getMarkers($with_tokens = true): array + { + $found = []; + $markers = MassMailMarker::findBySQL($with_tokens ? "1" : "`type` != 'token'"); + foreach ($markers as $marker) { + if (str_contains($this->message, $marker->marker)) { + $found[] = $marker; + } + } + return $found; + } + + /** + * Get message attachments (excluding files used fot token generation) + * @return array|\FileRef[] + */ + public function getAttachments() + { + $files = []; + $folder = Folder::find($this->folder_id); + + return array_filter( + $folder->getTypedFolder()->getFiles(), + fn ($ref) => !isset($ref->file->metadata['is_token_file']) + ); + } + + /** + * @see UserFilterRange::canEdit() + */ + public function canEditFilter(User $user, UserFilter $filter): bool + { + return MassMailPermission::has($user->id, true) + || MassMailPermission::has($user->id, false) && $this->creator_id === $user->id; + + } + +} diff --git a/lib/models/MassMail/MassMailPermission.php b/lib/models/MassMail/MassMailPermission.php new file mode 100644 index 00000000000..33af43d6363 --- /dev/null +++ b/lib/models/MassMail/MassMailPermission.php @@ -0,0 +1,139 @@ +<?php + +namespace MassMail; + +class MassMailPermission extends \SimpleORMap +{ + + public const MASSMAIL_ROOT_ROLE = 'Massenmail-Root'; + + protected static function configure($config = []) + { + $config['db_table'] = 'massmail_permissions'; + + $config['belongs_to']['institute'] = [ + 'class_name' => \Institute::class, + 'foreign_key' => 'institute_id', + 'assoc_foreign_key' => 'institut_id' + ]; + + $config['has_and_belongs_to_many']['allowed_degrees'] = [ + 'class_name' => \Degree::class, + 'thru_table' => 'massmail_permission_degree', + 'thru_key' => 'permission_id', + 'thru_assoc_key' => 'degree_id', + 'on_store' => 'store', + 'on_delete' => 'delete' + ]; + + $config['has_and_belongs_to_many']['allowed_subjects'] = [ + 'class_name' => \StudyCourse::class, + 'thru_table' => 'massmail_permission_subject', + 'thru_key' => 'permission_id', + 'thru_assoc_key' => 'subject_id', + 'on_store' => 'store', + 'on_delete' => 'delete' + ]; + + $config['has_and_belongs_to_many']['allowed_institutes'] = [ + 'class_name' => \Institute::class, + 'thru_table' => 'massmail_permission_institute', + 'thru_key' => 'permission_id', + 'thru_assoc_key' => 'institute_id', + 'on_store' => 'store', + 'on_delete' => 'delete' + ]; + + $config['additional_fields']['institute_name']['get'] = function($p) { + return $p->institute->name; + }; + + parent::configure($config); + } + + /** + * Check if the given user has permissions to write mass mails. The result is cached for performance reasons. + * + * @param string $user_id user to check + * @param bool $unrestricted check for unrestricted permissions + * @return bool + */ + public static function has(string $user_id, bool $unrestricted = false) : bool + { + $cached = \Studip\Cache\Factory::getCache()->read('massmail-permission-' . $user_id); + + if ($cached !== false) { + $perm = (int) $cached; + } else { + + $perm = 0; + + // Root and users with the massmeil root role are always allowed to do anything. + if ( + $GLOBALS['perm']->have_perm('root', $user_id) + || \RolePersistence::isAssignedRole($user_id, static::MASSMAIL_ROOT_ROLE) + ) { + $perm = 2; + + // Everyone else needs at least one institute assignment with existing permissions. + } else { + // Institute memberships with existing mass mail permission settings. + $relevant = static::findBySQL( + "JOIN `user_inst` ON (`user_inst`.`institut_id` = `massmail_permissions`.`institute_id`) + WHERE `user_inst`.`inst_perms` != 'user' AND `user_inst`.`user_id` = :user", + ['user' => $user_id] + ); + foreach ($relevant as $one) { + if ($GLOBALS['perm']->have_studip_perm($one->min_perm, $one->institute_id, $user_id)) { + $perm = 1; + break; + } + } + } + + \Studip\Cache\Factory::getCache()->write('massmail-permission-' . $user_id, $perm); + } + + return $unrestricted ? $perm === 2 : $perm >= 1; + } + + /** + * @return array{ + * allowed_degrees: array, + * allowed_subjects: array, + * allowed_institutes: array + * } + */ + public static function getForUser(\User $user, bool $withNames = false): array + { + // Get user's institutes with at least autor permission. + $institutes = $user->institute_memberships->filter(function ($membership) { + return in_array($membership->inst_perms, ['autor', 'tutor', 'dozent', 'admin']); + })->pluck($withNames ? 'institut_id institute_name' : 'institut_id'); + + // Get permission configuration for these institutes. + $permissions = static::findBySQL("`institute_id` IN (:institutes)", ['institutes' => $institutes]); + $config = [ + 'allowed_degrees' => [], + 'allowed_subjects' => [], + 'allowed_institutes' => $institutes + ]; + foreach ($permissions as $permission) { + $config['allowed_degrees'] = array_merge( + $config['allowed_degrees'], + $permission->allowed_degrees->pluck($withNames ? 'id name' : 'id') + ); + $config['allowed_subjects'] = array_merge( + $config['allowed_subjects'], + $permission->allowed_subjects->pluck($withNames ? 'id name' : 'id') + ); + $config['allowed_institutes'] = array_merge( + $config['allowed_institutes'], + $permission->allowed_institutes->pluck($withNames ? 'id name' : 'id') + ); + } + + return $config; + } + +} diff --git a/lib/models/MassMail/MassMailToken.php b/lib/models/MassMail/MassMailToken.php new file mode 100644 index 00000000000..aacfe4b64aa --- /dev/null +++ b/lib/models/MassMail/MassMailToken.php @@ -0,0 +1,25 @@ +<?php + +namespace MassMail; + +class MassMailToken extends \SimpleORMap +{ + + protected static function configure($config = []) + { + $config['db_table'] = 'massmail_tokens'; + + $config['belongs_to']['message'] = [ + 'class_name' => MassMailMessage::class, + 'foreign_key' => 'message_id' + ]; + + $config['belongs_to']['user'] = [ + 'class_name' => \User::class, + 'foreign_key' => 'user_id' + ]; + + parent::configure($config); + } + +} diff --git a/lib/navigation/MessagingNavigation.php b/lib/navigation/MessagingNavigation.php index 563b7603453..77405a03b07 100644 --- a/lib/navigation/MessagingNavigation.php +++ b/lib/navigation/MessagingNavigation.php @@ -31,7 +31,7 @@ class MessagingNavigation extends Navigation parent::initItem(); $my_messaging_settings = UserConfig::get($user->id)->MESSAGING_SETTINGS; $lastVisitedTimestamp = isset($my_messaging_settings['last_box_visit'])?(int)$my_messaging_settings['last_box_visit']:0; - + $query = "SELECT SUM(mkdate > :time AND readed = 0) AS num_new, SUM(readed = 0) AS num_unread, SUM(readed = 1) AS num_read @@ -42,7 +42,7 @@ class MessagingNavigation extends Navigation $statement->bindValue(':user_id', $GLOBALS['user']->id); $statement->execute(); list($neux, $neum, $altm) = $statement->fetch(PDO::FETCH_NUM); - + $this->setBadgeNumber($neum); if ($neux > 0) { @@ -69,12 +69,35 @@ class MessagingNavigation extends Navigation public function initSubNavigation() { parent::initSubNavigation(); - + $messages = new Navigation(_('Nachrichten'), 'dispatch.php/messages/overview'); $inbox = new Navigation(_('Eingang'), 'dispatch.php/messages/overview'); $messages->addSubNavigation('inbox', $inbox); $messages->addSubNavigation('sent', new Navigation(_('Gesendet'), 'dispatch.php/messages/sent')); $this->addSubNavigation('messages', $messages); - + + if ($GLOBALS['perm']->have_perm('tutor') && \MassMail\MassMailPermission::has(User::findCurrent()->id)) { + $massmail = new Navigation(_('Nachrichten an Zielgruppen'), 'dispatch.php/massmail/message'); + $massmail->addSubNavigation( + 'message', + new Navigation(_('Nachricht schreiben'), 'dispatch.php/massmail/message') + ); + $massmail->addSubNavigation( + 'overview', + new Navigation(_('Nachrichtenübersicht'), 'dispatch.php/massmail/overview') + ); + if (\MassMail\MassMailPermission::has(User::findCurrent()->id, true)) { + $massmail->addSubNavigation( + 'permissions', + new Navigation(_('Berechtigungen'), 'dispatch.php/massmail/permissions') + ); + $massmail->addSubNavigation( + 'settings', + new Navigation(_('Einstellungen'), 'dispatch.php/massmail/settings') + ); + } + $this->addSubNavigation('massmail', $massmail); + } + } } diff --git a/resources/vue/base-components.js b/resources/vue/base-components.js index ec18d59900c..567e9ff484a 100644 --- a/resources/vue/base-components.js +++ b/resources/vue/base-components.js @@ -6,12 +6,15 @@ const BaseComponents = { Datetimepicker: () => import('./components/Datetimepicker.vue'), DayOfWeekSelect: () => import('./components/form_inputs/DayOfWeekSelect.vue'), EditableList: () => import("./components/EditableList.vue"), + FileUpload: () => import('./components/form_inputs/FileUpload.vue'), I18nTextarea: () => import("./components/I18nTextarea.vue"), Multiselect: () => import('./components/Multiselect.vue'), MyCoursesColouredTable: () => import('./components/form_inputs/MyCoursesColouredTable.vue'), Quicksearch: () => import('./components/Quicksearch.vue'), + QuicksearchListInput: () => import('./components/form_inputs/QuicksearchListInput.vue'), RangeInput: () => import('./components/RangeInput.vue'), RepetitionInput: () => import("./components/form_inputs/RepetitionInput.vue"), + SerialTextMarkers: () => import('./components/form_inputs/SerialTextMarkers.vue'), SidebarWidget: () => import('./components/SidebarWidget.vue'), StudipActionMenu: () => import('./components/StudipActionMenu.vue'), StudipAssetImg: () => import('./components/StudipAssetImg.vue'), @@ -27,6 +30,7 @@ const BaseComponents = { StudipSelect: () => import('./components/StudipSelect.vue'), StudipTooltipIcon: () => import('./components/StudipTooltipIcon.vue'), StudipWysiwyg: () => import("./components/StudipWysiwyg.vue"), + UserFilterInput: () => import('./components/form_inputs/UserFilterInput.vue') }; export default BaseComponents; diff --git a/resources/vue/components/StudipUserFilter.vue b/resources/vue/components/StudipUserFilter.vue index f9e6741d171..20ca16201f7 100644 --- a/resources/vue/components/StudipUserFilter.vue +++ b/resources/vue/components/StudipUserFilter.vue @@ -65,6 +65,14 @@ export default { filter: { type: Array, default: () => [] + }, + context: { + type: String, + default: '' + }, + target: { + type: String, + default: '' } }, data() { @@ -119,7 +127,17 @@ export default { } }, created() { - STUDIP.jsonapi.withPromises().get('user-filter-fields').then(response => { + STUDIP.jsonapi.withPromises().get( + 'user-filter-fields', + { + data: { + filter: { + context: this.context, + target: this.target + } + } + } + ).then(response => { this.availableFields = response.data; this.addField(); }); diff --git a/resources/vue/components/StudipWysiwyg.vue b/resources/vue/components/StudipWysiwyg.vue index 799c5f1fc29..3b36cc8b111 100644 --- a/resources/vue/components/StudipWysiwyg.vue +++ b/resources/vue/components/StudipWysiwyg.vue @@ -59,6 +59,8 @@ export default { if (this.shouldFocus) { this.focus(); } + + STUDIP.eventBus.emit('editor-loaded', this.createdEditor); }, onInput(value) { this.currentText = value; diff --git a/resources/vue/components/form_inputs/FileUpload.vue b/resources/vue/components/form_inputs/FileUpload.vue new file mode 100644 index 00000000000..b512c026a20 --- /dev/null +++ b/resources/vue/components/form_inputs/FileUpload.vue @@ -0,0 +1,198 @@ +<template> + <section> + <button v-show="!uploading" + class="button select" + :class="{studiprequired: required}" + @click.prevent="openFileSelect"> + <studip-icon shape="upload"></studip-icon> + <span class="textlabel"> + {{ title }} + </span> + <span v-if="required" + class="asterisk" + :title="$gettext('Dies ist ein Pflichtfeld')" + aria-hidden="true">*</span> + </button> + <div class="file-count"> + <template v-if="selectedFiles?.length === 0"> + {{ $gettext('Keine Dateien gewählt') }} + </template> + <template v-else-if="selectedFiles?.length === 1"> + {{ $gettext('Eine Datei gewählt') }} + </template> + <template v-else> + {{ $gettextInterpolate($gettext('%{number} Dateien gewählt'), { number: selectedFiles.length }) }} + </template> + </div> + <input type="file" + :name="name" + :id="id" + :multiple="multiple" + :accept="accept" + ref="files" + class="button" + @change="selectFiles"> + <button v-if="selectedFiles.length > 0" + type="button" + class="button upload" + @click.prevent="upload"> + <studip-icon shape="upload"></studip-icon> + {{ $gettext('Jetzt hochladen') }} + </button> + <div v-if="!uploading && uploadedFiles.length > 0"> + <span> + {{ $gettext('Bereits hochgeladen:') }} + </span> + <ul> + <li v-for="(file, index) in uploadedFiles" + :key="index"> + {{ file.name + ' (' + getTextualFileSize(file.size) + ')' }} + </li> + </ul> + </div> + <input type="hidden" + :name="name" + :value="targetFolder"> + <studip-progress-indicator v-if="uploading" + :size="24"> + {{ $gettext('Wird hochgeladen...') }} + </studip-progress-indicator> + </section> +</template> + +<script> +import axios from 'axios'; +import StudipProgressIndicator from "../StudipProgressIndicator.vue"; + +export default { + name: 'FileUpload', + components: {StudipProgressIndicator}, + props: { + name: { + type: String, + required: true + }, + title: { + type: String, + required: true + }, + folder: { + type: String, + required: true + }, + uploadUrl: { + type: String, + required: true + }, + id: { + type: String + }, + required: { + type: Boolean, + default: false + }, + multiple: { + type: Boolean, + default: false + }, + accept: { + type: String, + default: '*/*' + } + }, + data() { + return { + selectedFiles: [], + uploading: false, + uploadedFiles: [], + targetFolder: '' + } + }, + methods: { + upload() { + if (this.$refs.files.files.length > 0) { + this.uploading = true; + + const files = this.$refs.files.files; + + const formData = new FormData(); + + let name = this.name; + if (this.multiple) { + name += '[]'; + } + + for (let i = 0; i < files.length; i++) { + formData.append(name, files[i]); + this.uploadedFiles.push(files[i]); + } + + axios.post( + this.uploadUrl, + formData + ).then(response => { + this.uploading = false; + this.$refs.files.value = ''; + this.targetFolder = this.folder; + }).catch(error => { + this.uploadedFiles = []; + this.uploading = false; + STUDIP.Report.error(this.$gettext('Fehler beim Hochladen'), error); + }); + } + }, + getTextualFileSize(bytes) { + let unit = ''; + let context = {size: bytes}; + if (bytes < 1024) { + unit = this.$gettext('%{size} B'); + } else if (bytes < 1024 * 1024) { + unit = this.$gettext('%{size} KB'); + context.size = (bytes / 1024).toFixed(2); + } else { + unit = this.$gettext('%{size} MB'); + context.size = (bytes / (1024 * 1024)).toFixed(2); + } + + return this.$gettextInterpolate(unit, context); + }, + openFileSelect() { + this.$refs.files.click(); + }, + selectFiles() { + this.selectedFiles = this.$refs.files.files; + } + } +} +</script> + +<style lang="scss" scoped> +input[type=file] { + display: none; +} +button { + margin-top: 0; + margin-bottom: 0; + padding: 5px 10px; + + img { + margin-right: 5px; + vertical-align: text-bottom; + } +} +.select { + margin-right: 0; +} +.file-count { + border: solid thin var(--light-gray-color-40); + border-left: unset; + display: inline-block; + margin-left: -4px; + padding: 5px 10px 4px 10px; + position: relative; + top: 2px; +} +.upload { + margin-left: 15px; +} +</style> diff --git a/resources/vue/components/form_inputs/QuicksearchListInput.vue b/resources/vue/components/form_inputs/QuicksearchListInput.vue new file mode 100644 index 00000000000..4a3e21c4cd3 --- /dev/null +++ b/resources/vue/components/form_inputs/QuicksearchListInput.vue @@ -0,0 +1,97 @@ +<template> + <div> + <quicksearch :searchtype="searchtype" + :autocomplete="autocomplete" + @input="addElement"></quicksearch> + <table v-if="elements.length > 0" ref="results" class="default"> + <tbody> + <tr v-for="(element, index) in elements" + :key="element.id"> + <td> + {{ element.name }} + </td> + <td class="actions"> + <a @click="removeElement(index)" + :title="$gettext('Dieses Element entfernen')"> + <studip-icon shape="trash"></studip-icon> + </a> + </td> + </tr> + </tbody> + </table> + <input type="hidden" + :name="name" + :value="realValue" + > + </div> +</template> + +<script> +import quicksearch from '../Quicksearch.vue'; + +export default { + name: 'QuicksearchList', + components: [ quicksearch ], + props: { + name: { + type: String, + required: true + }, + value: { + type: String, + default: '' + }, + searchtype: { + type: String, + required: true + }, + autocomplete: { + type: Boolean, + default: false + } + }, + data() { + return { + elements: [] + } + }, + computed: { + realValue() { + this.$emit('input', JSON.stringify(this.elements)); + return JSON.stringify(this.elements); + } + }, + methods: { + addElement(id, name) { + if (!this.elements.map(e => e.id).includes(id)) { + const element = { + id: id, + name: name + }; + this.elements.push(element); + } + }, + removeElement(index) { + this.elements.splice(index, 1); + } + }, + created() { + if (this.value !== '') { + this.elements = JSON.parse(this.value); + } + } +} +</script> + +<style lang="scss" scoped> +table.default { + margin-bottom: unset; + margin-top: 15px; + width: 50%; + + .actions { + text-align: right; + } +} + +</style> diff --git a/resources/vue/components/form_inputs/SerialTextMarkers.vue b/resources/vue/components/form_inputs/SerialTextMarkers.vue new file mode 100644 index 00000000000..20e542dab79 --- /dev/null +++ b/resources/vue/components/form_inputs/SerialTextMarkers.vue @@ -0,0 +1,80 @@ +<template> + <div> + <label class="col-3"> + {{ $gettext('Feld für Serienmail einfügen') }} + <select v-model="selectedMarker"> + <option value=""> + -- {{ $gettext('Feld zum Einfügen auswählen') }} -- + </option> + <option v-for="(marker, index) in markers" + :key="index" + :value="marker.marker" + :data-description="marker.description"> + {{ marker.name }} + </option> + </select> + </label> + <button class="button col-3 insert-marker-button" + :title="$gettext('Feld einfügen')" + :disabled="selectedMarker === ''" + @click.prevent="insertMarker"> + {{ $gettext('In den Text einfügen') }} + </button> + <p v-if="selectedMarker !== ''"> + {{ description }} + </p> + </div> +</template> + +<script> +export default { + name: 'SerialTextMarkers', + props: { + markers: { + type: Array, + required: true + }, + editor: { + type: String, + required: true + } + }, + data() { + return { + editorInstance: null, + selectedMarker: '' + } + }, + computed: { + description() { + return this.markers.find((m) => { return m.marker === this.selectedMarker; }).description; + } + }, + methods: { + insertMarker() { + this.editorInstance.model.change(writer => { + writer.insertText( + ' {{' + this.selectedMarker + '}}', + this.editorInstance.model.document.selection.getFirstPosition() + ); + }); + } + }, + mounted() { + STUDIP.eventBus.on('editor-loaded', editor => { + if (document.getElementById(this.editor) === editor.sourceElement) { + this.editorInstance = editor; + } + }); + }, + destroyed() { + STUDIP.eventBus.off('editor-loaded'); + } +} +</script> + +<style scoped> +button { + vertical-align: bottom; +} +</style> diff --git a/resources/vue/components/form_inputs/UserFilterInput.vue b/resources/vue/components/form_inputs/UserFilterInput.vue new file mode 100644 index 00000000000..d63e97b2f22 --- /dev/null +++ b/resources/vue/components/form_inputs/UserFilterInput.vue @@ -0,0 +1,146 @@ +<template> + <div class="formpart"> + <section v-if="filters.length > 0" class="default userfilter-list"> + <header> + <h2> + {{ $gettext('Mindestens ein Filter muss zutreffen') }} + </h2> + </header> + <table class="default"> + <tbody> + <tr v-for="(filter, index) in filters" + :key="index" + class="userfilter"> + <td v-html="filter.attributes.text"></td> + <td class="actions"> + <a class="undecorated" + @click.prevent="deleteFilter(index)" + :title="$gettext('Diesen Filter löschen')" + tabindex="0"> + <studip-icon shape="trash"></studip-icon> + </a> + </td> + </tr> + </tbody> + </table> + </section> + <button class="button" + type="button" + @click.prevent="editFilter(0)"> + {{ $gettext('Filter hinzufügen') }} + </button> + <studip-user-filter v-if="currentFilter !== null" + :filter="currentFilter !== 0 ? filters[currentFilter] : []" + :context="context" + :target="target" + @submit="submitFilter" + @close="closeFilter"></studip-user-filter> + </div> +</template> + +<script> +import StudipUserFilter from '../StudipUserFilter.vue'; + +export default { + name: 'UserFilterInput', + components: {StudipUserFilter}, + props: { + name: { + type: String, + required: true + }, + value: String, + context: { + type: String, + default: '' + }, + target: { + type: String, + default: 'all' + } + }, + data() { + return { + key: 0, + currentFilter: null, + filters: [], + stringified: '' + } + }, + methods: { + editFilter(index) { + this.currentFilter = index; + }, + submitFilter(filter) { + STUDIP.jsonapi.withPromises().post( + 'user-filters', + { + data: { + data: { + attributes: { + filters: filter + } + } + } + }) + .then(response => { + if (this.currentFilter !== 0) { + this.filters[this.currentFilter] = response.data; + } else { + this.filters.push(response.data); + } + this.currentFilter = null; + this.changed(); + }) + .catch(error => { + STUDIP.Report.error(this.$gettext('Es ist ein Fehler aufgetreten'), error); + }); + }, + closeFilter() { + this.currentFilter = null; + }, + deleteFilter(index) { + this.filters.splice(index, 1); + this.changed(); + }, + actionMenuItems(index) { + return [ + { + id: 'edit', + label: this.$gettext('Bearbeiten'), + icon: 'edit', + emit: 'edit', + emitArguments: index + }, + { + id: 'delete', + label: this.$gettext('Löschen'), + icon: 'trash', + emit: 'delete', + emitArguments: index + } + ]; + }, + changed() { + this.stringified = JSON.stringify(this.filters); + this.$emit('input', this.stringified); + } + }, + mounted() { + if (this.value) { + this.filters = JSON.parse(this.value); + } + } +} +</script> + +<style lang="scss" scoped> +table.default { + margin-bottom: unset; + width: 50%; + + .actions { + text-align: right; + } +} +</style> diff --git a/resources/vue/components/massmail/MassMailMessagesList.vue b/resources/vue/components/massmail/MassMailMessagesList.vue new file mode 100644 index 00000000000..9d71445e7e0 --- /dev/null +++ b/resources/vue/components/massmail/MassMailMessagesList.vue @@ -0,0 +1,153 @@ +<template> + <div> + <studip-progress-indicator v-if="loading" + :size="32" + /> + <table v-else-if="messages.data?.length > 0" class="default"> + <colgroup> + <col> + <col> + <col> + <col> + <col style="width: 200px"> + <col style="width: 20px"> + </colgroup> + <thead> + <tr> + <th>{{ $gettext('Betreff') }}</th> + <th>{{ $gettext('Nachricht') }}</th> + <th>{{ $gettext('Erstellt von') }}</th> + <th>{{ $gettext('Zielgruppe') }}</th> + <th>{{ $gettext('Letzte Änderung') }}</th> + <th>{{ $gettext('Aktionen') }}</th> + </tr> + </thead> + <tbody> + <tr v-for="(message, index) in messages.data" + :key="index" + > + <td>{{ message.attributes.subject }}</td> + <td v-html="message.attributes.message"></td> + <td>{{ getAuthor(message.relationships.author.data.id).attributes['formatted-name'] }}</td> + <td>{{ message.attributes.target }}</td> + <td>{{ message.attributes.chdate }}</td> + <td> + <studip-action-menu :items="actionMenuItems" + @edit="editMessage(message.id)" + @delete="deleteMessage(message.id)"></studip-action-menu> + </td> + </tr> + </tbody> + </table> + <studip-message-box v-else + type="info"> + {{ $gettext('Es wurden keine Nachrichten gefunden.') }} + </studip-message-box> + <mounting-portal mount-to="#message-views"> + <sidebar-widget id="views-widget" class="sidebar-widget" :title="$gettext('Ansichten')"> + <template #content> + <ul class="widget-list widget-links sidebar-views" + :aria-label="$gettext('Ansichten')"> + <li id="index" :class="{ active: 'unsent' === currentView}"> + <a :href="url('dispatch.php/massmail/overview')" + @click.prevent="setCurrentView('unsent')"> + {{ $gettext('Zum Versand anstehend') }} + </a> + </li> + <li id="index" :class="{ active: 'templates' === currentView}"> + <a :href="url('dispatch.php/massmail/overview')" + @click.prevent="setCurrentView('templates')"> + {{ $gettext('Meine Vorlagen') }} + </a> + </li> + <li id="index" :class="{ active: 'protected' === currentView}"> + <a :href="url('dispatch.php/massmail/overview')" + @click.prevent="setCurrentView('protected')"> + {{ $gettext('Geschützt') }} + </a> + </li> + </ul> + </template> + </sidebar-widget> + </mounting-portal> + </div> +</template> + +<script> +import StudipProgressIndicator from '../StudipProgressIndicator.vue'; +import StudipActionMenu from '../StudipActionMenu.vue'; +import SidebarWidget from '../SidebarWidget.vue'; + +export default { + name: 'MassMailMessagesList', + components: { SidebarWidget, StudipActionMenu, StudipProgressIndicator }, + data() { + return { + loading: false, + messages: {}, + currentView: 'unsent' + } + }, + computed: { + actionMenuItems() { + return [ + { label: this.$gettext('Bearbeiten'), icon: 'edit', emit: 'edit'}, + { label: this.$gettext('Löschen'), icon: 'trash', emit: 'delete'} + ]; + } + }, + methods: { + getMessages() { + this.loading = true; + + const data = { include: 'author'}; + + switch (this.currentView) { + case 'templates': + data.filter = {templates: 1}; + break; + case 'protected': + data.filter = {protected: 1}; + break; + } + + STUDIP.jsonapi.withPromises().get('mass-mails/messages', {data: data}) + .then(response => { + this.messages = response; + this.loading = false; + }) + .catch(error => { + this.messages = []; + STUDIP.Report.error(this.$gettext('Es ist ein Fehler aufgetreten'), error); + this.loading = false; + }); + }, + getAuthor(id) { + const result = this.messages.included.filter(entry => entry.id === id); + return result?.length > 0 ? result[0] : null; + }, + editMessage(id) { + window.location = STUDIP.URLHelper.getURL('dispatch.php/massmail/message/index/' + id); + }, + deleteMessage(id) { + if (STUDIP.Dialog.confirm( + this.$gettext('Soll diese Nachricht wirklich gelöscht werden?'), + () => { + window.location = STUDIP.URLHelper.getURL('dispatch.php/massmail/message/delete/' + id); + }, + STUDIP.Dialog.close()) + ); + }, + url(target) { + return STUDIP.URLHelper.getURL(target); + }, + setCurrentView(view) { + this.currentView = view; + this.getMessages(); + } + }, + created() { + this.getMessages(); + } +} +</script> diff --git a/resources/vue/components/massmail/MassMailPermissions.vue b/resources/vue/components/massmail/MassMailPermissions.vue new file mode 100644 index 00000000000..6342427f4ee --- /dev/null +++ b/resources/vue/components/massmail/MassMailPermissions.vue @@ -0,0 +1,108 @@ +<template> + <div> + <table v-if="!loading && permissions?.data.length > 0" + class="default"> + <colgroup> + <col> + <col width="20%"> + <col width="30%"> + <col width="24"> + </colgroup> + <thead> + <tr> + <th>{{ $gettext('Einrichtung') }}</th> + <th>{{ $gettext('Benötigte Rechte') }}</th> + <th>{{ $gettext('Erlaubte Zielgruppen') }}</th> + <th>{{ $gettext('Aktionen') }}</th> + </tr> + </thead> + <tbody> + <tr v-for="(permission) in permissions.data" :key="permission.id"> + <td> + {{ getInstitute(permission).attributes.name }} + </td> + <td> + {{ permission.attributes['min-perm']}} + </td> + <td> + <div v-if="permission.meta['allowed-degrees-count'] > 0"> + {{ $gettextInterpolate($gettext('%{degrees} Abschlüsse'), { degrees: permission.meta['allowed-degrees-count']}) }} + </div> + <div v-if="permission.meta['allowed-subjects-count'] > 0"> + {{ $gettextInterpolate($gettext('%{subjects} Fächer'), { subjects: permission.meta['allowed-subjects-count']}) }} + </div> + <div v-if="permission.meta['allowed-institutes-count'] > 0"> + {{ $gettextInterpolate($gettext('%{institutes} Einrichtungen'), { institutes: permission.meta['allowed-institutes-count']}) }} + </div> + </td> + <td> + <studip-action-menu :items="actionMenuItems" + @edit="editPermission(permission.id)" + @delete="deletePermission(permission.id)"></studip-action-menu> + </td> + </tr> + </tbody> + </table> + <studip-message-box v-if="!loading && permissions.data.length === 0" type="info"> + {{ $gettext('Es sind keine Berechtigungen für Personen ohne Root-Rechte konfiguriert.') }} + </studip-message-box> + <studip-progress-indicator v-if="loading"></studip-progress-indicator> + </div> +</template> + +<script> +import StudipProgressIndicator from "../StudipProgressIndicator.vue"; +import StudipActionMenu from "../StudipActionMenu.vue"; + +export default { + name: 'MassMailPermissions', + components: {StudipActionMenu, StudipProgressIndicator}, + data() { + return { + loading: true, + permissions: [] + } + }, + computed: { + actionMenuItems() { + return [ + { label: this.$gettext('Bearbeiten'), icon: 'edit', emit: 'edit'}, + { label: this.$gettext('Löschen'), icon: 'trash', emit: 'delete'} + ]; + } + }, + methods: { + getInstitute(permission) { + const institute = this.permissions.included.filter(entry => { + return entry.id === permission.relationships.institute.data.id; + }); + return institute.at(0); + }, + editPermission(id) { + STUDIP.Dialog.fromURL( + STUDIP.URLHelper.getURL('dispatch.php/massmail/permissions/edit/' + id) + ); + }, + deletePermission(id) { + if (STUDIP.Dialog.confirm( + this.$gettext('Soll diese Berechtigung wirklich gelöscht werden?'), + () => { + window.location = STUDIP.URLHelper.getURL('dispatch.php/massmail/permissions/delete/' + id); + location.reload(); + }, + STUDIP.Dialog.close()) + ); + } + }, + created() { + STUDIP.jsonapi.GET('mass-mails/permissions', { data: { include: 'institute'}}) + .then(response => { + this.permissions = response; + this.loading = false; + }) + .fail(error => { + STUDIP.Report.error(error); + }); + } +} +</script> diff --git a/templates/forms/checkbox_collection_input.php b/templates/forms/checkbox_collection_input.php new file mode 100644 index 00000000000..9e6dec34921 --- /dev/null +++ b/templates/forms/checkbox_collection_input.php @@ -0,0 +1,33 @@ +<?php +/** + * @var bool $collapsible + * @var string $title + * @var array $options + * @var bool $required + * @var string $name + * @var array $selected + * @var array $attributes + */ +?> +<fieldset<?= $collapsable ? ' class="collapsable collapsed"' : '' ?>> + <legend><?= htmlReady($title) ?></legend> + <? foreach ($options as $id => $displayname): ?> + <label<?= $required ? ' class="studiprequired"' : '' ?>> + <input type="checkbox" + v-model="<?= htmlReady($name) ?>" + name="<?= htmlReady($name) ?>[]" + value="<?= $id ?>" + class="<?= htmlReady($name . '-selector') ?>" + id="<?= $id ?>" + <?= $required ? 'required aria-required="true"' : '' ?> + <?= in_array($id, $selected) ? 'selected' : '' ?> + <?= $attributes ?>> + <span class="textlabel"> + <?= htmlReady($displayname) ?> + </span> + <? if ($required) : ?> + <span class="asterisk" title="<?= _('Dies ist ein Pflichtfeld') ?>" aria-hidden="true">*</span> + <? endif ?> + </label> + <? endforeach ?> +</fieldset> diff --git a/templates/forms/fieldset.php b/templates/forms/fieldset.php index 491f7260ea1..25a9739396b 100644 --- a/templates/forms/fieldset.php +++ b/templates/forms/fieldset.php @@ -1,4 +1,12 @@ -<fieldset> +<?php +/** + * @var bool $collapsable + * @var bool $collapsed + * @var string $legend + * @var array<\Studip\Forms\Part> $part + */ +?> +<fieldset<?= $collapsable ? ' class="collapsable' . ($collapsed ? ' collapsed' : '') . '"' : '' ?>> <? if ($legend) : ?> <legend><?= htmlReady($this->legend) ?></legend> <? endif ?> diff --git a/templates/forms/file_input.php b/templates/forms/file_input.php new file mode 100644 index 00000000000..2441b522cdf --- /dev/null +++ b/templates/forms/file_input.php @@ -0,0 +1,11 @@ +<div class="formpart" data-form-input-for="<?= htmlReady($name) ?>"> + <file-upload + name="<?= htmlReady($name) ?>" + title="<?= htmlReady($title) ?>" + upload-url="<?= htmlReady($uploadUrl) ?>" + folder="<?= htmlReady($value) ?>" + id="<?= htmlReady($id) ?>" + :multiple="<?= $multiple ? 'true' : 'false' ?>" + accept="<?= htmlReady($accept) ?>" + <?= $required ? ':required="true"' : '' ?>></file-upload> +</div> diff --git a/templates/forms/form.php b/templates/forms/form.php index b3679740825..00e4c7df6d4 100644 --- a/templates/forms/form.php +++ b/templates/forms/form.php @@ -37,6 +37,7 @@ $form_id = md5(uniqid()); data-required="<?= htmlReady(json_encode($required_inputs)) ?>" data-server_validation="<?= $server_validation ? 1 : 0?>" data-validation_url="<?= htmlReady($_SERVER['REQUEST_URI']) ?>" + <?= $form->hasFileInput() ? ' enctype="application/x-www-form-urlencoded"' : '' ?> class="default studipform<?= $form->isCollapsable() ? ' collapsable' : '' ?>"> <?= CSRFProtection::tokenTag(['ref' => 'securityToken']) ?> diff --git a/templates/forms/quicksearchlist_input.php b/templates/forms/quicksearchlist_input.php new file mode 100644 index 00000000000..167ec938cb8 --- /dev/null +++ b/templates/forms/quicksearchlist_input.php @@ -0,0 +1,18 @@ +<div class="formpart" data-form-input-for="<?= htmlReady($name) ?>"> + <label<?= $required ? ' class="studiprequired"' : '' ?> for="<?= htmlReady($id) ?>"> + <span class="textlabel"> + <?= htmlReady($title) ?> + </span> + <? if ($required) : ?> + <span class="asterisk" title="<?= _('Dies ist ein Pflichtfeld') ?>" aria-hidden="true">*</span> + <? endif ?> + <quicksearch-list-input + name="<?= htmlReady($name) ?>" + id="<?= htmlReady($id) ?>" + <?= $required ? 'required aria-required="true"' : '' ?> + value="<?= htmlReady($value) ?>" + v-model="<?= htmlReady($name) ?>" + <?= $attributes ?> + ></quicksearch-list-input> + </label> +</div> diff --git a/templates/forms/radio_input.php b/templates/forms/radio_input.php index da110d1f830..f7636d19cf6 100644 --- a/templates/forms/radio_input.php +++ b/templates/forms/radio_input.php @@ -1,17 +1,21 @@ <div class="formpart"> <section <?= $this->orientation == 'horizontal' ? 'class="hgroup"' : '' ?> id="<?= htmlReady($id) ?>"> - <span class="textlabel"> + <span class="textlabel<?= $required ? ' studiprequired' : '' ?> "> <?= htmlReady($this->title) ?> + <? if ($required) : ?> + <span class="asterisk" title="<?= _('Dies ist ein Pflichtfeld') ?>" aria-hidden="true">*</span> + <? endif ?> </span> - <? foreach ($options as $key => $option) : ?> + <? $count = 0; foreach ($options as $key => $option) : ?> <label class="" <?= $attributes ?>> <input type="radio" - name="<?= htmlReady($this->name) ?>" - v-model="<?= htmlReady($this->name) ?>" - value="<?= htmlReady($key) ?>" <?= $key == $value ? 'checked' : '' ?>> + name="<?= htmlReady($name) ?>" + v-model="<?= htmlReady($name) ?>" + value="<?= htmlReady($key) ?>" <?= $key == $value ? 'checked' : '' ?> + <?= $required && $count === 0 ? ' required' : ''?>> <?= htmlReady($option) ?> </label> - <? endforeach ?> + <? $count++; endforeach ?> </section> </div> diff --git a/templates/forms/serial_wysiwyg_input.php b/templates/forms/serial_wysiwyg_input.php new file mode 100644 index 00000000000..f67a0c14f45 --- /dev/null +++ b/templates/forms/serial_wysiwyg_input.php @@ -0,0 +1,17 @@ +<div class="formpart" data-form-input-for="<?= htmlReady($name) ?>"> + <label<?= $this->required ? ' class="studiprequired"' : '' ?> for="<?= htmlReady($id) ?>"> + <span class="textlabel"> + <?= htmlReady($this->title) ?> + </span> + <? if ($this->required) : ?> + <span class="asterisk" title="<?= _('Dies ist ein Pflichtfeld') ?>" aria-hidden="true">*</span> + <? endif ?> + </label> + <serial-text-markers :markers="<?= htmlReady($markers) ?>" editor="<?= htmlReady($id) ?>"></serial-text-markers> + <studip-wysiwyg + id="<?= htmlReady($id) ?>" + v-model="<?= htmlReady($name) ?>" + value="<?= htmlReady($value) ?>" + <?= $required ? 'required' : '' ?>> + </studip-wysiwyg> +</div> diff --git a/templates/forms/textarea_input.php b/templates/forms/textarea_input.php index 2afa96c3810..8effff1fa46 100644 --- a/templates/forms/textarea_input.php +++ b/templates/forms/textarea_input.php @@ -1,13 +1,15 @@ -<label<?= ($this->required ? ' class="studiprequired"' : '') ?> for="<?= $id ?>"> - <span class="textlabel"> - <?= htmlReady($this->title) ?> - </span> - <? if ($this->required) : ?> - <span class="asterisk" title="<?= _('Dies ist ein Pflichtfeld') ?>" aria-hidden="true">*</span> - <? endif ?> -</label> -<textarea name="<?= htmlReady($name) ?>" - v-model="<?= htmlReady($name) ?>" - id="<?= $id ?>" - <?= ($required ? 'required aria-required="true"' : '') ?> - <?= $attributes ?>><?= htmlReady($value) ?></textarea> +<div class="formpart" data-form-input-for="<?= htmlReady($name) ?>"> + <label<?= ($this->required ? ' class="studiprequired"' : '') ?> for="<?= $id ?>"> + <span class="textlabel"> + <?= htmlReady($this->title) ?> + </span> + <? if ($this->required) : ?> + <span class="asterisk" title="<?= _('Dies ist ein Pflichtfeld') ?>" aria-hidden="true">*</span> + <? endif ?> + </label> + <textarea name="<?= htmlReady($name) ?>" + v-model="<?= htmlReady($name) ?>" + id="<?= $id ?>" + <?= ($required ? 'required aria-required="true"' : '') ?> + <?= $attributes ?>><?= htmlReady($value) ?></textarea> +</div> diff --git a/templates/forms/user_filter_input.php b/templates/forms/user_filter_input.php new file mode 100644 index 00000000000..20d9386a63b --- /dev/null +++ b/templates/forms/user_filter_input.php @@ -0,0 +1,19 @@ +<div class="formpart" data-form-input-for="<?= htmlReady($name) ?>"> + <label<?= $required ? ' class="studiprequired"' : '' ?> for="<?= htmlReady($id) ?>"> + <span class="textlabel"> + <?= htmlReady($title) ?> + </span> + <? if ($required) : ?> + <span class="asterisk" title="<?= _('Dies ist ein Pflichtfeld') ?>" aria-hidden="true">*</span> + <? endif ?> + <user-filter-input + name="<?= htmlReady($name) ?>" + id="<?= htmlReady($id) ?>" + <?= $required ? 'required aria-required="true"' : '' ?> + value="<?= htmlReady($value) ?>" + v-model="<?= htmlReady($name) ?>" + <?= $attributes ?> + ></user-filter-input> + </label> + +</div> -- GitLab